diff --git a/admin/cmd/main.go b/admin/cmd/main.go index bada1f4..6c03493 100644 --- a/admin/cmd/main.go +++ b/admin/cmd/main.go @@ -17,6 +17,7 @@ import ( "github.com/mrhid6/vantage/admin/internal/db" "github.com/mrhid6/vantage/admin/internal/inject" "github.com/mrhid6/vantage/admin/internal/licensing" + "github.com/mrhid6/vantage/admin/internal/lifecycle" "github.com/mrhid6/vantage/admin/internal/mail" "github.com/mrhid6/vantage/admin/internal/models" ) @@ -72,6 +73,9 @@ func main() { defer stopReconcile() inject.StartReconciler(reconcileCtx) + lifecycle.SetPortalURL(cfg.PublicURL) + lifecycle.Start(reconcileCtx, cfg.ReapAfter) + srv := &http.Server{ Addr: cfg.Addr, Handler: api.Routes(cfg), diff --git a/admin/internal/config/config.go b/admin/internal/config/config.go index 6842b57..bcf8c8a 100644 --- a/admin/internal/config/config.go +++ b/admin/internal/config/config.go @@ -10,6 +10,7 @@ import ( "net/url" "os" "strings" + "time" ) type Config struct { @@ -26,6 +27,7 @@ type Config struct { AllowedOrigins []string TrustProxy bool Addr string + ReapAfter time.Duration SMTPHost string SMTPPort string @@ -108,6 +110,18 @@ func Load() (Config, error) { c.AllowedOrigins = append(c.AllowedOrigins, o) } } + + // Mirrors the control plane's FREE_INSTANCE_REAP_AFTER so notice emails can + // name the real deletion date. An unparseable value is refused rather than + // silently treated as "off": a typo here would quietly stop every deletion + // warning while the control plane still deletes. + if v := os.Getenv("FREE_INSTANCE_REAP_AFTER"); v != "" { + d, err := time.ParseDuration(v) + if err != nil { + return Config{}, fmt.Errorf("FREE_INSTANCE_REAP_AFTER %q: %w", v, err) + } + c.ReapAfter = d + } return c, nil } diff --git a/admin/internal/lifecycle/lifecycle.go b/admin/internal/lifecycle/lifecycle.go new file mode 100644 index 0000000..e2fe31d --- /dev/null +++ b/admin/internal/lifecycle/lifecycle.go @@ -0,0 +1,175 @@ +// Package lifecycle marks lapsed Free instances and sends the renewal notices. +// +// It sends; it never deletes. Deletion belongs to the control plane, which is +// the only service that knows what an instance is made of. The two are kept +// apart on purpose: a bug here sends a wrong email, a bug there loses data. +package lifecycle + +import ( + "context" + "log" + "slices" + "time" + + "github.com/mrhid6/vantage/admin/internal/db" + "github.com/mrhid6/vantage/admin/internal/mail" + "github.com/mrhid6/vantage/admin/internal/models" + "github.com/mrhid6/vantage/shared/license" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// Interval is how often the sweep runs. Hourly is far finer than the daily +// granularity of the notices, which means a notice goes out within an hour of +// becoming due rather than up to a day late. +const Interval = time.Hour + +// Notice keys, recorded on the instance so a restart cannot re-send one. +const ( + noticeExpiring = "expiring" + noticeExpired = "expired" + noticeDelete7 = "delete_7" + noticeDelete1 = "delete_1" +) + +// portalURL is the customer portal address used in notice emails. +var portalURL string + +// SetPortalURL is called once at boot. +func SetPortalURL(v string) { portalURL = v } + +// reapAfter mirrors the control plane's FREE_INSTANCE_REAP_AFTER so the emails +// can name the real deletion date. Zero means the reaper is off, and the +// deletion notices are then suppressed — promising a deletion that will never +// happen would be a lie, and a scarier one than saying nothing. +var reapAfter time.Duration + +// Run performs one sweep: mark lapsed instances, then send whatever notices are +// due. Errors on one instance never stop the others. +func Run(ctx context.Context) error { + now := time.Now().UTC() + + cur, err := db.Admin("admin_instances").Find(ctx, bson.M{ + "deployment": license.DeploymentCloud, + "tier": license.TierFree, + "status": bson.M{"$in": []string{models.StatusActive, models.StatusLapsed}}, + }) + if err != nil { + return err + } + var instances []models.Instance + if err := cur.All(ctx, &instances); err != nil { + return err + } + + for _, inst := range instances { + var lic models.License + if err := db.Admin("licenses").FindOne(ctx, + bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err != nil { + continue // no licence yet; nothing to expire + } + + // Flip active -> lapsed once the licence is past its expiry. + if now.After(lic.ExpiresAt) && inst.Status == models.StatusActive { + if _, err := db.Admin("admin_instances").UpdateOne(ctx, + bson.M{"instance_id": inst.InstanceID}, + bson.M{"$set": bson.M{"status": models.StatusLapsed}}); err != nil { + log.Printf("lifecycle: mark %s lapsed: %v", inst.InstanceID, err) + } + } + + due := dueNotice(now, lic.ExpiresAt, inst.NoticesSent) + if due == "" { + continue + } + if err := sendNotice(ctx, inst, lic, due); err != nil { + log.Printf("lifecycle: notice %s for %s: %v", due, inst.InstanceID, err) + continue + } + if _, err := db.Admin("admin_instances").UpdateOne(ctx, + bson.M{"instance_id": inst.InstanceID}, + bson.M{"$addToSet": bson.M{"notices_sent": due}}); err != nil { + log.Printf("lifecycle: record notice %s for %s: %v", due, inst.InstanceID, err) + } + } + return nil +} + +// dueNotice returns the most urgent unsent notice, or "". +// +// Most urgent first, so an instance that was missed for a week — because admin +// was down — sends the one that matters now rather than working through a +// backlog of stale warnings. +func dueNotice(now, expires time.Time, sent []string) string { + deleteOn := expires.Add(reapAfter) + + if reapAfter > 0 { + if now.After(deleteOn.Add(-24*time.Hour)) && !slices.Contains(sent, noticeDelete1) { + return noticeDelete1 + } + if now.After(deleteOn.Add(-7*24*time.Hour)) && !slices.Contains(sent, noticeDelete7) { + return noticeDelete7 + } + } + if now.After(expires) && !slices.Contains(sent, noticeExpired) { + return noticeExpired + } + if now.After(expires.Add(-models.RenewWindow)) && !slices.Contains(sent, noticeExpiring) { + return noticeExpiring + } + return "" +} + +func sendNotice(ctx context.Context, inst models.Instance, lic models.License, key string) error { + if !mail.Enabled() { + return nil + } + var acct models.Account + if err := db.Admin("accounts").FindOne(ctx, + bson.M{"account_id": inst.AccountID}).Decode(&acct); err != nil { + return err + } + to := acct.BillingEmail + deleteOn := lic.ExpiresAt.Add(reapAfter) + + switch key { + case noticeExpiring: + return mail.SendExpiring(to, inst.Name, portalURL, lic.ExpiresAt) + case noticeExpired: + return mail.SendExpired(to, inst.Name, portalURL, deleteOn) + case noticeDelete7: + return mail.SendDeletionWarning(to, inst.Name, portalURL, deleteOn, 7) + case noticeDelete1: + return mail.SendDeletionWarning(to, inst.Name, portalURL, deleteOn, 1) + } + return nil +} + +// Start runs the sweep on a ticker until ctx is cancelled. +// +// reapAfterDur must match the control plane's FREE_INSTANCE_REAP_AFTER. If they +// disagree, the emails name a date the reaper does not honour — so they are +// documented as a pair in CLAUDE.md and set together in the compose file. +func Start(ctx context.Context, reapAfterDur time.Duration) { + reapAfter = reapAfterDur + go func() { + runOnce(ctx) + t := time.NewTicker(Interval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + runOnce(ctx) + } + } + }() +} + +func runOnce(ctx context.Context) { + runCtx, cancel := context.WithTimeout(ctx, 5*time.Minute) + defer cancel() + if err := Run(runCtx); err != nil { + log.Printf("lifecycle: %v", err) + } +} diff --git a/admin/internal/mail/mail.go b/admin/internal/mail/mail.go index 043bd7b..bc6e6a4 100644 --- a/admin/internal/mail/mail.go +++ b/admin/internal/mail/mail.go @@ -80,3 +80,38 @@ func SendRenewed(to, instanceName string, expires time.Time) error { fmt.Sprintf("%s is renewed.\n\nYour Free licence now runs until %s.\n", instanceName, expires.Format("2 January 2006"))) } + +// SendExpiring is the renew-now nudge, seven days out. +func SendExpiring(to, instanceName, portalURL string, expires time.Time) error { + return send(to, instanceName+" expires on "+expires.Format("2 January"), + fmt.Sprintf("%s's Free licence runs out on %s.\n\n"+ + "Renew it in one click:\n\n%s\n\n"+ + "If you do nothing, the instance keeps running but stops accepting changes.\n", + instanceName, expires.Format("2 January 2006"), portalURL)) +} + +// SendExpired states plainly what has stopped and what happens next. +// +// It names the deletion date rather than a vague warning: the whole point of the +// sequence is that nobody loses an instance without having been told a date. +func SendExpired(to, instanceName, portalURL string, deleteOn time.Time) error { + return send(to, instanceName+" is now read-only", + fmt.Sprintf("%s's Free licence has expired.\n\n"+ + "Your servers and monitors keep running and your agents keep their keys, "+ + "but changes are disabled.\n\n"+ + "Renew it here:\n\n%s\n\n"+ + "If it is not renewed, the instance and everything in it will be deleted on %s.\n", + instanceName, portalURL, deleteOn.Format("2 January 2006"))) +} + +// SendDeletionWarning is the final countdown, sent at seven days and one day. +func SendDeletionWarning(to, instanceName, portalURL string, deleteOn time.Time, daysLeft int) error { + when := fmt.Sprintf("in %d days", daysLeft) + if daysLeft <= 1 { + when = "tomorrow" + } + return send(to, instanceName+" will be deleted "+when, + fmt.Sprintf("%s and everything in it will be deleted %s, on %s.\n\n"+ + "This cannot be undone. Renew it here to keep it:\n\n%s\n", + instanceName, when, deleteOn.Format("2 January 2006"), portalURL)) +}