diff --git a/server/cmd/main.go b/server/cmd/main.go index 12c8a22..e57d8ef 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -59,6 +59,13 @@ func main() { log.Fatalf("scoped collection check failed: %v", assertErr) } + gfCtx, gfCancel := context.WithTimeout(context.Background(), 2*time.Minute) + gfErr := services.MigrateGrandfatherLicences(gfCtx, db.Database) + gfCancel() + if gfErr != nil { + log.Fatalf("licence grandfather migration failed: %v", gfErr) + } + if err := services.EnsureAuthIndexes(); err != nil { log.Fatalf("failed to ensure auth indexes: %v", err) } diff --git a/server/internal/services/migrate_licence.go b/server/internal/services/migrate_licence.go new file mode 100644 index 0000000..c600f67 --- /dev/null +++ b/server/internal/services/migrate_licence.go @@ -0,0 +1,102 @@ +package services + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + "time" + + "github.com/mrhid6/vantage/shared/license" + "github.com/mrhid6/vantage/shared/models" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +// MigrateGrandfatherLicences stores pre-issued licences on instances that have +// none. Cloud only. +// +// The blobs are supplied through VANTAGE_GRANDFATHER_BLOBS, a JSON object +// mapping instance_id to licence blob, because this process cannot sign: it +// holds no private key and the signing code is compiled out. Cut the blobs +// beforehand with lkctl. +// +// The variable is single-use. Unset it on the next deploy. +func MigrateGrandfatherLicences(ctx context.Context, db *mongo.Database) error { + const marker = "0005_grandfather_licences" + + if n, _ := db.Collection("migrations").CountDocuments(ctx, bson.M{"_id": marker}); n > 0 { + return nil + } + if DeploymentMode() != license.DeploymentCloud { + log.Printf("0005: not a cloud deployment, skipping") + return nil + } + + raw := os.Getenv("VANTAGE_GRANDFATHER_BLOBS") + if raw == "" { + log.Printf("0005: VANTAGE_GRANDFATHER_BLOBS not set, skipping (no marker recorded)") + return nil + } + + var blobs map[string]string + if err := json.Unmarshal([]byte(raw), &blobs); err != nil { + return fmt.Errorf("0005: VANTAGE_GRANDFATHER_BLOBS is not valid JSON: %w", err) + } + + cur, err := db.Collection("instances").Find(ctx, bson.M{ + "$or": []bson.M{ + {"license_blob": bson.M{"$exists": false}}, + {"license_blob": ""}, + }, + }) + if err != nil { + return fmt.Errorf("0005: list instances: %w", err) + } + var instances []models.Instance + if err := cur.All(ctx, &instances); err != nil { + return fmt.Errorf("0005: decode instances: %w", err) + } + + var stored, missing int + for _, inst := range instances { + blob, ok := blobs[inst.InstanceID] + if !ok || blob == "" { + log.Printf("0005: no blob supplied for instance %s (%s)", inst.InstanceID, inst.Slug) + missing++ + continue + } + + res := license.Verify(blob, license.VerifyOpts{ + InstanceID: inst.InstanceID, + Deployment: license.DeploymentCloud, + }) + if res.State == license.StateInvalid { + return fmt.Errorf("0005: blob for instance %s is rejected: %s", inst.InstanceID, res.Reason) + } + + if _, err := db.Collection("instances").UpdateOne(ctx, + bson.M{"instance_id": inst.InstanceID}, + bson.M{"$set": bson.M{ + "license_blob": blob, + "license_tier": res.License.Tier, + "license_expiry": res.License.ExpiresAt, + }}); err != nil { + return fmt.Errorf("0005: store blob for %s: %w", inst.InstanceID, err) + } + log.Printf("0005: stored %s licence for instance %s (%s), expires %s", + res.License.Tier, inst.InstanceID, inst.Slug, + res.License.ExpiresAt.Format(time.RFC3339)) + stored++ + } + + if missing > 0 { + return fmt.Errorf("0005: %d instance(s) had no blob supplied; issue them with lkctl and rerun", missing) + } + + _, err = db.Collection("migrations").InsertOne(ctx, + bson.M{"_id": marker, "applied_at": time.Now()}) + log.Printf("0005: grandfathered %d instance(s)", stored) + return err +}