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 <noreply@anthropic.com>
This commit is contained in:
mrhid6
2026-07-25 19:02:45 +01:00
co-authored by Claude Opus 5
parent 427191b14c
commit a636a48e07
2 changed files with 155 additions and 0 deletions
+5
View File
@@ -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
+150
View File
@@ -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)
}
}
}
}()
}