fix(reaper): deliver pre-reap warnings for real — and never fake a delivery

The §18 warning path had no delivery channel at all: no Warner implementation
existed, `felis reaper` passed nil, and maybeWarn still stamped warned_3d_at/
warned_1d_at and counted `warned=N`. So every owned server was silently reaped
15 days after its last join with no notice, and the operator's only feedback
said warnings were sent. Two changes close that:

- Honest stamps: warned_* now records a DELIVERED notice. A nil Warner logs
  `warning suppressed — no warner wired` and does NOT stamp; a delivery error
  logs and retries on the next daily run (bounded by the warning window). The
  stamps are no longer burned by notices nobody received.

- A real channel: mail.SendNotice (the second and last message shape the mail
  package sends) plus a mailWarner that resolves the owner's VERIFIED email
  and mails the notice through the configured [smtp] relay. `felis reaper`
  wires it when [smtp] is set (same password_ref convention as felis-api) and
  prints exactly what happens when it is not.

Plumbing so the in-cluster CronJob can actually reach the relay: the reaper
pod gets the optional FELIS_SMTP_PASSWORD env (same Secret as felis-api), and
the "configure email" screen now refreshes the minecraft-namespace mirrors of
felis-smtp AND felis-config (a secretKeyRef is namespace-local, and the config
mirror is what carries [smtp] into the reaper's own config). `felis setup`'s
replica list gains felis-smtp for fresh installs.

Tests: the delivered/retried/suppressed matrix in internal/reaper (the old
"stamp advances on failure" contract is deliberately replaced), the notice
message shape, the warner's resolve/send/failure paths, and the CronJob's
optional-secret env. docs/troubleshooting.md §10 now states the real semantics.
This commit is contained in:
Lemon-miaow committed 2026-09-23 03:47:19 +08:00
1 parent 1d0ec61c9d
commit 8e7c7bbf24
12 files changed
+375 -27

No files matched your search

+14
View File
@@ -536,6 +536,20 @@ func reaperCronJob(p Params) *batchv1.CronJob {
"--config", configFilePath,
"--worlds-root", worldsMountPath,
},
// The [smtp] relay password for pre-reap warning emails — same optional
// Secret felis-api reads. Namespace caveat: a secretKeyRef is
// namespace-local, so this resolves against the minecraft-ns felis-smtp
// mirror that the "configure email" screen refreshes (the felis-config
// mirror it also refreshes is what puts [smtp] in this pod's config).
// Absent Secret ⇒ empty env ⇒ the reaper logs suppressed warnings
// instead of stamping them (never a failed pod).
Env: []corev1.EnvVar{
{Name: SMTPPasswordEnv, ValueFrom: &corev1.EnvVarSource{SecretKeyRef: &corev1.SecretKeySelector{
LocalObjectReference: corev1.LocalObjectReference{Name: SMTPSecretName},
Key: SMTPSecretPasswordKey,
Optional: boolPtr(true),
}}},
},
VolumeMounts: []corev1.VolumeMount{
{Name: configVolume, MountPath: configMountPath, ReadOnly: true},
{Name: worldsVolume, MountPath: worldsMountPath, ReadOnly: true},
+13
View File
@@ -729,6 +729,19 @@ func TestReaperCronJob_Shape(t *testing.T) {
t.Errorf("reaper image = %q, want FelisImage %q", c.Image, p.FelisImage)
}
// The relay password for pre-reap warning emails: same optional Secret as
// felis-api, resolved against the minecraft-ns mirror. Optional so an install
// without SMTP still starts (the reaper then logs suppressed warnings).
smtpEnv := envVar(c.Env, SMTPPasswordEnv)
if smtpEnv == nil || smtpEnv.ValueFrom == nil || smtpEnv.ValueFrom.SecretKeyRef == nil {
t.Fatalf("reaper must wire %s from a secretKeyRef", SMTPPasswordEnv)
}
if ref := smtpEnv.ValueFrom.SecretKeyRef; ref.Name != SMTPSecretName || ref.Key != SMTPSecretPasswordKey {
t.Errorf("reaper %s ref = %s/%s, want %s/%s", SMTPPasswordEnv, ref.Name, ref.Key, SMTPSecretName, SMTPSecretPasswordKey)
} else if ref.Optional == nil || !*ref.Optional {
t.Errorf("reaper %s secretKeyRef must be optional", SMTPPasswordEnv)
}
// config: Secret, mounted read-only (it carries the DB URL).
cfgVol := volumeByName(ps.Volumes, configVolume)
if cfgVol == nil || cfgVol.Secret == nil || cfgVol.Secret.SecretName != configSecretName {