package rollout import ( "context" "testing" "github.com/carlosframework/platform/internal/activator" "github.com/carlosframework/platform/internal/appconfig" ) // sleepHost makes host a sleeper: no current generation, no entry. func (h *harness) sleepHost(host string) { h.act.mu.Lock() defer h.act.mu.Unlock() delete(h.act.gens, host) delete(h.act.owned, host) } // Final review wave 2, N2, the reviewer's sequence: instance h serves its // good build v1, and its hold attempt onto v2 runs v2's process. A sibling's // first wake then fails v2, and the cohort-failure transition supersedes h's // attempt, which the activator abandons after v2 ran (Executed). Nothing is // held down on h: it was superseded, not seen to fail. h stops. Its next // wake must not boot v1: v2 may have migrated h's database (spec §6.4). Nor // may it boot v2 (Astra R3 N7): the sibling's record holds v2 for h, which // only passed nothing, and the marker lifts no record (spec §8.4 exempts an // instance that passed on the build, not one that merely ran it). So h // stays down until a retry (`carlos instances retry`, `carlos promote // --retry`) or a newer build, and its plan's check refuses both builds. func TestASupersededHoldThatRanKeepsTheInstanceOffItsGoodBuild(t *testing.T) { ctx := context.Background() h := newHarness(t, "h", "s") h.swap = appconfig.SwapHold h.stage("v2") h.act.mode = activator.ModeHold h.act.executed = func(a activator.Attempt, _ activator.SwapOutcome) bool { return a.Build == "v2" } h.sleepHost("s") h.pass() gate := h.act.hold("h") h.install("v2", "h", "s") h.pass() waitFor(t, "h's hold attempt onto v2", func() bool { _, started, _ := h.act.snapshot() return len(started) == 1 && started[0] == "h:v2" }) // The sibling's first wake of v2 fails, in hold: a record for v2 (held // down on s), and the halt supersedes h's attempt. h.wakeHost("s") sp := h.m.Plan(ctx, h.rows.get("s")) if !sp.FirstWake || sp.Boot.Version != "v2" || sp.Mode != activator.ModeHold { t.Fatalf("s's plan %+v: want a hold first wake of v2", sp) } h.m.OnGen(activator.GenEvent{Kind: activator.GenFailed, Route: h.rows.get("s"), Failed: sp.Boot, Mode: sp.Mode, FirstWake: true, Reason: "health check failed", Token: sp.Token, ConfigRev: sp.ConfigRev}) close(gate) waitFor(t, "h's attempt to finish", func() bool { return !h.m.attemptRunning("h") }) calls, _, _ := h.act.snapshot() if len(calls) != 1 || calls[0].outcome != activator.SwapAbandoned { t.Fatalf("calls %+v: want h's attempt abandoned (superseded by the halt)", calls) } if h.book.HeldDown("bab", "blog", "v2", "h") { t.Fatal("h is held down: a superseded attempt is no failure of the build") } // h stops, and wakes again. h.sleepHost("h") h.wakeHost("h") p := h.m.Plan(ctx, h.rows.get("h")) if p.Refuse != HeldDownRefusal("v2") || p.Boot.StagedPath != "" { t.Fatalf("h's plan %+v: want the wake refused in the hold-down words, neither v2 nor the good v1", p) } // The same rule at the exec, for a plan made before the record landed: // sp's check is the sibling's, so build one for h from a plan that boots // v2 under the marker with no record standing. check := h.m.planCheck(h.rows.get("h"), activator.WakePlan{Boot: sp.Boot, Mode: activator.ModeHold, FirstWake: true}) if check("v2") == nil { t.Fatal("h's check lets v2 boot while the sibling's record of it stands") } if check("v1") == nil { t.Fatal("h's check lets the good v1 boot after the hold build ran") } }