package rollout import ( "context" "runtime" "strings" "sync" "sync/atomic" "testing" "time" "github.com/carlosframework/platform/internal/activator" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/status" ) func TestStatusReportsHeldAndRollingInstances(t *testing.T) { h := newHarness(t, "a", "b") h.stage("v2") gate := h.act.hold("a") h.install("v2", "a", "b") h.pass() waitFor(t, "a's canary", func() bool { _, s, _ := h.act.snapshot(); return len(s) == 1 }) // Recorded after the pass: a pass prunes the records of builds no route // installs, and v3 is installed nowhere. if _, err := h.book.Record(FailureRecord{Account: "bab", App: "blog", Build: "v3", Host: "z", Reason: "health check failed", At: t0}); err != nil { t.Fatal(err) } r := h.m.Status() if got := r.Hosts["a"]; got.Attempt != status.AttemptSettling || got.AttemptBuild != "v2" || got.Installed != "v2" || got.Serving != "v1" || !got.Awake { t.Fatalf("a = %+v, want the canary settling onto v2", got) } if got := r.Hosts["b"]; got.Attempt != status.AttemptQueued || got.AttemptBuild != "v2" { t.Fatalf("b = %+v, want queued behind the canary", got) } if app := r.Apps["bab/blog"]; app.Settling != 1 || app.Queued != 1 || app.Running != 0 { t.Fatalf("app = %+v", app) } if len(r.Failures) != 1 || r.Failures[0].Build != "v3" || r.Failures[0].Reason != status.ReasonHealth { t.Fatalf("failures = %+v", r.Failures) } close(gate) } func TestAFailedFirstWakeShowsAsHeld(t *testing.T) { h := newHarness(t, "a") h.act.mu.Lock() delete(h.act.gens, "a") // asleep: v2 is a first wake's build h.act.mu.Unlock() h.stage("v2") h.install("v2", "a") h.pass() h.act.serve("a", "v1") // the first wake failed and the good build serves in its place cur, _ := h.act.Current("a") h.m.OnGen(activator.GenEvent{Kind: activator.GenFellBack, Route: h.rows.get("a"), FirstWake: true, Mode: activator.ModeOverlap, Gen: cur, Failed: release.StagedIdentity{Version: "v2"}, Reason: "exited within 10s of becoming healthy"}) h.pass() got := h.m.Status().Hosts["a"] if got.Attempt != status.AttemptFailed || got.AttemptBuild != "v2" || got.HeldBy != "v2" || got.Reason != status.ReasonExit || got.Serving != "v1" { t.Fatalf("a = %+v", got) } } // A hold build that failed on the instance is held down: its wakes are // refused, which the report calls down. func TestAHeldDownInstanceShowsAsDown(t *testing.T) { h := newHarness(t, "a") h.stage("v2") if _, err := h.book.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "a", Mode: "hold", Reason: "swap: successor exited during its settle", At: t0, HeldDown: []string{"a"}}); err != nil { t.Fatal(err) } h.install("v2", "a") h.pass() got := h.m.Status().Hosts["a"] if got.Attempt != status.AttemptDown || got.HeldBy != "v2" || got.Reason != status.ReasonSettle { t.Fatalf("a = %+v", got) } } // Spec §6.4, §9.1: an intentional stop (hibernation, maintenance) during a // first wake's 10 s ends the observation with no verdict. GenCancelled closes // what GenHealthy opened, so the host stops showing as settling and the // deploy watch can complete; nothing is recorded against the build. With no // closing event, observing[host] stayed set for ever. func TestACancelledObservationStopsSettlingAndRecordsNothing(t *testing.T) { h := newHarness(t, "a") h.act.mu.Lock() delete(h.act.gens, "a") // asleep: its first wake is the only thing that runs v2 h.act.mu.Unlock() h.stage("v2") h.install("v2", "a") h.pass() // The wake's plan: its events carry the plan's token (final review F4). p := h.m.Plan(context.Background(), h.rows.get("a")) g := activator.Gen{N: 9, Build: "v2", Digest: "sha-v2"} h.m.OnGen(activator.GenEvent{Kind: activator.GenHealthy, Route: h.rows.get("a"), Gen: g, FirstWake: true, Token: p.Token}) if got := h.m.Status().Hosts["a"]; got.Attempt != status.AttemptSettling { t.Fatalf("under observation: a = %+v, want settling", got) } h.m.OnGen(activator.GenEvent{Kind: activator.GenCancelled, Route: h.rows.get("a"), Gen: g, FirstWake: true, Token: p.Token}) if got := h.m.Status().Hosts["a"]; got.Attempt == status.AttemptSettling { t.Fatalf("after the cancelled observation: a = %+v, still settling", got) } if _, ok := h.book.Lookup("bab", "blog", "v2"); ok { t.Fatal("a cancelled observation was recorded as a failure of the build") } if r := h.rows.get("a"); r.GoodVersion != "v1" { t.Fatalf("good build = %q: a cancelled observation is no settle", r.GoodVersion) } } // Plan 6's deploy watch counts a sleeper as "installed, awaiting first // wake" once its box reports B installed. No attempt runs for a sleeper, so // the pass that first sees B must republish. func TestANewlyInstalledBuildIsPublishedAtOnce(t *testing.T) { h := newHarness(t, "a") var changes atomic.Int32 h.m.d.OnChange = func() { changes.Add(1) } h.act.mu.Lock() delete(h.act.gens, "a") // asleep h.act.mu.Unlock() h.stage("v2") h.install("v2", "a") h.pass() if changes.Load() == 0 { t.Fatal("a newly installed build waited for the five-minute status tick") } if got := h.m.Status().Hosts["a"]; got.Installed != "v2" || got.Awake { t.Fatalf("a = %+v, want v2 installed and asleep", got) } } // OnChange is a nudge called after every transition, with no manager or // scheduler lock held: this one reads Status, which takes both, so a call // under either lock deadlocks the test. func TestOnChangeFollowsEveryTransitionWithNoLockHeld(t *testing.T) { h := newHarness(t, "a") var changes atomic.Int32 h.m.d.OnChange = func() { h.m.Status() changes.Add(1) } h.stage("v2") h.install("v2", "a") h.pass() waitFor(t, "a on v2", func() bool { return h.servingAll("v2", "a") }) waitFor(t, "the attempt's change", func() bool { return changes.Load() >= 2 }) // the pass's, then done()'s before := changes.Load() h.m.OnGen(activator.GenEvent{Kind: activator.GenSettled, Route: h.rows.get("a"), Gen: activator.Gen{N: 1, Build: "v2"}}) if changes.Load() <= before { t.Fatal("OnGen did not report its transition") } } func TestStatusReportsAConfigRevisionDeliveredButNotActive(t *testing.T) { h := newHarness(t, "a") h.pass() gate := h.act.hold("a") h.m.ConfigDelivered("a", "rev1") waitFor(t, "the config attempt", func() bool { _, s, _ := h.act.snapshot(); return len(s) == 1 }) got := h.m.Status().Hosts["a"] // The config attempt is its cohort's canary, so it reads as settling. if !got.ConfigPending || got.Attempt != status.AttemptSettling || got.AttemptBuild != "v1" || got.ConfigError != "" { t.Fatalf("a = %+v, want the config attempt in flight with its revision pending", got) } close(gate) waitFor(t, "the config to activate", func() bool { return !h.m.Status().Hosts["a"].ConfigPending }) } // Carried from Task 13: a host whose route left the box is forgotten by the // next Pass, queue slot and config track alike, so Status never reports a // departed host as queued. One with an attempt running is kept until the // attempt finishes, and a row only passing through "moving" keeps its place. func TestPassForgetsHostsThatLeftTheBox(t *testing.T) { h := newHarness(t, "a", "b", "c") h.stage("v2") gate := h.act.hold("a") h.install("v2", "a", "b", "c") h.pass() waitFor(t, "a's canary", func() bool { _, s, _ := h.act.snapshot(); return len(s) == 1 }) h.m.ConfigDelivered("b", "rev1") if r := h.m.Status(); r.Hosts["b"].Attempt != status.AttemptQueued || !r.Hosts["b"].ConfigPending { t.Fatalf("b = %+v, want queued with its config pending", r.Hosts["b"]) } moving := h.rows.get("c") moving.Status = "moving" h.rows.put(moving) h.rows.mu.Lock() delete(h.rows.rows, "a") delete(h.rows.rows, "b") h.rows.mu.Unlock() h.pass() r := h.m.Status() if _, ok := r.Hosts["b"]; ok { t.Fatalf("b = %+v after its route left the box", r.Hosts["b"]) } h.m.mu.Lock() _, track := h.m.cfg["b"] _, mapped := h.m.hostApp["b"] h.m.mu.Unlock() if track || mapped { t.Fatalf("b's config track (%v) or app mapping (%v) outlived its route", track, mapped) } if got := r.Hosts["a"]; got.Attempt != status.AttemptSettling { t.Fatalf("a = %+v: a host with its attempt running was forgotten", got) } if got := r.Hosts["c"]; got.Attempt != status.AttemptQueued || got.Installed != "v2" { t.Fatalf("c = %+v: a row passing through moving lost its place", got) } close(gate) waitFor(t, "a's attempt to finish", func() bool { calls, _, _ := h.act.snapshot(); return len(calls) >= 1 }) waitFor(t, "a forgotten once its attempt finished", func() bool { h.pass() _, ok := h.m.Status().Hosts["a"] return !ok }) } // A host whose process is starting or stopping (the activator owns it and has // no current generation) is transient: no serving build and not awake, though // the last pass cached one. One that is awake or asleep is not (Astra R3 // section 5 item 10). Nothing is installed, so no attempt runs: the harness's // swap goroutines would otherwise write the activator's generations under the // test. func TestStatusReportsAStartingOrStoppingHostAsTransient(t *testing.T) { h := newHarness(t, "a", "b", "c") h.pass() if got := h.m.Status().Hosts["a"]; got.Transient || got.Installed != "v1" || got.Serving != "v1" || !got.Awake { t.Fatalf("a before = %+v, want awake on v1 and not transient", got) } h.act.mu.Lock() delete(h.act.gens, "a") h.act.owned["a"] = true // stopping or waking: a process, no current generation delete(h.act.gens, "b") // asleep: owned nowhere h.act.mu.Unlock() // The scheduler's cached serving build is deliberately not refreshed: the // report must not repeat it for a host the activator says is mid-change. r := h.m.Status() a := r.Hosts["a"] if !a.Transient || a.Serving != "" || a.Awake || a.Installed != "v1" { t.Fatalf("a = %+v, want transient, not awake, no serving build", a) } if b := r.Hosts["b"]; b.Transient { t.Fatalf("b = %+v: an asleep host is not transient", b) } if c := r.Hosts["c"]; c.Transient || c.Serving != "v1" || !c.Awake { t.Fatalf("c = %+v: an awake host is not transient", c) } } // Status asks the activator with no Manager or scheduler lock held (the lock // order on Manager), for both of its questions: Current for every host, and // Owns for one with no current generation (b, asleep here). The probe only // TryLocks, so a violation fails the test instead of deadlocking it. No // attempt runs (nothing is installed), so no other goroutine holds these // locks for long. The probe is the harness's (probeAct), wired in before // any scheduler started; its hook is installed atomically (final review // round 1, finding 7). func TestStatusAsksTheActivatorWithNoLockHeld(t *testing.T) { h := newHarness(t, "a", "b") h.pass() h.m.mu.Lock() scheds := make([]*scheduler, 0, len(h.m.apps)) for _, s := range h.m.apps { scheds = append(scheds, s) } h.m.mu.Unlock() if len(scheds) == 0 { t.Fatal("the pass built no scheduler") } h.act.mu.Lock() delete(h.act.gens, "b") // asleep: Status asks Owns about it h.act.mu.Unlock() lp := &lockProbe{m: h.m, scheds: scheds, t: t, asked: map[string]*atomic.Int64{"Owns": {}, "Current": {}}} h.probe.onAsk(lp.check) defer h.probe.onAsk(nil) h.m.Status() if lp.asked["Current"].Load() == 0 || lp.asked["Owns"].Load() == 0 { t.Fatalf("Status asked Current %d and Owns %d time(s): want both asked", lp.asked["Current"].Load(), lp.asked["Owns"].Load()) } } // lockProbe fails the test when Owns or Current is asked by Status while the // Manager's or a scheduler's mutex is held. type lockProbe struct { m *Manager scheds []*scheduler t *testing.T asked map[string]*atomic.Int64 // by method; built before the hook is installed, never written after } func (p *lockProbe) check(method, _ string) { if !askedByStatus() { return // the scheduler's own goroutine asks too; only Status is under test } p.asked[method].Add(1) // A lock that Status holds stays held for the whole ask, while a // scheduler goroutine's own hold is brief, so a violation is a lock that // cannot be taken for a long while. TryLock, never Lock: a violation // fails the test instead of deadlocking it. free := func(mu *sync.Mutex) bool { for end := time.Now().Add(300 * time.Millisecond); time.Now().Before(end); time.Sleep(time.Millisecond) { if mu.TryLock() { mu.Unlock() return true } } return false } if !free(&p.m.mu) { p.t.Errorf("activator asked (%s) with Manager.mu held", method) } for _, s := range p.scheds { if !free(&s.mu) { p.t.Errorf("activator asked (%s) with a scheduler.mu held", method) } } } // askedByStatus reports whether the calling goroutine is inside // Manager.Status. func askedByStatus() bool { pcs := make([]uintptr, 32) frames := runtime.CallersFrames(pcs[:runtime.Callers(2, pcs)]) for { f, more := frames.Next() if strings.HasSuffix(f.Function, "(*Manager).Status") { return true } if !more { return false } } } // A host that joins a scheduler after Status read the names was never asked of // the activator, so nothing says it is settled: that report calls it // transient, and the next one asks. func TestStatusCallsAHostThatJoinedAfterTheNamesWereReadTransient(t *testing.T) { h := newHarness(t, "a") h.pass() h.m.mu.Lock() var s *scheduler for _, s = range h.m.apps { } h.m.mu.Unlock() var once atomic.Bool h.probe.onAsk(func(method, _ string) { if method != "Current" || !askedByStatus() { // not the scheduler's own goroutine return } if once.CompareAndSwap(false, true) { // the names are read; now a host joins s.mu.Lock() s.q.hosts["late"] = &hostSlot{wantBuild: "v1", good: "v1", serving: "v1"} s.mu.Unlock() } }) defer h.probe.onAsk(nil) r := h.m.Status() if late := r.Hosts["late"]; !late.Transient || late.Serving != "" || late.Awake { t.Fatalf("late = %+v, want transient for the report that never asked", late) } if a := r.Hosts["a"]; a.Transient || a.Serving != "v1" { t.Fatalf("a = %+v, want awake on v1", a) } }