diff --git a/admin/internal/api/customer.go b/admin/internal/api/customer.go index 6c6df86..8e82912 100644 --- a/admin/internal/api/customer.go +++ b/admin/internal/api/customer.go @@ -585,8 +585,12 @@ func renameInstance(c *gin.Context) { // walking away, and an unwind sharing that context fails with it — leaving // the control plane renamed and admin's row not, which is the exact // divergence this handler is arranged to prevent. - bgCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) - defer cancel() + // + // Only the cancellation is detached here; each deadline is derived at its use + // site below. A deadline started before the forward work is a deadline the + // unwind may never get to use — a control plane slow enough to make the admin + // write fail is exactly the one that would have spent it already. + detached := context.WithoutCancel(ctx) // Claim the cooldown atomically BEFORE the control-plane call. Checking it // and then acting lets two parallel PUTs both pass the check and then @@ -643,7 +647,9 @@ func renameInstance(c *gin.Context) { if claimed.RenamedAt != nil { undo = bson.M{"$set": bson.M{"renamed_at": *claimed.RenamedAt}} } - if _, err := db.Admin("admin_instances").UpdateOne(bgCtx, + rcCtx, cancel := context.WithTimeout(detached, 5*time.Second) + defer cancel() + if _, err := db.Admin("admin_instances").UpdateOne(rcCtx, bson.M{"instance_id": inst.InstanceID}, undo); err != nil { log.Printf("renameInstance: releasing the cooldown claim on %s after %s: %v", inst.InstanceID, after, err) } @@ -677,9 +683,11 @@ func renameInstance(c *gin.Context) { if err != nil { // The control plane's own previous values, not admin's copy: admin's may // be stale, and its slug is omitempty. - if rbErr := cloudprov.RestoreInstanceIdentity(bgCtx, inst.InstanceID, prevName, prevSlug); rbErr != nil { + rbCtx, rbCancel := context.WithTimeout(detached, 5*time.Second) + if rbErr := cloudprov.RestoreInstanceIdentity(rbCtx, inst.InstanceID, prevName, prevSlug); rbErr != nil { log.Printf("renameInstance: rollback of %s failed: %v", inst.InstanceID, rbErr) } + rbCancel() releaseClaim("a failed record write") log.Printf("renameInstance: record rename of %s: %v", inst.InstanceID, err) c.JSON(http.StatusInternalServerError, gin.H{"error": "could not rename the instance"}) @@ -695,9 +703,11 @@ func renameInstance(c *gin.Context) { } s := auth.Current(c) - audit.Write(bgCtx, models.AuditEntry{ + auCtx, auCancel := context.WithTimeout(detached, 5*time.Second) + audit.Write(auCtx, models.AuditEntry{ Actor: s.Email, Action: "instance.renamed", AccountID: s.AccountID, Target: inst.InstanceID, Detail: prevSlug + " -> " + renamed.Slug, IP: c.ClientIP()}) + auCancel() c.JSON(http.StatusOK, gin.H{ "instance_id": inst.InstanceID, diff --git a/admin/internal/api/staff.go b/admin/internal/api/staff.go index f5f726b..54b7988 100644 --- a/admin/internal/api/staff.go +++ b/admin/internal/api/staff.go @@ -666,8 +666,12 @@ func staffRenameInstance(c *gin.Context) { // The unwind and the audit write must survive the request being cancelled: // an unwind on a dead context leaves the two databases disagreeing, which is // the failure the unwind exists for. - bgCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) - defer cancel() + // + // Only the cancellation is detached here; each deadline is derived at its use + // site below. A deadline started before the forward work is a deadline the + // unwind may never get to use — a control plane slow enough to make the admin + // write fail is exactly the one that would have spent it already. + detached := context.WithoutCancel(ctx) set := bson.M{"name": name} slug := inst.Slug @@ -704,17 +708,21 @@ func staffRenameInstance(c *gin.Context) { } if err != nil { if cloud { - if rbErr := cloudprov.RestoreInstanceIdentity(bgCtx, inst.InstanceID, prevName, prevSlug); rbErr != nil { + rbCtx, rbCancel := context.WithTimeout(detached, 5*time.Second) + if rbErr := cloudprov.RestoreInstanceIdentity(rbCtx, inst.InstanceID, prevName, prevSlug); rbErr != nil { log.Printf("staffRenameInstance: rollback of %s failed: %v", inst.InstanceID, rbErr) } + rbCancel() } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - audit.Write(bgCtx, models.AuditEntry{ + auCtx, auCancel := context.WithTimeout(detached, 5*time.Second) + audit.Write(auCtx, models.AuditEntry{ Actor: auth.Current(c).Email, Action: "instance.renamed", AccountID: inst.AccountID, Target: inst.InstanceID, Detail: prevSlug + " -> " + slug, IP: c.ClientIP()}) + auCancel() c.JSON(http.StatusOK, gin.H{"instance_id": inst.InstanceID, "name": name, "slug": slug}) } diff --git a/adminsite/app/(staff)/staff/instances/[id]/page.tsx b/adminsite/app/(staff)/staff/instances/[id]/page.tsx index b56f2f6..b62892a 100644 --- a/adminsite/app/(staff)/staff/instances/[id]/page.tsx +++ b/adminsite/app/(staff)/staff/instances/[id]/page.tsx @@ -38,6 +38,12 @@ export default function StaffInstancePage() { const inj = data.injection.state ? INJECTION[data.injection.state] : undefined; const current = data.licenses.find((l) => !l.superseded_by); + // A cloud placeholder has no control-plane row yet, so there is no host to + // move and nothing to rename — the panel's wording and its control are both + // read from this one answer rather than from the deployment alone, which is + // how they came to contradict each other. + const movesHost = data.instance.deployment === "cloud" && !data.instance.placeholder; + const cloudPlaceholder = data.instance.deployment === "cloud" && data.instance.placeholder; return (
+ This instance is not provisioned yet. Its name is set when the checkout provisions it, and it can be renamed after that. +
+ ) : ( + /* + * Keyed on the instance so a success note cannot follow staff + * from one instance page to the next — the element stays + * mounted across that navigation. + */ +