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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user