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:
12 files changed
+375
-27
No files matched your search
@@ -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).
|
||||
|
||||
Reference in new issue
Block a user