From 5856deede35229ceef2470bb6aa54daa2a18e558 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Wed, 12 Aug 2026 10:09:19 +0000 Subject: [PATCH] feat: Add customer instance rename endpoint --- admin/internal/api/customer.go | 93 ++++++++++++++++++++++++++++++++++ admin/internal/api/routes.go | 5 ++ 2 files changed, 98 insertions(+) diff --git a/admin/internal/api/customer.go b/admin/internal/api/customer.go index 8e2eb98..e199043 100644 --- a/admin/internal/api/customer.go +++ b/admin/internal/api/customer.go @@ -538,6 +538,99 @@ func claimFree(c *gin.Context) { c.JSON(http.StatusCreated, lic) } +// renameInstance changes a cloud instance's name and moves it to the slug that +// name derives to. +// +// The control plane is written FIRST, because instances.slug carries the unique +// index and that index is what actually settles a race between two accounts +// reaching for the same name. Admin's own row follows; if that write fails the +// control plane is put back, because HQ printing a host that is not the host is +// worse than a failed rename. +// +// No licence is issued and Paddle is not called: a licence binds the instance +// UUID, and a rename does not change it. +func renameInstance(c *gin.Context) { + inst, ok := ownedInstance(c, c.Param("id")) + if !ok { + return + } + if inst.Deployment != license.DeploymentCloud { + c.JSON(http.StatusBadRequest, gin.H{"error": selfHostedRefusal}) + return + } + if inst.Placeholder { + c.JSON(http.StatusConflict, gin.H{"error": "this instance is not provisioned yet"}) + return + } + + var body struct { + Name string `json:"name"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) + return + } + name := strings.TrimSpace(body.Name) + if name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) + return + } + + if inst.RenamedAt != nil { + if until := inst.RenamedAt.Add(models.RenameCooldown); time.Now().UTC().Before(until) { + c.JSON(http.StatusTooManyRequests, gin.H{ + "error": fmt.Sprintf("this instance was renamed recently; it can be renamed again after %s UTC", until.Format("2 Jan 2006 15:04")), + "retry_after": until, + }) + return + } + } + + ctx := c.Request.Context() + renamed, err := cloudprov.RenameInstance(ctx, inst.InstanceID, name) + switch { + case errors.Is(err, provision.ErrSlugTaken): + c.JSON(http.StatusConflict, gin.H{"error": "that name is already in use — try another"}) + return + case errors.Is(err, provision.ErrNameRejected): + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) + return + case err != nil: + log.Printf("renameInstance: control plane rename of %s: %v", inst.InstanceID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not rename the instance"}) + return + } + + if _, err := db.Admin("admin_instances").UpdateOne(ctx, + bson.M{"instance_id": inst.InstanceID}, + bson.M{"$set": bson.M{ + "name": renamed.Name, + "slug": renamed.Slug, + "renamed_at": time.Now().UTC(), + }}); err != nil { + if rbErr := cloudprov.RestoreInstanceIdentity(ctx, inst.InstanceID, inst.Name, inst.Slug); rbErr != nil { + log.Printf("renameInstance: rollback of %s failed: %v", inst.InstanceID, rbErr) + } + log.Printf("renameInstance: record rename of %s: %v", inst.InstanceID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not rename the instance"}) + return + } + + s := auth.Current(c) + audit.Write(ctx, models.AuditEntry{ + Actor: s.Email, Action: "instance.renamed", AccountID: s.AccountID, + Target: inst.InstanceID, Detail: inst.Slug + " -> " + renamed.Slug, IP: c.ClientIP()}) + + c.JSON(http.StatusOK, gin.H{ + "instance_id": inst.InstanceID, + "name": renamed.Name, + "slug": renamed.Slug, + // The same builder the licence emails use, rather than a second opinion + // about how a tenant host is spelled. Empty when APP_LOGIN_URL is unset. + "login_url": loginURLFor(renamed.Slug), + }) +} + // deliver sends a freshly issued licence where it needs to go. Cloud instances // are injected; self-hosted customers are emailed and can download. // diff --git a/admin/internal/api/routes.go b/admin/internal/api/routes.go index 19d66e1..eeadf5f 100644 --- a/admin/internal/api/routes.go +++ b/admin/internal/api/routes.go @@ -75,6 +75,11 @@ func Routes(cfg config.Config) http.Handler { cust.POST("/instances/:id/claim-free", auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin), claimFree) + // Renaming moves the instance's DNS host, so it is owner-or-admin like + // every other instance mutation. Cloud only; the handler refuses the rest. + cust.PUT("/instances/:id/name", + auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin), + renameInstance) cust.GET("/instances/:id/entitlement", getEntitlement) cust.GET("/checkout/options", checkoutOptions) // Paid self-hosted: links (or reuses) the customer's real install UUID so