From 3d3be7465fd4415f85964d05e6af8defdff68292 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 27 Jul 2026 10:03:17 +0100 Subject: [PATCH] fix(admin): re-key legacy plans before seeding, tolerant of a prior partial boot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boot ran SeedPlans before Backfill, so the fresh (self_hosted, professional) seed row was inserted before pass 3 tried to rename the legacy self_hosted TIER row into it — colliding on deployment_tier_unique and failing boot. The plan re-key moves to MigrateLegacyPlans, called before SeedPlans so the rename lands first and the seed no-ops on it. It is also self-healing: on a database a crashed boot already seeded (self_hosted, professional) into, the legacy row can no longer be renamed onto it, so it is dropped instead — its instances are re-tiered and re-entitled from the surviving professional row. Co-Authored-By: Claude Opus 5 --- admin/cmd/main.go | 7 ++ admin/internal/models/backfill.go | 132 +++++++++++++++++++++--------- 2 files changed, 99 insertions(+), 40 deletions(-) diff --git a/admin/cmd/main.go b/admin/cmd/main.go index 2eb80e7..d883f19 100644 --- a/admin/cmd/main.go +++ b/admin/cmd/main.go @@ -64,6 +64,13 @@ func main() { idxCancel() log.Fatalf("indexes: %v", err) } + // Legacy plans are re-keyed BEFORE the seed, so the seed's fresh + // (self_hosted, professional) row cannot collide with the legacy self_hosted + // row's rename on deployment_tier_unique. + if err := models.MigrateLegacyPlans(idxCtx); err != nil { + idxCancel() + log.Fatalf("migrate legacy plans: %v", err) + } if err := models.SeedPlans(idxCtx); err != nil { idxCancel() log.Fatalf("plan seed: %v", err) diff --git a/admin/internal/models/backfill.go b/admin/internal/models/backfill.go index 6c3d8d5..2660dd3 100644 --- a/admin/internal/models/backfill.go +++ b/admin/internal/models/backfill.go @@ -2,6 +2,7 @@ package models import ( "context" + "errors" "log" "time" @@ -13,6 +14,93 @@ import ( "go.mongodb.org/mongo-driver/v2/mongo" ) +// MigrateLegacyPlans re-keys the pre-spec-7 plan rows and MUST run before +// SeedPlans. +// +// The original three rows were keyed on tier alone: (cloud,free), +// (cloud,professional) and a self_hosted TIER row. Spec 7 re-keys on +// (deployment, tier), makes self_hosted a deployment rather than a tier, and +// renames limits/features to base_limits/base_features. +// +// Ordering is the whole point. SeedPlans inserts a fresh (self_hosted, +// professional) row; if the legacy self_hosted row is only renamed afterwards it +// collides with that seed on deployment_tier_unique. Running here, before the +// seed, the rename lands first and the seed then no-ops on it. +// +// It is idempotent and self-healing: on a database where a previous boot already +// seeded (self_hosted, professional) before crashing, the legacy row can no +// longer be renamed onto it, so the legacy row is dropped instead — its +// instances are re-tiered and re-entitled from the surviving professional row. +func MigrateLegacyPlans(ctx context.Context) error { + // Legacy cloud rows may predate the deployment field entirely. + if _, err := db.Admin("plans").UpdateMany(ctx, + bson.M{"deployment": bson.M{"$exists": false}, + "tier": bson.M{"$in": bson.A{license.TierFree, license.TierProfessional}}}, + bson.M{"$set": bson.M{"deployment": license.DeploymentCloud}}); err != nil { + return err + } + + // The legacy self_hosted TIER row becomes self-hosted Professional. If that + // target already exists (a prior partial boot seeded it), drop the legacy row + // rather than colliding — the seeded row carries the same professional base. + var legacy Plan + err := db.Admin("plans").FindOne(ctx, bson.M{"tier": license.TierSelfHosted}).Decode(&legacy) + switch { + case err == nil: + targetErr := db.Admin("plans").FindOne(ctx, + bson.M{"deployment": license.DeploymentSelfHosted, "tier": license.TierProfessional}).Err() + if targetErr == nil { + if _, err := db.Admin("plans").DeleteOne(ctx, bson.M{"_id": legacy.ID}); err != nil { + return err + } + log.Printf("backfill: dropped legacy self_hosted plan row; (self_hosted, professional) already present") + } else if errors.Is(targetErr, mongo.ErrNoDocuments) { + if _, err := db.Admin("plans").UpdateOne(ctx, + bson.M{"_id": legacy.ID}, + bson.M{"$set": bson.M{ + "deployment": license.DeploymentSelfHosted, + "tier": license.TierProfessional, + "name": "Professional", + }}); err != nil { + return err + } + log.Printf("backfill: re-keyed legacy self_hosted plan to (self_hosted, professional)") + } else { + return targetErr + } + case errors.Is(err, mongo.ErrNoDocuments): + // No legacy row; a fresh database or an already-migrated one. + default: + return err + } + + // limits/features become base_limits/base_features on any row still carrying + // the old names. + if _, err := db.Admin("plans").UpdateMany(ctx, + bson.M{"limits": bson.M{"$exists": true}}, + bson.M{"$rename": bson.M{"limits": "base_limits", "features": "base_features"}}); err != nil { + return err + } + + // Support level is new, so nothing has one. Fill from the seed table rather + // than guessing: a plan row a human edited keeps every other field. + for _, deployment := range license.Deployments() { + for _, tier := range license.Tiers() { + p, ok := license.PlanFor(deployment, tier) + if !ok { + continue + } + if _, err := db.Admin("plans").UpdateOne(ctx, + bson.M{"deployment": deployment, "tier": tier, + "support_level": bson.M{"$in": bson.A{nil, ""}}}, + bson.M{"$set": bson.M{"support_level": p.SupportLevel}}); err != nil { + return err + } + } + } + return nil +} + // Backfill brings pre-phase-3 data up to the membership model. // // It runs on every boot and is idempotent by construction: both passes filter @@ -98,46 +186,10 @@ func Backfill(ctx context.Context) error { log.Printf("backfill: recorded %d pre-existing instance owners", created) } - // Pass 3: plans were keyed on tier alone. Give the two cloud tiers their - // deployment, and turn the self_hosted TIER row into the self-hosted - // Professional row it always was. Field names move too: limits/features - // become base_limits/base_features, because "base" is a different claim. - if _, err := db.Admin("plans").UpdateMany(ctx, - bson.M{"deployment": bson.M{"$exists": false}, - "tier": bson.M{"$in": bson.A{license.TierFree, license.TierProfessional}}}, - bson.M{"$set": bson.M{"deployment": license.DeploymentCloud}}); err != nil { - return err - } - if _, err := db.Admin("plans").UpdateOne(ctx, - bson.M{"tier": license.TierSelfHosted}, - bson.M{"$set": bson.M{ - "deployment": license.DeploymentSelfHosted, - "tier": license.TierProfessional, - "name": "Professional", - }}); err != nil { - return err - } - if _, err := db.Admin("plans").UpdateMany(ctx, - bson.M{"limits": bson.M{"$exists": true}}, - bson.M{"$rename": bson.M{"limits": "base_limits", "features": "base_features"}}); err != nil { - return err - } - // Support level is new, so nothing has one. Fill from the seed table rather - // than guessing: a plan row a human edited keeps every other field. - for _, deployment := range license.Deployments() { - for _, tier := range license.Tiers() { - p, ok := license.PlanFor(deployment, tier) - if !ok { - continue - } - if _, err := db.Admin("plans").UpdateOne(ctx, - bson.M{"deployment": deployment, "tier": tier, - "support_level": bson.M{"$in": bson.A{nil, ""}}}, - bson.M{"$set": bson.M{"support_level": p.SupportLevel}}); err != nil { - return err - } - } - } + // Pass 3 (the plan re-key) now runs in MigrateLegacyPlans, called from main + // BEFORE SeedPlans. It has to: SeedPlans inserts a fresh + // (self_hosted, professional) row, and if the legacy self_hosted TIER row is + // only renamed afterwards it collides with that seed on deployment_tier_unique. // Pass 4: instances carrying the self_hosted TIER move to Professional. // Their deployment already says self_hosted, so only the tier is wrong.