Unverified Commit 8e7c7bbf authored by Lemon-miaow's avatar Lemon-miaow
Browse files

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.
parent 1d0ec61c
Loading
Loading
Loading
Loading
+77 −0
Changes for cmd/felis/reaper.go: 77 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -2,6 +2,8 @@ package main

import (
	"context"
	"database/sql"
	"errors"
	"flag"
	"fmt"
	"io"
@@ -14,6 +16,8 @@ import (
	"felis.lolicon.best/internal/apis/felis/v1alpha1"
	"felis.lolicon.best/internal/backup"
	"felis.lolicon.best/internal/config"
	"felis.lolicon.best/internal/mail"
	"felis.lolicon.best/internal/platform"
	"felis.lolicon.best/internal/reaper"
	"felis.lolicon.best/internal/store"
	corev1 "k8s.io/api/core/v1"
@@ -81,6 +85,47 @@ func cmdReaper(args []string, stdout, stderr io.Writer) int {
		Archiver: archiver,
	}

	// Pre-reap warnings go out by email when [smtp] is configured (the same
	// relay and password_ref convention felis-api uses); without it the channel
	// stays nil and the reaper logs each suppressed warning instead of stamping
	// it, so a later SMTP setup still gets to warn. The owner must have a
	// VERIFIED address — that flag is what proves the mailbox.
	if cfg.SMTP.Host != "" {
		passRef := cfg.SMTP.PasswordRef
		if passRef == "" {
			passRef = platform.SMTPPasswordEnv
		}
		password := os.Getenv(passRef)
		if cfg.SMTP.Username != "" && password == "" {
			fmt.Fprintf(stderr, "felis reaper: warning: [smtp] username is set but credentials env %s is empty — warning emails will fail AUTH\n", passRef)
		}
		db := drv.DB()
		r.Warner = &mailWarner{
			lookupEmail: func(ctx context.Context, ownerID string) (string, error) {
				var email string
				switch err := db.QueryRowContext(ctx,
					`SELECT email FROM users
					 WHERE id = $1 AND email_verified = true AND COALESCE(email, '') <> ''`,
					ownerID).Scan(&email); {
				case errors.Is(err, sql.ErrNoRows):
					return "", fmt.Errorf("owner %s has no verified email", ownerID)
				case err != nil:
					return "", err
				}
				return email, nil
			},
			notifier: &mail.SMTP{
				Host:     cfg.SMTP.Host,
				Port:     cfg.SMTP.Port,
				From:     cfg.SMTP.From,
				Username: cfg.SMTP.Username,
				Password: password,
			},
		}
	} else {
		fmt.Fprintln(stderr, "felis reaper: [smtp] not configured — pre-reap warnings are logged and NOT marked sent")
	}

	sum, err := r.RunOnce(ctx)
	if err != nil {
		fmt.Fprintf(stderr, "felis reaper: %v\n", err)
@@ -91,6 +136,38 @@ func cmdReaper(args []string, stdout, stderr io.Writer) int {
	return 0
}

// mailWarner delivers a pre-reap notice to the owner's verified email — the
// only channel this build can reach. Unowned owners and owners who never proved
// a mailbox yield an error; the reaper retries such notices on its next run and
// never lets them block the reap (red line ⑤).
type mailWarner struct {
	lookupEmail func(ctx context.Context, ownerID string) (string, error)
	notifier    noticeNotifier
}

// noticeNotifier is the slice of mail.SMTP the warner needs (injected in tests).
type noticeNotifier interface {
	SendNotice(ctx context.Context, email, subject, body string) error
}

func (w *mailWarner) Warn(ctx context.Context, ownerID, server, remaining string) error {
	email, err := w.lookupEmail(ctx, ownerID)
	if err != nil {
		return fmt.Errorf("resolve owner email: %w", err)
	}
	subject := fmt.Sprintf("Felis: 服务器 %s 将在 %s 后回收 · server reaped in %s", server, remaining, remaining)
	body := fmt.Sprintf(
		"Felis 世界回收提醒 / world-reaper notice\r\n"+
			"\r\n"+
			"服务器 / Server: %s\r\n"+
			"距回收 / Time left: %s\r\n"+
			"\r\n"+
			"闲置的服务器会先自动备份,再释放世界;有人加入游戏即可重置倒计时。\r\n"+
			"Idle servers are backed up and then released; any join resets the countdown.\r\n",
		server, remaining)
	return w.notifier.SendNotice(ctx, email, subject, body)
}

// reaperConfig derives the reaper's retention windows from felis.toml. The 15d
// idle deadline is fixed by §18; only the warning offsets, retention, and the
// store soft-cap are configurable (§24).
+46 −0
Changes for cmd/felis/reaper_test.go: 46 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -2,6 +2,7 @@ package main

import (
	"context"
	"errors"
	"os"
	"path/filepath"
	"strings"
@@ -75,3 +76,48 @@ func TestResolveWorldDir(t *testing.T) {
		}
	})
}

// The pre-reap warner resolves the owner's VERIFIED email and hands the notice
// to the mailer. Every failure (no verified address, relay refusal) returns an
// error so the reaper retries on its next run instead of stamping a notice
// nobody received.
func TestMailWarner(t *testing.T) {
	lookup := func(email string, err error) func(context.Context, string) (string, error) {
		return func(context.Context, string) (string, error) { return email, err }
	}

	n := &captureNotifier{}
	w := &mailWarner{lookupEmail: lookup("[email protected]", nil), notifier: n}
	if err := w.Warn(context.Background(), "u1", "survival", "3d"); err != nil {
		t.Fatalf("Warn: %v", err)
	}
	if n.email != "[email protected]" || !strings.Contains(n.subject, "survival") || !strings.Contains(n.subject, "3d") {
		t.Fatalf("notice envelope = (%q, %q)", n.email, n.subject)
	}
	if !strings.Contains(n.body, "survival") || !strings.Contains(n.body, "3d") {
		t.Fatalf("body missing server/remaining:\n%s", n.body)
	}

	w = &mailWarner{lookupEmail: lookup("", errors.New("owner u2 has no verified email")), notifier: n}
	if err := w.Warn(context.Background(), "u2", "survival", "3d"); err == nil || !strings.Contains(err.Error(), "verified email") {
		t.Fatalf("unverified owner = %v, want the lookup error surfaced", err)
	}

	w = &mailWarner{lookupEmail: lookup("[email protected]", nil), notifier: &captureNotifier{err: errors.New("relay down")}}
	if err := w.Warn(context.Background(), "u1", "survival", "3d"); err == nil || !strings.Contains(err.Error(), "relay down") {
		t.Fatalf("relay failure = %v, want it surfaced", err)
	}
}

type captureNotifier struct {
	email, subject, body string
	err                  error
}

func (n *captureNotifier) SendNotice(_ context.Context, email, subject, body string) error {
	if n.err != nil {
		return n.err
	}
	n.email, n.subject, n.body = email, subject, body
	return nil
}
+7 −0
Changes for cmd/felis/setup.go: 7 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -246,6 +246,13 @@ func provisionSystemServers(ctx context.Context, cfg *config.Config, out io.Writ
			naming.ForwardingSecretName, naming.ForwardingSecretKey, "forwarding-secret", "minecraft ns"),
		ensureSecretReplica(ctx, cl, controlNS, cfg.K8s.Namespace,
			"felis-config", "felis.toml", "config", "minecraft ns"),
		// The reaper's pre-reap warning emails authenticate with the same relay
		// password felis-api uses; the reaper pod runs in the minecraft namespace,
		// where a secretKeyRef resolves only against a local mirror. Skipped while
		// the relay is not configured yet — the "configure email" screen refreshes
		// both mirrors when it applies.
		ensureSecretReplica(ctx, cl, controlNS, cfg.K8s.Namespace,
			"felis-smtp", "password", "smtp", "minecraft ns"),
		// The build namespace needs the same token: the build Job's fetch
		// initContainer reads the submission context from the internal face. Best
		// effort — a deployment that only installs the control plane simply never
+5 −4
Changes for cmd/felis/systemservers.go: 5 added lines, 4 removed lines.
Original line number Diff line number Diff line
@@ -478,11 +478,12 @@ func phaseOrPending(p v1alpha1.Phase) string {
// beside the control plane — so without this replica the secretKeyRef would dangle and
// wedge the pod in CreateContainerConfigError.
//
// Two Secrets need it, for different reasons: the service token (the login limbo and
// the build Pod's context fetch — both authenticate to the felis-api internal face)
// and the Velocity modern-forwarding secret (every backend — it is how a backend knows
// Three Secrets need it, for different reasons: the service token (the login limbo and
// the build Pod's context fetch — both authenticate to the felis-api internal face),
// the Velocity modern-forwarding secret (every backend — it is how a backend knows
// a login really came from the proxy, and so that the player's UUID is Mojang-verified
// rather than offline-derived).
// rather than offline-derived), and the SMTP relay password (the reaper's pre-reap
// warning emails; the felis-config mirror is what carries [smtp] into its pod).
//
// It is create-if-absent: an existing replica is left untouched so a hand-rotated
// value in the workload namespace is never clobbered (to rotate, delete the replica
+59 −6
Changes for cmd/felis/tui_smtp.go: 59 added lines, 6 removed lines.
Original line number Diff line number Diff line
@@ -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
}
Loading