fix(admin): re-key legacy plans before seeding, tolerant of a prior partial boot
Server Deploy / deploy (push) Successful in 1m5s
Server Deploy / deploy (push) Successful in 1m5s
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user