From ee427ed6e1825b3f2941e91a5f68aa7d9bf598a6 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Wed, 12 Aug 2026 10:12:04 +0000 Subject: [PATCH] feat: Add staff instance rename endpoint --- admin/internal/api/routes.go | 1 + admin/internal/api/staff.go | 72 ++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/admin/internal/api/routes.go b/admin/internal/api/routes.go index eeadf5f..358b069 100644 --- a/admin/internal/api/routes.go +++ b/admin/internal/api/routes.go @@ -123,6 +123,7 @@ func Routes(cfg config.Config) http.Handler { staff.GET("/subscriptions", staffListSubscriptions) staff.POST("/instances/:id/issue", staffIssue) staff.POST("/instances/:id/relink", staffRelink) + staff.PUT("/instances/:id/name", staffRenameInstance) staff.GET("/licenses", staffListLicenses) staff.GET("/plans", staffListPlans) // Plans are keyed on the pair now, so the path is too. A single :tier diff --git a/admin/internal/api/staff.go b/admin/internal/api/staff.go index 693f86b..13a2d83 100644 --- a/admin/internal/api/staff.go +++ b/admin/internal/api/staff.go @@ -1,18 +1,22 @@ package api import ( + "errors" "fmt" + "log" "net/http" "strings" "time" "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit" "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth" + "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/cloudprov" "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db" "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing" "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models" "gitea.hostxtra.co.uk/mrhid6/vantage/shared/license" sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models" + "gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision" "github.com/gin-gonic/gin" "github.com/google/uuid" "go.mongodb.org/mongo-driver/v2/bson" @@ -618,3 +622,71 @@ func staffCreateAccountUser(c *gin.Context) { Actor: s.Email, Action: "customer_user.created", AccountID: accountID, Target: email}) c.JSON(http.StatusCreated, gin.H{"pending": true}) } + +// staffRenameInstance renames any instance, with no cooldown. +// +// It does NOT write renamed_at: a staff rename must not start the customer's +// 24h clock, or fixing a name for someone locks them out of fixing it further. +// +// On self-hosted it changes admin's label only. There is no control-plane row to +// write — the install is the customer's — and no slug, because self-hosted has +// no tenant subdomain. +func staffRenameInstance(c *gin.Context) { + 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 + } + + ctx := c.Request.Context() + var inst models.Instance + if err := db.Admin("admin_instances").FindOne(ctx, + bson.M{"instance_id": c.Param("id")}).Decode(&inst); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + + set := bson.M{"name": name} + slug := inst.Slug + + if inst.Deployment == license.DeploymentCloud && !inst.Placeholder { + 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"}) + return + case errors.Is(err, provision.ErrNameRejected): + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) + return + case err != nil: + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + slug = renamed.Slug + set["slug"] = renamed.Slug + } + + if _, err := db.Admin("admin_instances").UpdateOne(ctx, + bson.M{"instance_id": inst.InstanceID}, bson.M{"$set": set}); err != nil { + if inst.Deployment == license.DeploymentCloud && !inst.Placeholder { + if rbErr := cloudprov.RestoreInstanceIdentity(ctx, inst.InstanceID, inst.Name, inst.Slug); rbErr != nil { + log.Printf("staffRenameInstance: rollback of %s failed: %v", inst.InstanceID, rbErr) + } + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + audit.Write(ctx, models.AuditEntry{ + Actor: auth.Current(c).Email, Action: "instance.renamed", AccountID: inst.AccountID, + Target: inst.InstanceID, Detail: inst.Slug + " -> " + slug, IP: c.ClientIP()}) + + c.JSON(http.StatusOK, gin.H{"instance_id": inst.InstanceID, "name": name, "slug": slug}) +}