carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package rollout
2
3import (
4 "context"
5 "runtime"
6 "strings"
7 "sync"
8 "sync/atomic"
9 "testing"
10 "time"
11
12 "github.com/carlosframework/platform/internal/activator"
13 "github.com/carlosframework/platform/internal/release"
14 "github.com/carlosframework/platform/internal/status"
15)
16
17func TestStatusReportsHeldAndRollingInstances(t *testing.T) {
18 h := newHarness(t, "a", "b")
19 h.stage("v2")
20 gate := h.act.hold("a")
21 h.install("v2", "a", "b")
22 h.pass()
23 waitFor(t, "a's canary", func() bool { _, s, _ := h.act.snapshot(); return len(s) == 1 })
24 // Recorded after the pass: a pass prunes the records of builds no route
25 // installs, and v3 is installed nowhere.
26 if _, err := h.book.Record(FailureRecord{Account: "bab", App: "blog", Build: "v3", Host: "z", Reason: "health check failed", At: t0}); err != nil {
27 t.Fatal(err)
28 }
29 r := h.m.Status()
30 if got := r.Hosts["a"]; got.Attempt != status.AttemptSettling || got.AttemptBuild != "v2" || got.Installed != "v2" || got.Serving != "v1" || !got.Awake {
31 t.Fatalf("a = %+v, want the canary settling onto v2", got)
32 }
33 if got := r.Hosts["b"]; got.Attempt != status.AttemptQueued || got.AttemptBuild != "v2" {
34 t.Fatalf("b = %+v, want queued behind the canary", got)
35 }
36 if app := r.Apps["bab/blog"]; app.Settling != 1 || app.Queued != 1 || app.Running != 0 {
37 t.Fatalf("app = %+v", app)
38 }
39 if len(r.Failures) != 1 || r.Failures[0].Build != "v3" || r.Failures[0].Reason != status.ReasonHealth {
40 t.Fatalf("failures = %+v", r.Failures)
41 }
42 close(gate)
43}
44
45func TestAFailedFirstWakeShowsAsHeld(t *testing.T) {
46 h := newHarness(t, "a")
47 h.act.mu.Lock()
48 delete(h.act.gens, "a") // asleep: v2 is a first wake's build
49 h.act.mu.Unlock()
50 h.stage("v2")
51 h.install("v2", "a")
52 h.pass()
53 h.act.serve("a", "v1") // the first wake failed and the good build serves in its place
54 cur, _ := h.act.Current("a")
55 h.m.OnGen(activator.GenEvent{Kind: activator.GenFellBack, Route: h.rows.get("a"), FirstWake: true,
56 Mode: activator.ModeOverlap, Gen: cur, Failed: release.StagedIdentity{Version: "v2"},
57 Reason: "exited within 10s of becoming healthy"})
58 h.pass()
59 got := h.m.Status().Hosts["a"]
60 if got.Attempt != status.AttemptFailed || got.AttemptBuild != "v2" || got.HeldBy != "v2" || got.Reason != status.ReasonExit || got.Serving != "v1" {
61 t.Fatalf("a = %+v", got)
62 }
63}
64
65// A hold build that failed on the instance is held down: its wakes are
66// refused, which the report calls down.
67func TestAHeldDownInstanceShowsAsDown(t *testing.T) {
68 h := newHarness(t, "a")
69 h.stage("v2")
70 if _, err := h.book.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "a", Mode: "hold",
71 Reason: "swap: successor exited during its settle", At: t0, HeldDown: []string{"a"}}); err != nil {
72 t.Fatal(err)
73 }
74 h.install("v2", "a")
75 h.pass()
76 got := h.m.Status().Hosts["a"]
77 if got.Attempt != status.AttemptDown || got.HeldBy != "v2" || got.Reason != status.ReasonSettle {
78 t.Fatalf("a = %+v", got)
79 }
80}
81
82// Spec §6.4, §9.1: an intentional stop (hibernation, maintenance) during a
83// first wake's 10 s ends the observation with no verdict. GenCancelled closes
84// what GenHealthy opened, so the host stops showing as settling and the
85// deploy watch can complete; nothing is recorded against the build. With no
86// closing event, observing[host] stayed set for ever.
87func TestACancelledObservationStopsSettlingAndRecordsNothing(t *testing.T) {
88 h := newHarness(t, "a")
89 h.act.mu.Lock()
90 delete(h.act.gens, "a") // asleep: its first wake is the only thing that runs v2
91 h.act.mu.Unlock()
92 h.stage("v2")
93 h.install("v2", "a")
94 h.pass()
95 // The wake's plan: its events carry the plan's token (final review F4).
96 p := h.m.Plan(context.Background(), h.rows.get("a"))
97 g := activator.Gen{N: 9, Build: "v2", Digest: "sha-v2"}
98 h.m.OnGen(activator.GenEvent{Kind: activator.GenHealthy, Route: h.rows.get("a"), Gen: g, FirstWake: true, Token: p.Token})
99 if got := h.m.Status().Hosts["a"]; got.Attempt != status.AttemptSettling {
100 t.Fatalf("under observation: a = %+v, want settling", got)
101 }
102 h.m.OnGen(activator.GenEvent{Kind: activator.GenCancelled, Route: h.rows.get("a"), Gen: g, FirstWake: true, Token: p.Token})
103 if got := h.m.Status().Hosts["a"]; got.Attempt == status.AttemptSettling {
104 t.Fatalf("after the cancelled observation: a = %+v, still settling", got)
105 }
106 if _, ok := h.book.Lookup("bab", "blog", "v2"); ok {
107 t.Fatal("a cancelled observation was recorded as a failure of the build")
108 }
109 if r := h.rows.get("a"); r.GoodVersion != "v1" {
110 t.Fatalf("good build = %q: a cancelled observation is no settle", r.GoodVersion)
111 }
112}
113
114// Plan 6's deploy watch counts a sleeper as "installed, awaiting first
115// wake" once its box reports B installed. No attempt runs for a sleeper, so
116// the pass that first sees B must republish.
117func TestANewlyInstalledBuildIsPublishedAtOnce(t *testing.T) {
118 h := newHarness(t, "a")
119 var changes atomic.Int32
120 h.m.d.OnChange = func() { changes.Add(1) }
121 h.act.mu.Lock()
122 delete(h.act.gens, "a") // asleep
123 h.act.mu.Unlock()
124 h.stage("v2")
125 h.install("v2", "a")
126 h.pass()
127 if changes.Load() == 0 {
128 t.Fatal("a newly installed build waited for the five-minute status tick")
129 }
130 if got := h.m.Status().Hosts["a"]; got.Installed != "v2" || got.Awake {
131 t.Fatalf("a = %+v, want v2 installed and asleep", got)
132 }
133}
134
135// OnChange is a nudge called after every transition, with no manager or
136// scheduler lock held: this one reads Status, which takes both, so a call
137// under either lock deadlocks the test.
138func TestOnChangeFollowsEveryTransitionWithNoLockHeld(t *testing.T) {
139 h := newHarness(t, "a")
140 var changes atomic.Int32
141 h.m.d.OnChange = func() {
142 h.m.Status()
143 changes.Add(1)
144 }
145 h.stage("v2")
146 h.install("v2", "a")
147 h.pass()
148 waitFor(t, "a on v2", func() bool { return h.servingAll("v2", "a") })
149 waitFor(t, "the attempt's change", func() bool { return changes.Load() >= 2 }) // the pass's, then done()'s
150 before := changes.Load()
151 h.m.OnGen(activator.GenEvent{Kind: activator.GenSettled, Route: h.rows.get("a"), Gen: activator.Gen{N: 1, Build: "v2"}})
152 if changes.Load() <= before {
153 t.Fatal("OnGen did not report its transition")
154 }
155}
156
157func TestStatusReportsAConfigRevisionDeliveredButNotActive(t *testing.T) {
158 h := newHarness(t, "a")
159 h.pass()
160 gate := h.act.hold("a")
161 h.m.ConfigDelivered("a", "rev1")
162 waitFor(t, "the config attempt", func() bool { _, s, _ := h.act.snapshot(); return len(s) == 1 })
163 got := h.m.Status().Hosts["a"]
164 // The config attempt is its cohort's canary, so it reads as settling.
165 if !got.ConfigPending || got.Attempt != status.AttemptSettling || got.AttemptBuild != "v1" || got.ConfigError != "" {
166 t.Fatalf("a = %+v, want the config attempt in flight with its revision pending", got)
167 }
168 close(gate)
169 waitFor(t, "the config to activate", func() bool { return !h.m.Status().Hosts["a"].ConfigPending })
170}
171
172// Carried from Task 13: a host whose route left the box is forgotten by the
173// next Pass, queue slot and config track alike, so Status never reports a
174// departed host as queued. One with an attempt running is kept until the
175// attempt finishes, and a row only passing through "moving" keeps its place.
176func TestPassForgetsHostsThatLeftTheBox(t *testing.T) {
177 h := newHarness(t, "a", "b", "c")
178 h.stage("v2")
179 gate := h.act.hold("a")
180 h.install("v2", "a", "b", "c")
181 h.pass()
182 waitFor(t, "a's canary", func() bool { _, s, _ := h.act.snapshot(); return len(s) == 1 })
183 h.m.ConfigDelivered("b", "rev1")
184 if r := h.m.Status(); r.Hosts["b"].Attempt != status.AttemptQueued || !r.Hosts["b"].ConfigPending {
185 t.Fatalf("b = %+v, want queued with its config pending", r.Hosts["b"])
186 }
187 moving := h.rows.get("c")
188 moving.Status = "moving"
189 h.rows.put(moving)
190 h.rows.mu.Lock()
191 delete(h.rows.rows, "a")
192 delete(h.rows.rows, "b")
193 h.rows.mu.Unlock()
194 h.pass()
195 r := h.m.Status()
196 if _, ok := r.Hosts["b"]; ok {
197 t.Fatalf("b = %+v after its route left the box", r.Hosts["b"])
198 }
199 h.m.mu.Lock()
200 _, track := h.m.cfg["b"]
201 _, mapped := h.m.hostApp["b"]
202 h.m.mu.Unlock()
203 if track || mapped {
204 t.Fatalf("b's config track (%v) or app mapping (%v) outlived its route", track, mapped)
205 }
206 if got := r.Hosts["a"]; got.Attempt != status.AttemptSettling {
207 t.Fatalf("a = %+v: a host with its attempt running was forgotten", got)
208 }
209 if got := r.Hosts["c"]; got.Attempt != status.AttemptQueued || got.Installed != "v2" {
210 t.Fatalf("c = %+v: a row passing through moving lost its place", got)
211 }
212 close(gate)
213 waitFor(t, "a's attempt to finish", func() bool { calls, _, _ := h.act.snapshot(); return len(calls) >= 1 })
214 waitFor(t, "a forgotten once its attempt finished", func() bool {
215 h.pass()
216 _, ok := h.m.Status().Hosts["a"]
217 return !ok
218 })
219}
220
221// A host whose process is starting or stopping (the activator owns it and has
222// no current generation) is transient: no serving build and not awake, though
223// the last pass cached one. One that is awake or asleep is not (Astra R3
224// section 5 item 10). Nothing is installed, so no attempt runs: the harness's
225// swap goroutines would otherwise write the activator's generations under the
226// test.
227func TestStatusReportsAStartingOrStoppingHostAsTransient(t *testing.T) {
228 h := newHarness(t, "a", "b", "c")
229 h.pass()
230 if got := h.m.Status().Hosts["a"]; got.Transient || got.Installed != "v1" || got.Serving != "v1" || !got.Awake {
231 t.Fatalf("a before = %+v, want awake on v1 and not transient", got)
232 }
233 h.act.mu.Lock()
234 delete(h.act.gens, "a")
235 h.act.owned["a"] = true // stopping or waking: a process, no current generation
236 delete(h.act.gens, "b") // asleep: owned nowhere
237 h.act.mu.Unlock()
238 // The scheduler's cached serving build is deliberately not refreshed: the
239 // report must not repeat it for a host the activator says is mid-change.
240 r := h.m.Status()
241 a := r.Hosts["a"]
242 if !a.Transient || a.Serving != "" || a.Awake || a.Installed != "v1" {
243 t.Fatalf("a = %+v, want transient, not awake, no serving build", a)
244 }
245 if b := r.Hosts["b"]; b.Transient {
246 t.Fatalf("b = %+v: an asleep host is not transient", b)
247 }
248 if c := r.Hosts["c"]; c.Transient || c.Serving != "v1" || !c.Awake {
249 t.Fatalf("c = %+v: an awake host is not transient", c)
250 }
251}
252
253// Status asks the activator with no Manager or scheduler lock held (the lock
254// order on Manager), for both of its questions: Current for every host, and
255// Owns for one with no current generation (b, asleep here). The probe only
256// TryLocks, so a violation fails the test instead of deadlocking it. No
257// attempt runs (nothing is installed), so no other goroutine holds these
258// locks for long. The probe is the harness's (probeAct), wired in before
259// any scheduler started; its hook is installed atomically (final review
260// round 1, finding 7).
261func TestStatusAsksTheActivatorWithNoLockHeld(t *testing.T) {
262 h := newHarness(t, "a", "b")
263 h.pass()
264 h.m.mu.Lock()
265 scheds := make([]*scheduler, 0, len(h.m.apps))
266 for _, s := range h.m.apps {
267 scheds = append(scheds, s)
268 }
269 h.m.mu.Unlock()
270 if len(scheds) == 0 {
271 t.Fatal("the pass built no scheduler")
272 }
273 h.act.mu.Lock()
274 delete(h.act.gens, "b") // asleep: Status asks Owns about it
275 h.act.mu.Unlock()
276 lp := &lockProbe{m: h.m, scheds: scheds, t: t, asked: map[string]*atomic.Int64{"Owns": {}, "Current": {}}}
277 h.probe.onAsk(lp.check)
278 defer h.probe.onAsk(nil)
279 h.m.Status()
280 if lp.asked["Current"].Load() == 0 || lp.asked["Owns"].Load() == 0 {
281 t.Fatalf("Status asked Current %d and Owns %d time(s): want both asked", lp.asked["Current"].Load(), lp.asked["Owns"].Load())
282 }
283}
284
285// lockProbe fails the test when Owns or Current is asked by Status while the
286// Manager's or a scheduler's mutex is held.
287type lockProbe struct {
288 m *Manager
289 scheds []*scheduler
290 t *testing.T
291 asked map[string]*atomic.Int64 // by method; built before the hook is installed, never written after
292}
293
294func (p *lockProbe) check(method, _ string) {
295 if !askedByStatus() {
296 return // the scheduler's own goroutine asks too; only Status is under test
297 }
298 p.asked[method].Add(1)
299 // A lock that Status holds stays held for the whole ask, while a
300 // scheduler goroutine's own hold is brief, so a violation is a lock that
301 // cannot be taken for a long while. TryLock, never Lock: a violation
302 // fails the test instead of deadlocking it.
303 free := func(mu *sync.Mutex) bool {
304 for end := time.Now().Add(300 * time.Millisecond); time.Now().Before(end); time.Sleep(time.Millisecond) {
305 if mu.TryLock() {
306 mu.Unlock()
307 return true
308 }
309 }
310 return false
311 }
312 if !free(&p.m.mu) {
313 p.t.Errorf("activator asked (%s) with Manager.mu held", method)
314 }
315 for _, s := range p.scheds {
316 if !free(&s.mu) {
317 p.t.Errorf("activator asked (%s) with a scheduler.mu held", method)
318 }
319 }
320}
321
322// askedByStatus reports whether the calling goroutine is inside
323// Manager.Status.
324func askedByStatus() bool {
325 pcs := make([]uintptr, 32)
326 frames := runtime.CallersFrames(pcs[:runtime.Callers(2, pcs)])
327 for {
328 f, more := frames.Next()
329 if strings.HasSuffix(f.Function, "(*Manager).Status") {
330 return true
331 }
332 if !more {
333 return false
334 }
335 }
336}
337
338// A host that joins a scheduler after Status read the names was never asked of
339// the activator, so nothing says it is settled: that report calls it
340// transient, and the next one asks.
341func TestStatusCallsAHostThatJoinedAfterTheNamesWereReadTransient(t *testing.T) {
342 h := newHarness(t, "a")
343 h.pass()
344 h.m.mu.Lock()
345 var s *scheduler
346 for _, s = range h.m.apps {
347 }
348 h.m.mu.Unlock()
349 var once atomic.Bool
350 h.probe.onAsk(func(method, _ string) {
351 if method != "Current" || !askedByStatus() { // not the scheduler's own goroutine
352 return
353 }
354 if once.CompareAndSwap(false, true) { // the names are read; now a host joins
355 s.mu.Lock()
356 s.q.hosts["late"] = &hostSlot{wantBuild: "v1", good: "v1", serving: "v1"}
357 s.mu.Unlock()
358 }
359 })
360 defer h.probe.onAsk(nil)
361 r := h.m.Status()
362 if late := r.Hosts["late"]; !late.Transient || late.Serving != "" || late.Awake {
363 t.Fatalf("late = %+v, want transient for the report that never asked", late)
364 }
365 if a := r.Hosts["a"]; a.Transient || a.Serving != "v1" {
366 t.Fatalf("a = %+v, want awake on v1", a)
367 }
368}
369