zdd-6 final fix wave 1: W4 drafts that state no false behaviour
zdd.event.swapping is mode-neutral (it is emitted for hold swaps and worker replacements too); zdd.instance.retry-line drops the fallback promise; a hold-mode instance left down gets zdd.instance.hold-down-text instead of a hold row that says it stays on a build. Astra plan 6 R1 #8.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+133
−3
internal/console/instancepage.go+6 −1internal/console/swapcopy.go+7 −1internal/console/swapcopy_truth_test.go+70 −0internal/rollout/events_mode_test.go+46 −0internal/rolloutwords/words.go+4 −1
diff --git a/internal/console/instancepage.go b/internal/console/instancepage.go| index 586ca9f2c..4dcd1d9a6 100644 |
| --- a/internal/console/instancepage.go |
| +++ b/internal/console/instancepage.go |
| @@ -33,12 +33,17 @@ type instanceDetail struct { |
| // HoldText is the Failure hold row (spec §9.2), or "" when nothing holds |
| // this host back (and always for a box that could not arm its scheduler, |
| -// whose view carries no hold). |
| +// whose view carries no hold). A hold-mode failure that left the instance |
| +// down (attempt down) has nothing serving it, so its row says so rather |
| +// than naming a build it "stays on" (Astra plan 6 R1 #8). |
| func (d *instanceDetail) HoldText() string { |
| r := d.Rollout |
| if r == nil || r.Unavailable != "" || r.HeldBy == "" { |
| return "" |
| } |
| + if r.Attempt == status.AttemptDown { |
| + return fmt.Sprintf(swapCopy.HoldDownText, r.HeldBy) |
| + } |
| stays := r.Serving |
| if stays == "" { |
| stays = r.Good |
diff --git a/internal/console/swapcopy.go b/internal/console/swapcopy.go| index 705cf3959..d21fc5c99 100644 |
| --- a/internal/console/swapcopy.go |
| +++ b/internal/console/swapcopy.go |
| @@ -22,6 +22,7 @@ type swapCopyStrings struct { |
| Serving string |
| Hold string |
| HoldText string |
| + HoldDownText string |
| PromoteRetry string |
| RetryButton string |
| Rolling string |
| @@ -70,6 +71,11 @@ var swapCopy = swapCopyStrings{ |
| Hold: "Failure hold", |
| // TODO-COPY zdd.instance.hold-text |
| HoldText: "Build %s failed on this box because %s. This instance stays on %s until you retry it or ship a newer build.", |
| + // HoldDownText is the hold row when the failed attempt left the instance |
| + // down (hold mode, attempt down): nothing serves it, so it stays on |
| + // nothing (Astra plan 6 R1 #8). |
| + // TODO-COPY zdd.instance.hold-down-text |
| + HoldDownText: "Build %s failed in hold mode on this box, and nothing is serving this instance. Roll back with carlos rollback, or retry it.", |
| // TODO-COPY zdd.instance.promote-retry |
| PromoteRetry: "To try it again on every box: carlos promote --app %s --retry %s %s", |
| // TODO-COPY zdd.instance.retry-button |
| @@ -81,7 +87,7 @@ var swapCopy = swapCopyStrings{ |
| // TODO-COPY zdd.instance.retry-title |
| RetryTitle: "Retry build {build} on {host}?", |
| // TODO-COPY zdd.instance.retry-line |
| - RetryLine: "This clears the failure hold on {host}. Its next attempt at {build} runs as a canary. If that fails too, {host} stays on its last good build.", |
| + RetryLine: "This clears the failure hold on {host}. Its next attempt at {build} runs as a canary. If that fails too, {host} is held again.", |
| // TODO-COPY zdd.instance.retry-done |
| RetryDone: "Retry requested for {host}.", |
| // TODO-COPY zdd.instance.retry-failed |
diff --git a/internal/console/swapcopy_truth_test.go b/internal/console/swapcopy_truth_test.go| new file mode 100644 |
| index 000000000..a93f5b106 |
| --- /dev/null |
| +++ b/internal/console/swapcopy_truth_test.go |
| @@ -0,0 +1,70 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/rolloutwords" |
| + "github.com/carlosframework/platform/internal/status" |
| +) |
| + |
| +// Astra plan 6 R1 #8: the retry confirmation promises no fallback. A retry |
| +// that fails in hold mode leaves the instance down, so "stays on its last |
| +// good build" was false there; the host is held again either way. |
| +func TestBrowserRetryConfirmationPromisesNoFallback(t *testing.T) { |
| + fx := heldFixture(t) |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + code, _, body := postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/retry"), url.Values{"csrf": {csrf}}) |
| + want := "This clears the failure hold on " + heldHost + ". Its next attempt at v2 runs as a canary. If that fails too, " + heldHost + " is held again." |
| + if code != http.StatusOK || !strings.Contains(body, want) { |
| + t.Fatalf("confirm = %d, lacks %q", code, want) |
| + } |
| + if strings.Contains(body, "last good build") { |
| + t.Fatal("the confirmation still promises a fallback to the last good build") |
| + } |
| +} |
| + |
| +// Astra plan 6 R1 #8: a hold-mode failure leaves nothing serving |
| +// (attempt down), so the page's hold row must not say the instance "stays |
| +// on" a build there. Both renders: a held instance that serves its good |
| +// build keeps the hold text; a down one gets the down text. |
| +func TestInstancePageHoldTextTellsDownFromHeld(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + const down = "down.kass.carlosapp.dev" |
| + for _, h := range []string{heldHost, down} { |
| + fx.addRoute(t, registry.Route{Host: h, App: "kass", Account: fx.sqid, Kind: "instance", Status: "ready", Version: "v2", Channel: "stable"}) |
| + sibling(t, fx, "kass", h, "stable") |
| + } |
| + box := status.CollectBox(nil, status.Probes{MachineID: "box1"}, time.Now()) |
| + box.Metrics = &status.BoxMetrics{Rollout: &status.RolloutReport{Hosts: map[string]status.HostRollout{ |
| + heldHost: {Account: fx.sqid, App: "kass", Installed: "v2", Serving: "v1", Good: "v1", Awake: true, HeldBy: "v2", Reason: status.ReasonHealth}, |
| + down: {Account: fx.sqid, App: "kass", Installed: "v2", Good: "v1", Attempt: status.AttemptDown, AttemptBuild: "v2", |
| + HeldBy: "v2", Reason: status.ReasonExit}, |
| + }}} |
| + if err := status.WriteBox(context.Background(), fx.rel, box); err != nil { |
| + t.Fatal(err) |
| + } |
| + c := signInPaul(t, fx) |
| + _, _, held := get(t, c, fx.u("/apps/kass/instances/"+heldHost)) |
| + if want := fmt.Sprintf(swapCopy.HoldText, "v2", rolloutwords.Reason(status.ReasonHealth), "v1"); !strings.Contains(held, want) { |
| + t.Errorf("held page lacks %q", want) |
| + } |
| + _, _, body := get(t, c, fx.u("/apps/kass/instances/"+down)) |
| + want := "Build v2 failed in hold mode on this box, and nothing is serving this instance. Roll back with carlos rollback, or retry it." |
| + if !strings.Contains(body, want) { |
| + t.Errorf("down page lacks %q", want) |
| + } |
| + if strings.Contains(body, "stays on") { |
| + t.Error("the down page says the instance stays on a build") |
| + } |
| + if !strings.Contains(body, `/instances/`+down+`/retry"`) { |
| + t.Error("the down page lost its Retry") |
| + } |
| +} |
diff --git a/internal/rollout/events_mode_test.go b/internal/rollout/events_mode_test.go| new file mode 100644 |
| index 000000000..2b6089bad |
| --- /dev/null |
| +++ b/internal/rollout/events_mode_test.go |
| @@ -0,0 +1,46 @@ |
| +package rollout |
| + |
| +import ( |
| + "fmt" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/rolloutwords" |
| +) |
| + |
| +// Astra plan 6 R1 #8: the swapping event is emitted for every attempt, a |
| +// hold-mode swap and a socketless worker's stop-then-start replacement as |
| +// much as an overlap swap, so its words must not say the new build starts |
| +// beside the running one. Driven through the real emit sites (scheduler |
| +// execute, executeWorker). |
| +func TestTheSwappingEventIsModeNeutral(t *testing.T) { |
| + for name, h := range map[string]*harness{ |
| + "hold swap": func() *harness { h := newHarness(t, "a"); h.swap = "hold"; return h }(), |
| + "worker replacement": workerHarness(t, "a"), |
| + } { |
| + h.stage("v2") |
| + h.install("v2", "a") |
| + h.pass() |
| + want := "a: " + fmt.Sprintf(rolloutwords.EventSwapping, "v2", rolloutwords.Cause("release")) |
| + waitFor(t, name+": the swapping event", func() bool { return h.events.has(want) }) |
| + waitFor(t, name+": the attempt", func() bool { |
| + calls, _, _ := h.act.snapshot() |
| + return len(calls)+len(h.act.replacements()) == 1 |
| + }) |
| + calls, _, _ := h.act.snapshot() |
| + switch { |
| + case name == "hold swap" && (len(calls) != 1 || calls[0].a.Mode != activator.ModeHold): |
| + t.Fatalf("%s: swaps %+v, want one in hold mode", name, calls) |
| + case name == "worker replacement" && (len(calls) != 0 || len(h.act.replacements()) != 1): |
| + t.Fatalf("%s: %d swaps, %d replacements, want one replacement", name, len(calls), len(h.act.replacements())) |
| + } |
| + h.events.mu.Lock() |
| + for _, l := range h.events.lines { |
| + if strings.Contains(l, "beside") { |
| + t.Errorf("%s: %q says the new build runs beside the old one", name, l) |
| + } |
| + } |
| + h.events.mu.Unlock() |
| + } |
| +} |
diff --git a/internal/rolloutwords/words.go b/internal/rolloutwords/words.go| index 7641e1907..58a213a1e 100644 |
| --- a/internal/rolloutwords/words.go |
| +++ b/internal/rolloutwords/words.go |
| @@ -54,8 +54,11 @@ func Reason(r string) string { |
| // Event formats (spec §9.4), one per text function in internal/rollout/events.go |
| // plus the bounce. TestVerbsAreWhereTheCallersPutThem pins each verb count. |
| const ( |
| + // Emitted for every attempt, overlap, hold and a socketless worker's |
| + // stop-then-start alike (rollout's execute and executeWorker), so it |
| + // says nothing about the mode (Astra plan 6 R1 #8). |
| // TODO-COPY zdd.event.swapping |
| - EventSwapping = "starting build %s beside the running one (%s)" |
| + EventSwapping = "starting build %s (%s)" |
| // TODO-COPY zdd.event.swapped |
| EventSwapped = "now serving build %s. The previous build finished its requests and stopped in %s" |
| // TODO-COPY zdd.event.swap-failed |