carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package activator
2
3import (
4 "context"
5 "errors"
6 "os"
7 "path/filepath"
8 "reflect"
9 "slices"
10 "strings"
11 "sync/atomic"
12 "testing"
13 "time"
14
15 "github.com/carlosframework/platform/internal/hostclaim"
16 "github.com/carlosframework/platform/internal/hostwrite"
17 "github.com/carlosframework/platform/internal/registry"
18 "github.com/carlosframework/platform/internal/release"
19)
20
21// Zero-downtime plan 5 Task 12: the generations file names every live or
22// pending INSTANCE generation too (spec §4.2, §8.5), and the build a staged
23// instance spawn is about to exec is in a successfully written file before
24// that exec, or the spawn does not happen.
25
26// hostsOf is the generations file's hosts, or nil when it does not read.
27func hostsOf(path string) map[string][]string {
28 g, err := release.ReadGenerations(path)
29 if err != nil || g == nil {
30 return nil
31 }
32 return g.Hosts
33}
34
35// waitForPublications waits until the generations file at path has had n
36// publications (its seq). A wake goroutine's last write comes after the
37// request it served has returned; a test whose file lives in a TempDir
38// waits for it, or the write races the directory's removal.
39func waitForPublications(t *testing.T, path string, n uint64) *release.Generations {
40 t.Helper()
41 var g *release.Generations
42 waitFor(t, "the wake's last publication", func() bool {
43 var err error
44 g, err = release.ReadGenerations(path)
45 return err == nil && g != nil && g.Seq >= n
46 })
47 return g
48}
49
50// unwritableGenerationsFile is a path whose parent is a regular file, so
51// every write fails.
52func unwritableGenerationsFile(t *testing.T) string {
53 t.Helper()
54 blocker := filepath.Join(t.TempDir(), "not-a-dir")
55 if err := os.WriteFile(blocker, nil, 0o644); err != nil {
56 t.Fatal(err)
57 }
58 return filepath.Join(blocker, "zdd", "generations.json")
59}
60
61// breakGenerationsFile makes every later write of path fail: its directory
62// is replaced by a regular file.
63func breakGenerationsFile(t *testing.T, path string) {
64 t.Helper()
65 dir := filepath.Dir(path)
66 if err := os.RemoveAll(dir); err != nil {
67 t.Fatal(err)
68 }
69 if err := os.WriteFile(dir, nil, 0o644); err != nil {
70 t.Fatal(err)
71 }
72}
73
74func TestTheGenerationsFileFollowsEveryChange(t *testing.T) {
75 t.Parallel()
76 dir := t.TempDir()
77 good := stagedID(t, dir, "v1")
78 a, _, _ := planActivator(t, WakePlan{Boot: good, Mode: ModeOverlap})
79 a.GenerationsFile = release.GenerationsPath(dir)
80 if err := a.persistGenerations(); err != nil {
81 t.Fatal(err)
82 }
83 g, err := release.ReadGenerations(a.GenerationsFile)
84 if err != nil || g == nil || len(g.Hosts) != 0 {
85 t.Fatalf("before any spawn: %+v, %v; want an empty file, written", g, err)
86 }
87 done, err := a.beginRequest(context.Background(), genTestRoute(t, "one.example.com"))
88 if err != nil {
89 t.Fatal(err)
90 }
91 done()
92 // The wake goroutine republishes after the request it served has
93 // returned, and a reader that lands between a publication's intent and
94 // its file sees an incomplete one by design (release.ReadGenerations,
95 // plan 4 R19). So wait for a complete publication that names the awake
96 // generation, instead of reading once.
97 var lastErr error
98 waitFor(t, "a complete publication naming the awake generation", func() bool {
99 g, lastErr = release.ReadGenerations(a.GenerationsFile)
100 return lastErr == nil && g != nil && slices.Equal(g.Hosts["one.example.com"], []string{"v1"}) &&
101 slices.Equal(g.Paths["one.example.com"], []string{good.StagedPath})
102 })
103 a.BounceFor("one.example.com", CauseRestart)
104 // Four publications: the empty file, the plan before the lease, the
105 // awake generation, the stop (in either order with the third).
106 if g := waitForPublications(t, a.GenerationsFile, 4); len(g.Hosts) != 0 || len(g.Paths) != 0 {
107 t.Fatalf("after the stop: %+v, want no generations", g)
108 }
109}
110
111func TestTheGenerationsFileNamesThePlannedBuildBeforeItSpawns(t *testing.T) {
112 t.Parallel()
113 dir := t.TempDir()
114 good, next := stagedID(t, dir, "v1"), stagedID(t, dir, "v2")
115 a, b, _ := planActivator(t, WakePlan{Boot: next, Mode: ModeOverlap, FirstWake: true, Fallback: &good})
116 a.GenerationsFile = filepath.Join(dir, "zdd", "generations.json")
117 b.gate = make(chan struct{})
118 woke := make(chan error, 1)
119 go func() {
120 done, err := a.beginRequest(context.Background(), genTestRoute(t, "one.example.com"))
121 if err == nil {
122 done()
123 }
124 woke <- err
125 }()
126 waitFor(t, "the spawning build and its fallback pinned before the spawn", func() bool {
127 return len(b.started()) == 0 && slices.Equal(hostsOf(a.GenerationsFile)["one.example.com"], []string{"v1", "v2"})
128 })
129 close(b.gate)
130 if err := <-woke; err != nil {
131 t.Fatal(err)
132 }
133 waitForPublications(t, a.GenerationsFile, 2) // the plan, then the awake generation
134}
135
136// Spec §7.4, §8.1: a planned build the selection gate refuses after the
137// restore is planned again, and the replanned build is published before it
138// execs; when it cannot be, it is not exec'd, and that is no failure of
139// either build.
140func TestAReplannedBuildIsPublishedBeforeItExecs(t *testing.T) {
141 t.Parallel()
142 for _, broken := range []bool{false, true} {
143 name := "published"
144 if broken {
145 name = "unpublished"
146 }
147 t.Run(name, func(t *testing.T) {
148 dir := t.TempDir()
149 v2, v3 := stagedID(t, dir, "v2"), stagedID(t, dir, "v3")
150 a, b, evs := planActivator(t, WakePlan{})
151 a.GenerationsFile = filepath.Join(dir, "zdd", "generations.json")
152 var plans atomic.Int32
153 a.Planner = func(context.Context, registry.Route) WakePlan {
154 if plans.Add(1) == 1 {
155 return WakePlan{Boot: v2, Mode: ModeOverlap, Check: func(build string) error {
156 if build != "v2" {
157 return nil
158 }
159 if broken {
160 breakGenerationsFile(t, a.GenerationsFile)
161 }
162 return errors.New("v2 is held now")
163 }}
164 }
165 return WakePlan{Boot: v3, Mode: ModeOverlap}
166 }
167 if broken {
168 _, err := a.beginRequest(context.Background(), genTestRoute(t, "one.example.com"))
169 if !errors.Is(err, errStagedBuildNotPublished) {
170 t.Fatalf("err = %v, want errStagedBuildNotPublished", err)
171 }
172 if gens := b.started(); len(gens) != 0 {
173 t.Fatalf("spawned %+v with the replanned build unpublished", gens)
174 }
175 if k := evs.kinds(); len(k) != 0 {
176 t.Fatalf("events %v: a publication failure is no failure of the build", k)
177 }
178 return
179 }
180 b.gate = make(chan struct{})
181 woke := make(chan error, 1)
182 go func() {
183 done, err := a.beginRequest(context.Background(), genTestRoute(t, "one.example.com"))
184 if err == nil {
185 done()
186 }
187 woke <- err
188 }()
189 waitFor(t, "the replanned v3 pinned before its exec", func() bool {
190 return len(b.started()) == 0 && slices.Contains(hostsOf(a.GenerationsFile)["one.example.com"], "v3")
191 })
192 close(b.gate)
193 if err := <-woke; err != nil {
194 t.Fatal(err)
195 }
196 if gens := b.started(); len(gens) != 1 || gens[0].Build != "v3" {
197 t.Fatalf("spawned %+v, want the replanned v3 alone", gens)
198 }
199 waitForPublications(t, a.GenerationsFile, 3) // the plan, the replan, the awake generation
200 })
201 }
202}
203
204// Spec §4.2, §8.5: a failed wake whose generation is not proven terminated
205// keeps its lease and its process may still run (plan 3, unprovenWakes),
206// though its entry is gone. Its build stays in the generations file until
207// the proof holds.
208func TestARetainedFailedWakeStaysPinned(t *testing.T) {
209 a, _, _ := planActivator(t, WakePlan{})
210 a.GenerationsFile = filepath.Join(t.TempDir(), "zdd", "generations.json")
211 a.mu.Lock()
212 a.unprovenWakes = map[string]*unprovenWake{"one.example.com": {gen: Gen{N: 4, Build: "v2", StagedPath: "/opt/carlos/versions/v2/app"}}}
213 a.mu.Unlock()
214 if err := a.persistGenerations(); err != nil {
215 t.Fatal(err)
216 }
217 g, err := release.ReadGenerations(a.GenerationsFile)
218 if err != nil || g == nil || !slices.Equal(g.Hosts["one.example.com"], []string{"v2"}) ||
219 !slices.Equal(g.Paths["one.example.com"], []string{"/opt/carlos/versions/v2/app"}) {
220 t.Fatalf("generations %+v, %v: want the retained failed wake's v2 pinned", g, err)
221 }
222}
223
224// Spec §8.5: every generation of the operation set is named, the successor
225// before its exec, and the retiring one until its termination is proven.
226func TestTheGenerationsFileFollowsAnOverlapSwap(t *testing.T) {
227 t.Parallel()
228 f := newSwapFixture(t)
229 f.a.GenerationsFile = release.GenerationsPath(t.TempDir())
230 gate := make(chan struct{})
231 f.b.gmu.Lock()
232 f.b.genGate = gate
233 f.b.gmu.Unlock()
234 res := make(chan SwapResult, 1)
235 go func() { res <- f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest) }()
236 waitFor(t, "the successor pinned beside the incumbent before its exec", func() bool {
237 gens, _, _ := f.b.calls()
238 return gens == 1 && slices.Equal(hostsOf(f.a.GenerationsFile)[f.host], []string{"v1", "v2"})
239 })
240 close(gate)
241 if r := <-res; r.Outcome != SwapDone {
242 t.Fatalf("Swap = %+v, want done", r)
243 }
244 waitForGenerations(t, f.a.GenerationsFile, map[string][]string{f.host: {"v2"}})
245}
246
247// Spec §4.2, §8.5: root pins only what the file names. A staged instance
248// spawn whose build could not be published must not exec it. The refusal is
249// no failure of the build, takes no lease, and is never down.
250func TestAnUnpublishedBuildIsNeverSpawned(t *testing.T) {
251 t.Parallel()
252 t.Run("planned wake", func(t *testing.T) {
253 dir := t.TempDir()
254 good, next := stagedID(t, dir, "v1"), stagedID(t, dir, "v2")
255 a, b, evs := planActivator(t, WakePlan{Boot: next, Mode: ModeOverlap, FirstWake: true, Fallback: &good})
256 a.GenerationsFile = unwritableGenerationsFile(t)
257 _, err := a.beginRequest(context.Background(), genTestRoute(t, "one.example.com"))
258 if !errors.Is(err, errStagedBuildNotPublished) {
259 t.Fatalf("err = %v, want errStagedBuildNotPublished", err)
260 }
261 if gens := b.started(); len(gens) != 0 {
262 t.Fatalf("spawned %+v with the generations file unwritten", gens)
263 }
264 if acquires, _, _ := b.counts(); acquires != 0 {
265 t.Fatal("an unpublished wake took the lease")
266 }
267 if k := evs.kinds(); len(k) != 0 {
268 t.Fatalf("events %v: a publication failure is no failure of the build", k)
269 }
270 })
271 t.Run("overlap successor", func(t *testing.T) {
272 f := newSwapFixture(t)
273 f.a.GenerationsFile = unwritableGenerationsFile(t)
274 res := f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest)
275 if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, errStagedBuildNotPublished.Error()) {
276 t.Fatalf("Swap = %+v, want abandoned: unpublished", res)
277 }
278 if gens, raises, restores := f.b.calls(); gens != 0 || raises != restores {
279 t.Fatalf("spawned %d, raises %d, restores %d", gens, raises, restores)
280 }
281 f.a.mu.Lock()
282 members := len(f.a.entries[f.host].ops.members)
283 f.a.mu.Unlock()
284 if members != 0 {
285 t.Fatalf("%d members left in the operation set", members)
286 }
287 })
288 t.Run("hold's pinned wake", func(t *testing.T) {
289 f := newSwapFixture(t)
290 sink := &lockedSink{}
291 f.a.Logs = sink
292 f.a.GenerationsFile = unwritableGenerationsFile(t)
293 res := f.a.Swap(context.Background(), f.attempt(ModeHold), f.newest)
294 if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, errStagedBuildNotPublished.Error()) {
295 t.Fatalf("Swap = %+v, want abandoned: unpublished", res)
296 }
297 if gens, _, _ := f.b.calls(); gens != 0 {
298 t.Fatalf("spawned %d generations with the pinned build unpublished", gens)
299 }
300 if got := sink.errors(); len(got) != 0 {
301 t.Fatalf("member error events %q, want none for an abandoned hold", got)
302 }
303 })
304 t.Run("claimed planned wake", func(t *testing.T) {
305 dir := t.TempDir()
306 good, next := stagedID(t, dir, "v1"), stagedID(t, dir, "v2")
307 a, admits, prepares, evs := claimedPlanActivator(t, WakePlan{Boot: next, Mode: ModeOverlap, FirstWake: true, Fallback: &good})
308 a.GenerationsFile = unwritableGenerationsFile(t)
309 _, err := a.beginRequest(context.Background(), registry.Route{Host: "x.example", Kind: "instance", Hibernate: true})
310 if !errors.Is(err, errStagedBuildNotPublished) {
311 t.Fatalf("err = %v, want errStagedBuildNotPublished", err)
312 }
313 if admits.Load() != 0 || prepares.Load() != 0 {
314 t.Fatalf("admitted %d, prepared %d: an unpublished claimed wake reached the admission", admits.Load(), prepares.Load())
315 }
316 if k := evs.kinds(); len(k) != 0 {
317 t.Fatalf("events %v: a publication failure is no failure of the build", k)
318 }
319 })
320}
321
322// claimedPlanActivator is a claims-on Service whose planner answers plan,
323// as TestAPlannedClaimedWakeRefusesAtTheLaunchRecordingNothing builds it,
324// counting the admissions and preparations a wake reaches, and logging the
325// generation events it records.
326func claimedPlanActivator(t *testing.T, plan WakePlan) (*Service, *atomic.Int32, *atomic.Int32, *genLog) {
327 t.Helper()
328 const host = "x.example"
329 obs := claimedObs(host)
330 obs.Claim.Op.Exec = "box"
331 obs.Claim.Holder.WriterBox = "box"
332 admitted := obs
333 claim := *obs.Claim
334 holder := *claim.Holder
335 holder.Session, holder.Generation = 2, "two"
336 claim.Holder = &holder
337 admitted.Claim = &claim
338 work, _ := (hostwrite.Config{}).Select(context.Background(), host, "spawn")
339 var (
340 reads atomic.Int32
341 admits atomic.Int32
342 prepares atomic.Int32
343 )
344 reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
345 if err != nil {
346 t.Fatal(err)
347 }
348 a := New(Config{Bucket: "test-bucket", Idle: time.Hour, HostWrites: hostwrite.Config{Enabled: true}, Claims: &ClaimRuntime{Box: "box",
349 Read: func(context.Context, string) (hostclaim.Observed, error) {
350 if reads.Add(1) == 1 {
351 return obs, nil
352 }
353 return admitted, nil
354 },
355 StopForeign: func(context.Context, string) error { return nil },
356 FailedAdmission: func(context.Context, hostclaim.Observed, error) error { return nil },
357 Prerequisites: func(context.Context, registry.Route, hostclaim.Observed, startPolicy) error { return nil },
358 Admit: func(context.Context, registry.Route, hostclaim.Observed, startPolicy) (hostclaim.Observed, error) {
359 admits.Add(1)
360 return admitted, nil
361 },
362 Prepare: func(_ context.Context, _ registry.Route, _ hostclaim.Observed, p startPolicy) (ClaimStart, error) {
363 prepares.Add(1)
364 return claimedStartFor(admitted, work, p.Pin.Build), nil
365 }}}, &fakeBackend{}, reg)
366 evs := &genLog{}
367 a.OnGen = evs.add
368 a.Planner = func(context.Context, registry.Route) WakePlan { return plan }
369 return a, &admits, &prepares, evs
370}
371
372// The flag-independent behaviour (rulings R17, R19): with generations off
373// the file says exactly what plan 4's writer said. A worker's captured build
374// is listed by version and by staged path; an instance, which runs from the
375// live link, adds nothing.
376func TestTheGenerationsFileIsUnchangedWithTheFlagOff(t *testing.T) {
377 t.Parallel()
378 a, _, _, _, _ := stagedActivator(t) // generations off, no planner
379 a.GenerationsFile = release.GenerationsPath(t.TempDir())
380 const bot = "bot.x.internal"
381 if err := a.WakeWorker(alwaysOnWorkerRoute(bot)); err != nil {
382 t.Fatal(err)
383 }
384 done, err := a.beginRequest(context.Background(), genTestRoute(t, "one.example.com"))
385 if err != nil {
386 t.Fatal(err)
387 }
388 done()
389 a.mu.Lock()
390 instGen := a.entries["one.example.com"].gen
391 a.mu.Unlock()
392 if instGen.Build == "" {
393 t.Fatal("the instance's generation names no build: this test would prove nothing")
394 }
395 want := map[string][]string{bot: {"v1"}}
396 wantPaths := map[string][]string{bot: {fakeIdentity("v1").StagedPath}}
397 check := func(when string) {
398 t.Helper()
399 if err := a.persistGenerations(); err != nil {
400 t.Fatal(err)
401 }
402 g := readGenerationsFile(t, a.GenerationsFile)
403 if !reflect.DeepEqual(g.Hosts, want) || !reflect.DeepEqual(g.Paths, wantPaths) {
404 t.Fatalf("%s: flag-off file names %v and paths %v, want %v and %v", when, g.Hosts, g.Paths, want, wantPaths)
405 }
406 }
407 // With the instance awake, after a sweep, and after its stop: none of
408 // it may add the instance.
409 check("both awake")
410 a.sweepOnce()
411 check("after a sweep")
412 a.mu.Lock()
413 a.beginStopLocked(a.entries["one.example.com"], StopIdle)
414 a.mu.Unlock()
415 waitFor(t, "the instance's stop", func() bool {
416 a.mu.Lock()
417 defer a.mu.Unlock()
418 return a.entries["one.example.com"] == nil
419 })
420 check("after the instance's stop")
421}
422
423// Task 11's socketless attempt names its build and its fallback while it
424// runs (its fallback above all: startWorker publishes only the build about
425// to exec).
426func TestAWorkerAttemptsBuildsArePublished(t *testing.T) {
427 a, _, _ := planActivator(t, WakePlan{})
428 a.GenerationsFile = filepath.Join(t.TempDir(), "zdd", "generations.json")
429 a.mu.Lock()
430 a.entries["bot.x.internal"] = &actEntry{host: "bot.x.internal", worker: true, attemptBuilds: []string{"v3", "v1"}}
431 a.mu.Unlock()
432 if err := a.persistGenerations(); err != nil {
433 t.Fatal(err)
434 }
435 if got := hostsOf(a.GenerationsFile)["bot.x.internal"]; !slices.Equal(got, []string{"v1", "v3"}) {
436 t.Fatalf("attempt builds published as %v, want [v1 v3]", got)
437 }
438}
439
440// The index contract: the generations file is written before the agent's
441// first spawn, whatever CARLOS_SWAP says. A file left by a previous
442// incarnation is stale (it may over-pin, or have been cut short by a crash,
443// which root reads as incomplete and answers by neither restaging nor
444// sweeping), so Start replaces it with this incarnation's snapshot, which is
445// empty: nothing has spawned yet.
446func TestStartReplacesAStaleGenerationsFile(t *testing.T) {
447 t.Parallel()
448 stale := func(t *testing.T, path string) {
449 t.Helper()
450 if err := release.WriteGenerations(path, release.Generations{
451 Hosts: map[string][]string{"gone.example.com": {"v9"}},
452 Paths: map[string][]string{"gone.example.com": {"/opt/carlos/versions/v9/app"}},
453 }); err != nil {
454 t.Fatal(err)
455 }
456 }
457 for _, tc := range []struct {
458 name string
459 setup func(t *testing.T, path string)
460 }{
461 {"a complete file from a previous run", stale},
462 {"an intent with the file missing", func(t *testing.T, path string) {
463 stale(t, path)
464 if err := os.Remove(path); err != nil {
465 t.Fatal(err)
466 }
467 }},
468 {"an intent ahead of the file's seq", func(t *testing.T, path string) {
469 stale(t, path)
470 if err := os.WriteFile(release.GenerationsSeqPath(path), []byte("7\n"), 0o644); err != nil {
471 t.Fatal(err)
472 }
473 }},
474 } {
475 t.Run(tc.name, func(t *testing.T) {
476 a, _, _, _, _ := stagedActivator(t) // generations off, no planner, no instances
477 a.GenerationsFile = release.GenerationsPath(t.TempDir())
478 tc.setup(t, a.GenerationsFile)
479 a.Start()
480 t.Cleanup(func() { a.Shutdown(context.Background()) })
481 g, err := release.ReadGenerations(a.GenerationsFile)
482 if err != nil || g == nil {
483 t.Fatalf("after Start the file reads %+v, %v; want a complete publication", g, err)
484 }
485 if len(g.Hosts) != 0 || len(g.Paths) != 0 {
486 t.Fatalf("after Start the file names %v and %v, want nothing", g.Hosts, g.Paths)
487 }
488 })
489 }
490}
491
492// A write that fails at Start is logged and left to the sweep: it does not
493// block or fail Start.
494func TestStartDoesNotFailOnAnUnwritableGenerationsFile(t *testing.T) {
495 a, _, _, _, _ := stagedActivator(t)
496 a.GenerationsFile = unwritableGenerationsFile(t)
497 a.Start()
498 t.Cleanup(func() { a.Shutdown(context.Background()) })
499 a.genMu.Lock()
500 dirty := a.genDirty
501 a.genMu.Unlock()
502 if !dirty {
503 t.Fatal("a failed write at Start did not mark the publication dirty for the sweep")
504 }
505}
506