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 "fmt"
6 "sort"
7 "strings"
8 "sync"
9 "sync/atomic"
10 "testing"
11 "time"
12
13 "github.com/carlosframework/platform/internal/activator"
14 "github.com/carlosframework/platform/internal/appconfig"
15 "github.com/carlosframework/platform/internal/registry"
16 "github.com/carlosframework/platform/internal/release"
17)
18
19type swapCall struct {
20 a activator.Attempt
21 outcome activator.SwapOutcome
22}
23
24type replaceCall struct {
25 a activator.Attempt
26 fallback *release.StagedIdentity
27}
28
29// fakeAct models plan 3's Swap faithfully on the one point this plan relies
30// on: the re-check before the switch. A superseded attempt never switches.
31type fakeAct struct {
32 mu sync.Mutex
33 gens map[string]activator.Gen
34 owned map[string]bool
35 n uint64
36 gates map[string]chan struct{}
37 outcome func(a activator.Attempt) activator.SwapOutcome
38 started []string
39 calls []swapCall
40 inFlight int
41 maxFlight int
42 replaced []replaceCall
43 worker func(a activator.Attempt, fallback *release.StagedIdentity) activator.SwapResult
44 // mode, when set, is the effective mode Swap reports (SwapResult.Mode):
45 // plan 3 runs an overlap attempt on a foreign-layout row as a hold.
46 mode activator.SwapMode
47 // reason, when set, is a failed Swap's diagnostic text in place of
48 // "health check failed".
49 reason string
50 // onHeldPublish, when set, runs on every PublishHeldServing: a test's
51 // view of the moment the scheduler releases a held worker publication.
52 onHeldPublish func(host string)
53 // executed, when set, is Swap's execution evidence (SwapResult.Executed)
54 // for an attempt that ended out. Unset, only an abandoned attempt ran
55 // nothing.
56 executed func(a activator.Attempt, out activator.SwapOutcome) bool
57}
58
59// PublishHeldServing runs onHeldPublish, when set.
60func (f *fakeAct) PublishHeldServing(host string) {
61 f.mu.Lock()
62 hook := f.onHeldPublish
63 f.mu.Unlock()
64 if hook != nil {
65 hook(host)
66 }
67}
68
69// EffectiveMode is the mode Swap will run m in: mode when a test set it (the
70// foreign-layout override it models), m otherwise.
71func (f *fakeAct) EffectiveMode(_ string, m activator.SwapMode) activator.SwapMode {
72 f.mu.Lock()
73 defer f.mu.Unlock()
74 if f.mode != "" {
75 return f.mode
76 }
77 return m
78}
79
80func newFakeAct() *fakeAct {
81 return &fakeAct{gens: map[string]activator.Gen{}, owned: map[string]bool{}, gates: map[string]chan struct{}{}}
82}
83
84// probeAct is the Activator the harness hands its manager: the fake, with
85// a hook a test may install at any moment, run before every Owns and
86// Current. It is wired in at harness build, before any scheduler starts,
87// and the hook is swapped atomically (final review round 1, finding 7,
88// Astra): a test that replaced Deps.Act after a Pass raced the scheduler
89// goroutines that read it.
90type probeAct struct {
91 *fakeAct
92 hook atomic.Pointer[func(method, host string)]
93}
94
95// onAsk installs f as the hook; nil removes it.
96func (p *probeAct) onAsk(f func(method, host string)) {
97 if f == nil {
98 p.hook.Store(nil)
99 return
100 }
101 p.hook.Store(&f)
102}
103
104func (p *probeAct) ask(method, host string) {
105 if f := p.hook.Load(); f != nil {
106 (*f)(method, host)
107 }
108}
109
110func (p *probeAct) Owns(host string) bool {
111 p.ask("Owns", host)
112 return p.fakeAct.Owns(host)
113}
114
115func (p *probeAct) Current(host string) (activator.Gen, bool) {
116 p.ask("Current", host)
117 return p.fakeAct.Current(host)
118}
119
120func (f *fakeAct) Current(host string) (activator.Gen, bool) {
121 f.mu.Lock()
122 defer f.mu.Unlock()
123 g, ok := f.gens[host]
124 return g, ok
125}
126
127func (f *fakeAct) Owns(host string) bool {
128 f.mu.Lock()
129 defer f.mu.Unlock()
130 _, ok := f.gens[host]
131 return ok || f.owned[host]
132}
133
134func (f *fakeAct) serve(host, build string) {
135 f.mu.Lock()
136 defer f.mu.Unlock()
137 f.n++
138 f.gens[host] = activator.Gen{N: f.n, Build: build, StagedPath: "/opt/" + build, Digest: "sha-" + build}
139}
140
141func (f *fakeAct) hold(host string) chan struct{} {
142 f.mu.Lock()
143 defer f.mu.Unlock()
144 ch := make(chan struct{})
145 f.gates[host] = ch
146 return ch
147}
148
149func (f *fakeAct) Swap(_ context.Context, a activator.Attempt, newest func(string) uint64) activator.SwapResult {
150 f.mu.Lock()
151 f.inFlight++
152 f.maxFlight = max(f.maxFlight, f.inFlight)
153 f.started = append(f.started, a.Host+":"+a.Build)
154 gate := f.gates[a.Host]
155 delete(f.gates, a.Host)
156 f.mu.Unlock()
157 if gate != nil {
158 <-gate
159 }
160 out := activator.SwapDone
161 if newest(a.Host) != a.SchedRev {
162 out = activator.SwapAbandoned
163 } else if f.outcome != nil {
164 out = f.outcome(a)
165 }
166 f.mu.Lock()
167 defer f.mu.Unlock()
168 f.inFlight--
169 f.calls = append(f.calls, swapCall{a: a, outcome: out})
170 executed := out != activator.SwapAbandoned
171 if f.executed != nil {
172 executed = f.executed(a, out)
173 }
174 if out != activator.SwapDone {
175 reason := "health check failed"
176 if f.reason != "" {
177 reason = f.reason
178 }
179 return activator.SwapResult{Outcome: out, Reason: reason, Mode: f.mode, Executed: executed}
180 }
181 f.n++
182 g := activator.Gen{N: f.n, Build: a.Build, StagedPath: a.StagedPath, Digest: a.Digest, Demand: a.Demand}
183 f.gens[a.Host] = g
184 return activator.SwapResult{Outcome: out, Gen: g, Executed: executed}
185}
186
187// ReplaceWorker models plan 5's same-entry socketless attempt (Task 11):
188// worker decides the outcome, and every call is recorded with the fallback
189// it was handed. The default is a clean replacement onto the attempt's build.
190func (f *fakeAct) ReplaceWorker(_ context.Context, a activator.Attempt, fallback *release.StagedIdentity, newest func(string) uint64) activator.SwapResult {
191 f.mu.Lock()
192 f.replaced = append(f.replaced, replaceCall{a: a, fallback: fallback})
193 decide := f.worker
194 f.mu.Unlock()
195 if newest(a.Host) != a.SchedRev {
196 return activator.SwapResult{Outcome: activator.SwapAbandoned, Reason: "superseded"}
197 }
198 res := activator.SwapResult{Outcome: activator.SwapDone}
199 if decide != nil {
200 res = decide(a, fallback)
201 }
202 f.mu.Lock()
203 defer f.mu.Unlock()
204 switch {
205 case res.Outcome == activator.SwapDone && res.CleanExit:
206 // A one-shot's child exited: the result names its generation, but
207 // the activator serves nothing for the host afterwards.
208 f.n++
209 res.Gen = activator.Gen{N: f.n, Build: a.Build, StagedPath: a.StagedPath, Digest: a.Digest, Demand: a.Demand}
210 delete(f.gens, a.Host)
211 case res.Outcome == activator.SwapDone:
212 f.n++
213 res.Gen = activator.Gen{N: f.n, Build: a.Build, StagedPath: a.StagedPath, Digest: a.Digest, Demand: a.Demand}
214 f.gens[a.Host] = res.Gen
215 case res.Outcome == activator.SwapFailed && fallback != nil:
216 f.n++
217 res.Gen = activator.Gen{N: f.n, Build: fallback.Version, StagedPath: fallback.StagedPath, Digest: fallback.Digest, Demand: fallback.Demand}
218 f.gens[a.Host] = res.Gen
219 case res.Outcome == activator.SwapDown:
220 delete(f.gens, a.Host)
221 }
222 return res
223}
224
225func (f *fakeAct) replacements() []replaceCall {
226 f.mu.Lock()
227 defer f.mu.Unlock()
228 return append([]replaceCall(nil), f.replaced...)
229}
230
231func (f *fakeAct) snapshot() ([]swapCall, []string, int) {
232 f.mu.Lock()
233 defer f.mu.Unlock()
234 return append([]swapCall(nil), f.calls...), append([]string(nil), f.started...), f.maxFlight
235}
236
237type fakeRoutes struct {
238 mu sync.Mutex
239 rows map[string]registry.Route
240 // getHook, when set, runs at the start of every Get, with no lock held:
241 // a test's window into the moment the scheduler reads a row. getErr,
242 // when set and answering an error, fails that Get.
243 getHook func(host string)
244 getErr func(host string) error
245 // setGoodHook, when set, runs at the start of every SetGoodBuild, with
246 // no lock held. setServingHook does the same for SetServingVersion.
247 setGoodHook func(host string)
248 setServingHook func(host string)
249}
250
251func (f *fakeRoutes) Get(host string) (*registry.Route, error) {
252 f.mu.Lock()
253 hook, fail := f.getHook, f.getErr
254 f.mu.Unlock()
255 if hook != nil {
256 hook(host)
257 }
258 if fail != nil {
259 if err := fail(host); err != nil {
260 return nil, err
261 }
262 }
263 f.mu.Lock()
264 defer f.mu.Unlock()
265 r, ok := f.rows[host]
266 if !ok {
267 return nil, nil
268 }
269 return &r, nil
270}
271
272func (f *fakeRoutes) List() ([]registry.Route, error) {
273 f.mu.Lock()
274 defer f.mu.Unlock()
275 out := make([]registry.Route, 0, len(f.rows))
276 for _, r := range f.rows {
277 out = append(out, r)
278 }
279 sort.Slice(out, func(i, j int) bool { return out[i].Host < out[j].Host })
280 return out, nil
281}
282
283func (f *fakeRoutes) SetGoodBuild(host string, expect, next registry.GoodBuild) error {
284 f.mu.Lock()
285 hook := f.setGoodHook
286 f.mu.Unlock()
287 if hook != nil {
288 hook(host)
289 }
290 f.mu.Lock()
291 defer f.mu.Unlock()
292 r := f.rows[host]
293 if r.Good() != expect {
294 return registry.ErrGoodBuildChanged
295 }
296 r.GoodVersion, r.GoodDigest, r.GoodDemand = next.Version, next.Digest, next.Demand
297 f.rows[host] = r
298 return nil
299}
300
301func (f *fakeRoutes) SetServingVersion(host, ver string) error {
302 f.mu.Lock()
303 hook := f.setServingHook
304 f.mu.Unlock()
305 if hook != nil {
306 hook(host)
307 }
308 f.mu.Lock()
309 defer f.mu.Unlock()
310 r := f.rows[host]
311 r.ServingVersion = ver
312 f.rows[host] = r
313 return nil
314}
315
316func (f *fakeRoutes) put(r registry.Route) {
317 f.mu.Lock()
318 defer f.mu.Unlock()
319 f.rows[r.Host] = r
320}
321
322func (f *fakeRoutes) get(host string) registry.Route {
323 f.mu.Lock()
324 defer f.mu.Unlock()
325 return f.rows[host]
326}
327
328type eventLog struct {
329 mu sync.Mutex
330 lines []string
331}
332
333func (e *eventLog) App(string, string, string, string) {}
334
335func (e *eventLog) Event(_, _, host, _, msg string) {
336 e.mu.Lock()
337 defer e.mu.Unlock()
338 e.lines = append(e.lines, host+": "+msg)
339}
340
341func (e *eventLog) has(substr string) bool {
342 e.mu.Lock()
343 defer e.mu.Unlock()
344 for _, l := range e.lines {
345 if strings.Contains(l, substr) {
346 return true
347 }
348 }
349 return false
350}
351
352// count is how many logged lines contain substr.
353func (e *eventLog) count(substr string) int {
354 e.mu.Lock()
355 defer e.mu.Unlock()
356 n := 0
357 for _, l := range e.lines {
358 if strings.Contains(l, substr) {
359 n++
360 }
361 }
362 return n
363}
364
365// harness is a manager over the fakes, for app "blog" of account "bab",
366// every host awake on v1, its good build.
367type harness struct {
368 t *testing.T
369 act *fakeAct
370 probe *probeAct // the manager's Activator: act behind a test hook
371 rows *fakeRoutes
372 book *FailureBook
373 m *Manager
374 opt, data string
375 mu sync.Mutex
376 now time.Time
377 concurrency int
378 swap string
379 demands map[string]string
380 mismatch map[string]bool
381 demandErr map[string]error
382 noDigest map[string]bool
383 pointers map[string]release.ChannelPointer // channel -> its signed pointer
384 failing atomic.Bool
385 touched atomic.Int32
386 events *eventLog
387 // pointerHook, when set, runs at the start of every pointer read.
388 pointerHook func()
389 // demandHook, when set, runs at the start of every demand read, with no
390 // lock held: a test's window into the planner's slow store read.
391 demandHook func(ver string)
392 // policyHook, when set, runs at the start of every policy load, with no
393 // lock held: a test's window into the planner's policy read.
394 policyHook func()
395}
396
397func newHarness(t *testing.T, hosts ...string) *harness {
398 t.Helper()
399 h := &harness{t: t, act: newFakeAct(), rows: &fakeRoutes{rows: map[string]registry.Route{}}, book: &FailureBook{},
400 opt: t.TempDir(), data: t.TempDir(), now: t0, concurrency: 1,
401 demands: map[string]string{}, mismatch: map[string]bool{}, demandErr: map[string]error{}, noDigest: map[string]bool{},
402 pointers: map[string]release.ChannelPointer{},
403 events: &eventLog{}}
404 h.book.now = h.clock // a marker's At follows the harness clock
405 h.probe = &probeAct{fakeAct: h.act}
406 h.stage("v1")
407 for _, host := range hosts {
408 h.rows.put(registry.Route{Host: host, Kind: "instance", App: "blog", Account: "bab", Status: "ready",
409 Hibernate: true, Version: "v1", GoodVersion: "v1", GoodDigest: "sha-v1"})
410 h.act.serve(host, "v1")
411 }
412 ctx, cancel := context.WithCancel(context.Background())
413 t.Cleanup(cancel)
414 h.m = NewManager(ctx, Deps{
415 Act: h.probe, Routes: h.rows, Book: h.book, OptDir: h.opt, DataDir: h.data, Box: "box-1",
416 Demand: h.demand, Policy: h.policy, Pointer: h.pointerFor, Events: h.events,
417 Touch: func() { h.touched.Add(1) }, Now: h.clock, Logf: func(string, ...any) {},
418 })
419 return h
420}
421
422// pointer signs channel's pointer onto ver at promoted, as `carlos promote`
423// (with or without --retry) would.
424func (h *harness) pointer(channel, ver string, promoted time.Time) {
425 h.mu.Lock()
426 defer h.mu.Unlock()
427 h.pointers[channel] = release.ChannelPointer{Schema: 1, Account: "bab", App: "blog", Channel: channel, Version: ver, PromotedAt: promoted}
428}
429
430func (h *harness) pointerFor(_ context.Context, _, _, channel string) (*release.ChannelPointer, error) {
431 h.mu.Lock()
432 hook := h.pointerHook
433 h.mu.Unlock()
434 if hook != nil {
435 hook()
436 }
437 h.mu.Lock()
438 defer h.mu.Unlock()
439 p, ok := h.pointers[channel]
440 if !ok {
441 return nil, nil
442 }
443 return &p, nil
444}
445
446func (h *harness) clock() time.Time {
447 h.mu.Lock()
448 defer h.mu.Unlock()
449 return h.now
450}
451
452func (h *harness) advance(d time.Duration) {
453 h.mu.Lock()
454 defer h.mu.Unlock()
455 h.now = h.now.Add(d)
456}
457
458func (h *harness) stage(vers ...string) {
459 for _, v := range vers {
460 if err := release.StageArtifact(h.opt, "bab", v, "blog", []byte(v)); err != nil {
461 h.t.Fatal(err)
462 }
463 }
464}
465
466// demand is release.CanonicalDemand's contract: a mismatch wraps
467// release.ErrStagedDigestMismatch (a staging refusal); demandErr is any other
468// error, and noDigest is a store that could not compare, both of which must
469// end as a hold attempt.
470func (h *harness) demand(_ context.Context, _, _, ver, _ string) (string, string, error) {
471 h.mu.Lock()
472 hook := h.demandHook
473 h.mu.Unlock()
474 if hook != nil {
475 hook(ver)
476 }
477 h.mu.Lock()
478 defer h.mu.Unlock()
479 if h.mismatch[ver] {
480 return "", "", fmt.Errorf("%w: the staged executable's digest differs from the canonical manifest", release.ErrStagedDigestMismatch)
481 }
482 if err := h.demandErr[ver]; err != nil {
483 return "", "", err
484 }
485 if h.noDigest[ver] {
486 return "", "", nil
487 }
488 return h.demands[ver], "sha-" + ver, nil
489}
490
491func (h *harness) policy(context.Context, string, string) (*appconfig.Manifest, error) {
492 h.mu.Lock()
493 hook := h.policyHook
494 h.mu.Unlock()
495 if hook != nil {
496 hook()
497 }
498 h.mu.Lock()
499 defer h.mu.Unlock()
500 return &appconfig.Manifest{Schema: 1, App: "blog", Swap: h.swap, SwapConcurrency: h.concurrency}, nil
501}
502
503func (h *harness) install(ver string, hosts ...string) {
504 for _, host := range hosts {
505 r := h.rows.get(host)
506 r.Version = ver
507 h.rows.put(r)
508 }
509}
510
511func (h *harness) pass() {
512 rs, _ := h.rows.List()
513 h.m.Pass(rs)
514}
515
516func (h *harness) servingAll(build string, hosts ...string) bool {
517 for _, host := range hosts {
518 if g, ok := h.act.Current(host); !ok || g.Build != build {
519 return false
520 }
521 }
522 return true
523}
524
525// flushAcks waits until the manager's ack writer has written every ack
526// handed to it so far. Acks are written off the path that releases them
527// (Task 11), so a test reads what was written only after this.
528func (h *harness) flushAcks() {
529 h.t.Helper()
530 waitFor(h.t, "the ack writer to finish", h.m.acks.idle)
531}
532
533func waitFor(t *testing.T, what string, cond func() bool) {
534 t.Helper()
535 deadline := time.Now().Add(5 * time.Second)
536 for time.Now().Before(deadline) {
537 if cond() {
538 return
539 }
540 time.Sleep(5 * time.Millisecond)
541 }
542 t.Fatalf("timed out waiting for %s", what)
543}
544