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

+59 -6
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"os"
"strconv"
"strings"
@@ -338,17 +339,23 @@ func applySMTPConfig(ctx context.Context, in smtpInputs) error {
if err := applyFelisConfigSecret(ctx); err != nil {
return err
}
// Refresh the workload-namespace copies too (the reaper's warning path): the
// OTP path is already live in the control namespace, so a replica miss is
// reported but not fatal.
if err := replicateSMTPToWorkloadNamespace(ctx, in.password); err != nil {
fmt.Fprintf(os.Stderr, "felis setup: warning: email is configured, but refreshing the workload copies failed (pre-reap warning emails may stay suppressed): %v\n", err)
}
if err := kubectl(ctx, "-n", "felis", "rollout", "restart", "deployment/felis-api"); err != nil {
return err
}
return kubectl(ctx, "-n", "felis", "rollout", "status", "deployment/felis-api", "--timeout=180s")
}
// applySMTPSecret creates (or replaces) the felis-smtp Secret the felis-api
// Deployment injects the relay password from. Rendered in-process and piped to
// `kubectl apply` — the password is never a command-line arg, so it never
// appears in the host process table.
func applySMTPSecret(ctx context.Context, password string) error {
// smtpSecretManifest renders the felis-smtp Secret (in the control namespace,
// via the caller's apply) the felis-api Deployment injects the relay password
// from. Rendered in-process and piped to `kubectl apply` — the password is
// never a command-line arg, so it never appears in the host process table.
func smtpSecretManifest(password string) ([]byte, error) {
secret := &corev1.Secret{
TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Secret"},
ObjectMeta: metav1.ObjectMeta{Name: platform.SMTPSecretName, Namespace: "felis"},
@@ -359,7 +366,53 @@ func applySMTPSecret(ctx context.Context, password string) error {
}
manifest, err := yaml.Marshal(secret)
if err != nil {
return fmt.Errorf("render smtp secret: %w", err)
return nil, fmt.Errorf("render smtp secret: %w", err)
}
return manifest, nil
}
func applySMTPSecret(ctx context.Context, password string) error {
manifest, err := smtpSecretManifest(password)
if err != nil {
return err
}
return kubectlWithInput(ctx, manifest, "apply", "-f", "-")
}
// replicateSMTPToWorkloadNamespace refreshes the workload-namespace (minecraft)
// copies of felis-smtp and felis-config after email is reconfigured. The
// reaper's CronJob runs there and resolves both by local reference — a
// secretKeyRef is namespace-local, and `felis setup` creates the felis-config
// replica create-if-absent, so without this refresh a later SMTP change would
// never reach the pre-reap warning emails. Deliberately OVERWRITES both: these
// are mirrors of the control-namespace sources, and a stale mirror is exactly
// the failure this closes.
func replicateSMTPToWorkloadNamespace(ctx context.Context, password string) error {
cfg, err := config.Load(hostSetupConfigPath)
if err != nil {
return err
}
ns := cfg.K8s.Namespace
if ns == "" || ns == "felis" {
return nil
}
smtpManifest, err := smtpSecretManifest(password)
if err != nil {
return err
}
if err := kubectlWithInput(ctx, smtpManifest, "-n", ns, "apply", "-f", "-"); err != nil {
return fmt.Errorf("replicate %s to %s: %w", platform.SMTPSecretName, ns, err)
}
manifest, err := kubectlOutput(ctx,
"-n", ns, "create", "secret", "generic", "felis-config",
"--from-file=felis.toml="+podSetupConfigPath,
"--dry-run=client", "-o", "yaml",
)
if err != nil {
return fmt.Errorf("render felis-config for %s: %w", ns, err)
}
if err := kubectlWithInput(ctx, manifest, "-n", ns, "apply", "-f", "-"); err != nil {
return fmt.Errorf("replicate felis-config to %s: %w", ns, err)
}
return nil
}