package provision import ( "context" "errors" "fmt" "time" "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models" "github.com/google/uuid" "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo" ) // ErrNameRejected wraps every reason a name cannot become an instance. var ErrNameRejected = errors.New("organisation name rejected") const maxSlugAttempts = 50 // CreateInstance inserts an instance under the first free slug derived from name, // with a freshly generated ID. func CreateInstance(ctx context.Context, db *mongo.Database, name string) (*models.Instance, error) { return CreateInstanceWithID(ctx, db, uuid.NewString(), name) } // CreateInstanceWithID inserts an instance under the first free slug derived from // name, using a caller-supplied instance ID. // // A caller-supplied ID exists for the paid-cloud flow: a placeholder row is // created before payment and provisioning happens on the confirmed-payment // webhook. Provisioning with the placeholder's own ID keeps the id stable, so // the subscription's custom_data never points at a rewritten row and later // webhooks still resolve it. If an instance with this ID already exists — a // webhook retried after a partial provision — it is returned as-is rather than // duplicated. // // The count-then-insert loop is racy on its own. It is safe only because // instances.slug carries a unique index: a lost race surfaces as a duplicate-key // error, which we treat as "that slug is taken" and retry. Do not remove the // duplicate-key branch, and do not remove the index. func CreateInstanceWithID(ctx context.Context, db *mongo.Database, instanceID, name string) (*models.Instance, error) { // Idempotency: a retried provision finds its own instance already present. var existing models.Instance if err := db.Collection("instances").FindOne(ctx, bson.M{"instance_id": instanceID}).Decode(&existing); err == nil { return &existing, nil } base, err := BaseSlug(name) if err != nil { return nil, fmt.Errorf("%w: %s", ErrNameRejected, err.Error()) } for attempt := 1; attempt <= maxSlugAttempts; attempt++ { slug := NextSlug(base, attempt) n, err := db.Collection("instances").CountDocuments(ctx, bson.M{"slug": slug}) if err != nil { return nil, err } if n > 0 { continue } inst := models.Instance{ InstanceID: instanceID, Name: name, Slug: slug, CreatedAt: time.Now().UTC(), } if _, err := db.Collection("instances").InsertOne(ctx, inst); err != nil { if mongo.IsDuplicateKeyError(err) { continue // lost the race; try the next slug } return nil, err } return &inst, nil } return nil, fmt.Errorf("%w: could not find a free slug for %q", ErrNameRejected, name) } // ErrSlugTaken means the slug a new name derives to already belongs to another // instance. // // Rename refuses rather than appending a counter the way creation does. Creation // appends because the customer is waiting on an instance and any free slug will // do; a rename is a request for one specific host, and silently landing them on // "acme-2" answers a question they did not ask. var ErrSlugTaken = errors.New("slug taken") // RenameSlug derives the slug a rename to name would move an instance to, given // the slug it holds now. // // It returns the current slug unchanged when the name still derives to it, so a // cosmetic edit — capitalisation, punctuation, a trailing "Ltd." — is not a move // and cannot collide with the instance's own slug. func RenameSlug(name, currentSlug string) (string, error) { base, err := BaseSlug(name) if err != nil { return "", fmt.Errorf("%w: %s", ErrNameRejected, err.Error()) } if base == currentSlug { return currentSlug, nil } return base, nil } // RenameInstance changes an instance's name and re-derives its slug from it. // // It returns the name and slug the control plane held BEFORE the write, and // those are the only correct values to unwind with. The caller's own copy of the // instance may be stale, and admin's copy stores slug with `omitempty`, so an // unwind driven from there can write an empty slug — which either mis-restores // the tenant host or trips the unique index against every other slugless row. // // The count-then-update is racy on its own, and is safe for the same reason // CreateInstanceWithID's loop is: instances.slug carries a unique index, so a // lost race surfaces as a duplicate-key error. Unlike creation there is nothing // to retry with — the caller asked for one specific name — so it becomes // ErrSlugTaken. Do not remove the duplicate-key branch, and do not remove the // index. func RenameInstance(ctx context.Context, db *mongo.Database, instanceID, name string) (inst *models.Instance, prevName, prevSlug string, err error) { var cur models.Instance if err := db.Collection("instances").FindOne(ctx, bson.M{"instance_id": instanceID}).Decode(&cur); err != nil { return nil, "", "", err } prevName, prevSlug = cur.Name, cur.Slug slug, err := RenameSlug(name, cur.Slug) if err != nil { return nil, prevName, prevSlug, err } if slug != cur.Slug { n, err := db.Collection("instances").CountDocuments(ctx, bson.M{ "slug": slug, "instance_id": bson.M{"$ne": instanceID}, }) if err != nil { return nil, prevName, prevSlug, err } if n > 0 { return nil, prevName, prevSlug, fmt.Errorf("%w: %s", ErrSlugTaken, slug) } } if _, err := db.Collection("instances").UpdateOne(ctx, bson.M{"instance_id": instanceID}, bson.M{"$set": bson.M{"name": name, "slug": slug}}); err != nil { if mongo.IsDuplicateKeyError(err) { return nil, prevName, prevSlug, fmt.Errorf("%w: %s", ErrSlugTaken, slug) } return nil, prevName, prevSlug, err } cur.Name = name cur.Slug = slug return &cur, prevName, prevSlug, nil } // RestoreInstanceIdentity writes an exact name and slug back, unwinding a rename // whose caller-side bookkeeping then failed. // // It derives nothing. The values being restored may include a creation-time // collision suffix that no name derives to, so re-running RenameInstance with the // old name would not reproduce them. func RestoreInstanceIdentity(ctx context.Context, db *mongo.Database, instanceID, name, slug string) error { _, err := db.Collection("instances").UpdateOne(ctx, bson.M{"instance_id": instanceID}, bson.M{"$set": bson.M{"name": name, "slug": slug}}) return err } // RollbackInstance deletes an instance that has no users. // // It refuses an instance that has users. Rollback exists to clean up a // half-finished signup, and an instance with users is not half-finished. func RollbackInstance(ctx context.Context, db *mongo.Database, instanceID string) error { n, err := db.Collection("users").CountDocuments(ctx, bson.M{"instance_id": instanceID}) if err != nil { return err } if n > 0 { return fmt.Errorf("refusing to roll back instance %s: it has %d user(s)", instanceID, n) } _, err = db.Collection("instances").DeleteOne(ctx, bson.M{"instance_id": instanceID}) return err }