From f75b2024e039a8d86dc9456d1e02fba0f5488790 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Wed, 12 Aug 2026 10:01:56 +0000 Subject: [PATCH] feat: Add instance rename to shared provisioning --- provision/instance.go | 86 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/provision/instance.go b/provision/instance.go index 8ff82c2..2ba4d30 100644 --- a/provision/instance.go +++ b/provision/instance.go @@ -79,6 +79,92 @@ func CreateInstanceWithID(ctx context.Context, db *mongo.Database, instanceID, n 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. +// +// 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) (*models.Instance, error) { + var inst models.Instance + if err := db.Collection("instances").FindOne(ctx, + bson.M{"instance_id": instanceID}).Decode(&inst); err != nil { + return nil, err + } + + slug, err := RenameSlug(name, inst.Slug) + if err != nil { + return nil, err + } + + if slug != inst.Slug { + n, err := db.Collection("instances").CountDocuments(ctx, bson.M{ + "slug": slug, + "instance_id": bson.M{"$ne": instanceID}, + }) + if err != nil { + return nil, err + } + if n > 0 { + return nil, 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, fmt.Errorf("%w: %s", ErrSlugTaken, slug) + } + return nil, err + } + + inst.Name = name + inst.Slug = slug + return &inst, 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