fix: Derive the rename unwind deadline at its use site
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="grid gap-8">
|
||||
@@ -86,23 +92,31 @@ export default function StaffInstancePage() {
|
||||
* fixing a name on someone's behalf must not spend their next 24
|
||||
* hours.
|
||||
*/}
|
||||
<Panel title="Name" meta={data.instance.deployment === "cloud" ? "Moves the address" : "Label only"}>
|
||||
{/*
|
||||
* 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.
|
||||
*/}
|
||||
<RenamePanel
|
||||
key={data.instance.instance_id}
|
||||
movesHost={data.instance.deployment === "cloud" && !data.instance.placeholder}
|
||||
currentName={data.instance.name}
|
||||
currentSlug={data.instance.slug ?? ""}
|
||||
onRename={async (name) => {
|
||||
const res = await api.staff.renameInstance(data.instance.instance_id, name);
|
||||
qc.invalidateQueries({ queryKey: ["staff-instance", id] });
|
||||
return res;
|
||||
}}
|
||||
/>
|
||||
<Panel title="Name" meta={movesHost ? "Moves the address" : "Label only"}>
|
||||
{cloudPlaceholder ? (
|
||||
// The API refuses this with a 409, so offering the control
|
||||
// would only be a form that cannot succeed.
|
||||
<p className="text-[0.85rem] text-ink-3">
|
||||
This instance is not provisioned yet. Its name is set when the checkout provisions it, and it can be renamed after that.
|
||||
</p>
|
||||
) : (
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
<RenamePanel
|
||||
key={data.instance.instance_id}
|
||||
movesHost={movesHost}
|
||||
currentName={data.instance.name}
|
||||
currentSlug={data.instance.slug ?? ""}
|
||||
onRename={async (name) => {
|
||||
const res = await api.staff.renameInstance(data.instance.instance_id, name);
|
||||
qc.invalidateQueries({ queryKey: ["staff-instance", id] });
|
||||
return res;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<EntitlementSection instanceId={data.instance.instance_id} deployment={data.instance.deployment} />
|
||||
|
||||
Reference in New Issue
Block a user