feat(admin): lapse sweep and the four renewal notices

Hourly sweep marks expired Free instances lapsed and sends at most one
notice per instance per pass, most urgent first, recorded on the document
so a restart cannot re-send.

Deletion warnings are suppressed when the reaper is off. Promising a
deletion that will never happen is a lie, and a scarier one than silence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrhid6
2026-07-26 13:20:00 +01:00
co-authored by Claude Opus 5
parent 909ddb884e
commit 372a8c5ddf
4 changed files with 228 additions and 0 deletions
+175
View File
@@ -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)
}
}