From a636a48e07feda84cb4a466392c3eab693596f72 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Sat, 25 Jul 2026 19:02:45 +0100 Subject: [PATCH] feat(admin): cloud injection and the 15-minute reconciler Injection is a single UpdateOne of three licence fields, so it is idempotent and safe to re-run. The control plane caches licence state for 60 seconds, so an injected licence takes effect within a minute with no restart. Deliver never fails its caller. The reconciler, not the issuance path, is what actually guarantees a cloud instance ends up holding the licence admin says it holds -- injection at issue time is best-effort and this is the backstop. Co-Authored-By: Claude Opus 5 --- admin/cmd/main.go | 5 ++ admin/internal/inject/inject.go | 150 ++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 admin/internal/inject/inject.go diff --git a/admin/cmd/main.go b/admin/cmd/main.go index 9698c35..b755cbc 100644 --- a/admin/cmd/main.go +++ b/admin/cmd/main.go @@ -13,6 +13,7 @@ import ( "github.com/joho/godotenv" "github.com/mrhid6/vantage/admin/internal/config" "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/models" ) @@ -46,6 +47,10 @@ func main() { } idxCancel() + reconcileCtx, stopReconcile := context.WithCancel(context.Background()) + defer stopReconcile() + inject.StartReconciler(reconcileCtx) + srv := &http.Server{ Addr: cfg.Addr, Handler: http.NotFoundHandler(), // replaced in Task 8 diff --git a/admin/internal/inject/inject.go b/admin/internal/inject/inject.go new file mode 100644 index 0000000..6d04e86 --- /dev/null +++ b/admin/internal/inject/inject.go @@ -0,0 +1,150 @@ +// Package inject writes licences onto control-plane instance documents. +// +// This is admin's ONLY write path into the control plane, and it touches exactly +// three fields on one collection. If this package ever grows a second write +// target, that is a design change and not a refactor. +package inject + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/mrhid6/vantage/admin/internal/db" + "github.com/mrhid6/vantage/admin/internal/models" + "github.com/mrhid6/vantage/shared/license" + sharedmodels "github.com/mrhid6/vantage/shared/models" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// ReconcileInterval is how often every cloud instance is compared against what +// admin believes it should hold. +// +// This job, not the issuance path, is what guarantees eventual consistency. +// Injection at issue time is best-effort; this is the backstop. +const ReconcileInterval = 15 * time.Minute + +// Cloud writes the licence onto the control-plane instance document. +// +// Idempotent and safe to re-run: it is a single UpdateOne of three fields with +// no read-modify-write. Retries three times with backoff. +// +// The control plane caches licence state for 60 seconds, so this takes effect +// within a minute with no restart. +func Cloud(ctx context.Context, lic *models.License) error { + set := bson.M{"$set": bson.M{ + "license_blob": lic.Blob, + "license_tier": lic.Tier, + "license_expiry": lic.ExpiresAt, + }} + + var lastErr error + for attempt := 1; attempt <= 3; attempt++ { + res, err := db.Control("instances").UpdateOne(ctx, + bson.M{"instance_id": lic.InstanceID}, set) + if err == nil { + if res.MatchedCount == 0 { + return fmt.Errorf("no control-plane instance %s", lic.InstanceID) + } + return nil + } + lastErr = err + time.Sleep(time.Duration(attempt) * 2 * time.Second) + } + return fmt.Errorf("inject after 3 attempts: %w", lastErr) +} + +// Deliver injects and records the outcome without ever failing the caller. +// +// A licence that is recorded but not injected is recoverable — the reconciler +// will fix it within 15 minutes, and staff can see it on the health endpoint. +// Failing the purchase because one write failed would be worse. +func Deliver(ctx context.Context, lic *models.License) { + if err := Cloud(ctx, lic); err != nil { + log.Printf("INJECTION FAILED instance=%s licence=%s: %v", lic.InstanceID, lic.LicenseID, err) + now := time.Now().UTC() + _, _ = db.Admin("admin_instances").UpdateOne(ctx, + bson.M{"instance_id": lic.InstanceID}, + bson.M{"$set": bson.M{"inject_failed_at": now}}) + return + } + _, _ = db.Admin("admin_instances").UpdateOne(ctx, + bson.M{"instance_id": lic.InstanceID}, + bson.M{"$unset": bson.M{"inject_failed_at": ""}}) +} + +// Reconcile compares every active cloud instance's current licence against the +// blob actually stored in the control plane, and re-injects on mismatch. +func Reconcile(ctx context.Context) (checked, repaired int, err error) { + cur, err := db.Admin("admin_instances").Find(ctx, bson.M{ + "deployment": license.DeploymentCloud, + "status": models.StatusActive, + "current_license": bson.M{"$ne": ""}, + }) + if err != nil { + return 0, 0, err + } + var instances []models.Instance + if err := cur.All(ctx, &instances); err != nil { + return 0, 0, err + } + + for _, inst := range instances { + checked++ + + var lic models.License + if err := db.Admin("licenses").FindOne(ctx, + bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err != nil { + log.Printf("reconcile: instance %s references unknown licence %s", inst.InstanceID, inst.CurrentLicense) + continue + } + + var remote sharedmodels.Instance + if err := db.Control("instances").FindOne(ctx, + bson.M{"instance_id": inst.InstanceID}).Decode(&remote); err != nil { + log.Printf("reconcile: no control-plane instance %s: %v", inst.InstanceID, err) + continue + } + + if remote.LicenseBlob == lic.Blob { + continue + } + + log.Printf("reconcile: repairing instance %s (licence %s)", inst.InstanceID, lic.LicenseID) + if err := Cloud(ctx, &lic); err != nil { + log.Printf("reconcile: repair failed for %s: %v", inst.InstanceID, err) + continue + } + repaired++ + _, _ = db.Admin("admin_instances").UpdateOne(ctx, + bson.M{"instance_id": inst.InstanceID}, + bson.M{"$unset": bson.M{"inject_failed_at": ""}}) + } + return checked, repaired, nil +} + +// StartReconciler runs Reconcile on a ticker until ctx is cancelled. +func StartReconciler(ctx context.Context) { + go func() { + t := time.NewTicker(ReconcileInterval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + runCtx, cancel := context.WithTimeout(ctx, 5*time.Minute) + checked, repaired, err := Reconcile(runCtx) + cancel() + if err != nil { + log.Printf("reconcile: %v", err) + continue + } + if repaired > 0 { + log.Printf("reconcile: checked %d, repaired %d", checked, repaired) + } + } + } + }() +}