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 "bytes"
5 "context"
6 "errors"
7 "fmt"
8 "os"
9 "path/filepath"
10 "reflect"
11 "runtime"
12 "strings"
13 "testing"
14 "time"
15
16 "github.com/carlosframework/platform/internal/registry"
17 "github.com/carlosframework/platform/internal/release"
18)
19
20// readGenerations is the generations file's hosts, or nil when it is absent.
21func readGenerations(t *testing.T, path string) map[string][]string {
22 t.Helper()
23 g, err := release.ReadGenerations(path)
24 if err != nil {
25 t.Fatalf("ReadGenerations: %v", err)
26 }
27 if g == nil {
28 return nil
29 }
30 return g.Hosts
31}
32
33func waitForGenerations(t *testing.T, path string, want map[string][]string) {
34 t.Helper()
35 waitFor(t, "generations file naming "+stringOf(want), func() bool {
36 g, err := release.ReadGenerations(path)
37 return err == nil && g != nil && reflect.DeepEqual(g.Hosts, want)
38 })
39}
40
41func stringOf(m map[string][]string) string {
42 var b strings.Builder
43 for h, vs := range m {
44 b.WriteString(h + "=" + strings.Join(vs, ",") + " ")
45 }
46 return b.String()
47}
48
49// Ruling R17: the file names a worker's captured build after its wake, keeps
50// naming it across a crash recycle while live/<host> names another build,
51// follows a converge, and drops it once the worker stops.
52func TestWorkerGenerationsFileFollowsTheCapturedBuild(t *testing.T) {
53 t.Parallel()
54 a, b, clk, _, ver := stagedActivator(t)
55 path := release.GenerationsPath(t.TempDir())
56 a.GenerationsFile = path
57 host := "bot.x.internal"
58 if err := a.WakeWorker(alwaysOnWorkerRoute(host)); err != nil {
59 t.Fatalf("WakeWorker: %v", err)
60 }
61 if got := readGenerations(t, path); !reflect.DeepEqual(got, map[string][]string{host: {"v1"}}) {
62 t.Fatalf("after the wake the file names %v; want %s: [v1]", got, host)
63 }
64
65 ver.Store("v2") // root installs v2 under the running child
66 b.lastWorker().kill()
67 a.sweepOnce()
68 waitForRestart(t, a, b.fakeBackend, host, 2)
69 if calls := b.calls(); calls[1] == nil || *calls[1] != fakeIdentity("v1") {
70 t.Fatalf("crash recycle asked for %v; want the captured v1", calls)
71 }
72 waitForGenerations(t, path, map[string][]string{host: {"v1"}})
73
74 clk.advance(ConvergeGrace + time.Minute)
75 a.sweepOnce()
76 waitFor(t, "the converge onto v2", func() bool { return a.entrySpawnVersion(host) == "v2" && !a.entryRespawning(host) })
77 waitForGenerations(t, path, map[string][]string{host: {"v2"}})
78
79 // The stop every path takes (a hibernation, a stand-down, a shutdown).
80 a.mu.Lock()
81 a.beginStopLocked(a.entries[host], StopIdle)
82 a.mu.Unlock()
83 waitFor(t, "the worker's stop", func() bool {
84 a.mu.Lock()
85 defer a.mu.Unlock()
86 return a.entries[host] == nil
87 })
88 waitForGenerations(t, path, map[string][]string{})
89}
90
91// A captured build with no version, or a version name the store could never
92// hold, is not listed by version, which would make the file unwritable; it
93// is listed by its staged path instead (ruling R19 item 2).
94func TestWorkerGenerationsListOnlyVersionStoreBuilds(t *testing.T) {
95 t.Parallel()
96 a, _, _, _, ver := stagedActivator(t)
97 path := release.GenerationsPath(t.TempDir())
98 a.GenerationsFile = path
99 ver.Store("")
100 if err := a.WakeWorker(alwaysOnWorkerRoute("empty.x.internal")); err != nil {
101 t.Fatalf("WakeWorker: %v", err)
102 }
103 ver.Store("not/a/version")
104 if err := a.WakeWorker(alwaysOnWorkerRoute("odd.x.internal")); err != nil {
105 t.Fatalf("WakeWorker: %v", err)
106 }
107 ver.Store("v3")
108 if err := a.WakeWorker(alwaysOnWorkerRoute("bot.x.internal")); err != nil {
109 t.Fatalf("WakeWorker: %v", err)
110 }
111 if got := readGenerations(t, path); !reflect.DeepEqual(got, map[string][]string{"bot.x.internal": {"v3"}}) {
112 t.Fatalf("file names %v; want only bot.x.internal: [v3]", got)
113 }
114}
115
116// realWorkerBackend is the fake's lease, restore and replication around a
117// REAL ExecBackend worker spawn: the child is a process exec'd by staged path,
118// and a recycle runs release.VerifyStaged on real bytes.
119type realWorkerBackend struct {
120 *fakeBackend
121 exec *ExecBackend
122}
123
124func (b *realWorkerBackend) StartWorkerStaged(ctx context.Context, rt registry.Route, env map[string]string, onto *release.StagedIdentity) (Proc, release.StagedIdentity, error) {
125 return b.exec.StartWorkerStaged(ctx, rt, env, onto)
126}
127
128// captureStdout runs fn with os.Stdout redirected, and returns what it
129// printed: the adoption pass reports its refusals there.
130func captureStdout(t *testing.T, fn func()) string {
131 t.Helper()
132 r, w, err := os.Pipe()
133 if err != nil {
134 t.Fatal(err)
135 }
136 orig := os.Stdout
137 os.Stdout = w
138 done := make(chan struct{})
139 var buf bytes.Buffer
140 go func() {
141 buf.ReadFrom(r)
142 close(done)
143 }()
144 func() {
145 defer func() { os.Stdout = orig }()
146 fn()
147 }()
148 w.Close()
149 <-done
150 return buf.String()
151}
152
153// Ruling R17, end to end (spec §4.2 "a staged copy that something refers to
154// never changes underneath it"). A worker is captured on build A (v1). Root
155// installs B and then C on its host, so the live link names C and the row
156// says Version C, PrevVersion B: only the generations file still names A.
157// A is then re-shipped under the same name with different bytes, and a pass
158// wants it for another host. The pass refuses with ErrVersionReused and A's
159// staged bytes do not change, so the worker's crash recycle onto A passes
160// VerifyStaged and it keeps running.
161func TestWorkerCapturedBuildSurvivesARestageOfItsReusedName(t *testing.T) {
162 ctx := context.Background()
163 eb := testExecBackend(t)
164 opt, dataDir := eb.OptDir, t.TempDir()
165 fb := &fakeBackend{}
166 reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
167 if err != nil {
168 t.Fatal(err)
169 }
170 a := New(Config{Bucket: "test-bucket", Idle: time.Hour}, &realWorkerBackend{fakeBackend: fb, exec: eb}, reg)
171 a.liveVer = eb.LiveVersion
172 a.GenerationsFile = release.GenerationsPath(dataDir)
173 t.Cleanup(func() { a.Shutdown(context.Background()) })
174
175 store := release.NewDirStore(t.TempDir())
176 seed, pubB64, err := release.GenerateKey()
177 if err != nil {
178 t.Fatal(err)
179 }
180 priv, err := release.ParseSigningKey(seed, "test")
181 if err != nil {
182 t.Fatal(err)
183 }
184 pub, err := release.ParsePublicKey(pubB64, "test")
185 if err != nil {
186 t.Fatal(err)
187 }
188 plat := runtime.GOOS + "-" + runtime.GOARCH
189 argv0 := filepath.Join(t.TempDir(), "argv0")
190 build := func(name string) []byte {
191 return []byte("#!/bin/sh\n# build " + name + "\nprintf '%s\\n' \"$0\" > '" + argv0 + ".tmp' && mv '" + argv0 + ".tmp' '" + argv0 + "'\nexec sleep 30\n")
192 }
193 ship := func(ver string, body []byte) {
194 t.Helper()
195 m := release.Manifest{App: "app", Version: ver, Kind: "binary", Artifacts: map[string]release.Artifact{plat: {}}}
196 if err := release.Ship(ctx, store, "bab", m, map[string][]byte{plat: body}); err != nil {
197 t.Fatalf("Ship %s: %v", ver, err)
198 }
199 }
200 promote := func(channel, ver string) {
201 t.Helper()
202 if err := release.Promote(ctx, store, priv, "bab", "app", channel, ver); err != nil {
203 t.Fatalf("Promote %s %s: %v", channel, ver, err)
204 }
205 }
206 pass := func() string {
207 t.Helper()
208 return captureStdout(t, func() {
209 if err := release.Pass(ctx, reg, store, pub, opt, func(string) error { return nil }, release.BakeDir(dataDir)); err != nil {
210 t.Fatalf("Pass: %v", err)
211 }
212 })
213 }
214
215 host := "bot.example.com"
216 rt := registry.Route{Host: host, Kind: "sidecar", App: "app", Channel: "canary/x", Args: `["sidecar","run"]`, Status: "ready"}
217 if err := reg.Upsert(rt); err != nil {
218 t.Fatal(err)
219 }
220 stagedA := release.VersionPath(opt, "bab", "v1", "app")
221
222 // A (v1) is installed and the worker is captured on it.
223 buildA := build("A")
224 ship("v1", buildA)
225 promote("canary/x", "v1")
226 pass()
227 if err := a.WakeWorker(rt); err != nil {
228 t.Fatalf("WakeWorker: %v", err)
229 }
230 // The child's own startup write, waited out so it cannot land after the
231 // removal below and pass for the recycle's.
232 if got := strings.TrimSpace(waitForFile(t, argv0, 5*time.Second)); got != stagedA {
233 t.Fatalf("the wake exec'd %q; want A's staged path %s", got, stagedA)
234 }
235 captured := a.entryServing(host)
236 if captured.Version != "v1" || captured.StagedPath != stagedA {
237 t.Fatalf("captured %+v; want v1 at %s", captured, stagedA)
238 }
239 if got := readGenerations(t, a.GenerationsFile); !reflect.DeepEqual(got, map[string][]string{host: {"v1"}}) {
240 t.Fatalf("generations file names %v; want %s: [v1]", got, host)
241 }
242
243 // B (v2), then C (v3): the link and the row move on.
244 ship("v2", build("B"))
245 promote("canary/x", "v2")
246 pass()
247 ship("v3", build("C"))
248 promote("canary/x", "v3")
249 pass()
250 row, err := reg.Get(host)
251 if err != nil || row == nil || row.Version != "v3" || row.PrevVersion != "v2" {
252 t.Fatalf("row = %+v, %v; want Version v3, PrevVersion v2", row, err)
253 }
254 if lv, _ := release.LiveVersion(opt, host); lv != "v3" {
255 t.Fatalf("live = %q, want v3", lv)
256 }
257 rows, err := reg.List()
258 if err != nil {
259 t.Fatal(err)
260 }
261 if pins, err := release.BoxPins(opt, rows); err != nil || pins.Has(stagedA) {
262 t.Fatalf("BoxPins = %v, %v; want A unpinned by links and rows, so only the generations file protects it", pins, err)
263 }
264
265 // A is pruned from the store and re-shipped under the same name with
266 // different bytes, and another host wants it.
267 for _, key := range []string{release.ManifestKey("bab", "app", "v1"), release.ArtifactKey("bab", "app", "v1", plat)} {
268 if err := store.Delete(ctx, key); err != nil {
269 t.Fatalf("prune %s: %v", key, err)
270 }
271 }
272 ship("v1", build("A, re-shipped"))
273 // Hibernating, so the sweep's always-on arm never starts it: only the
274 // pass acts on it here.
275 other := registry.Route{Host: "bot2.example.com", Kind: "sidecar", App: "app", Channel: "canary/y", Args: `["sidecar","run"]`, Status: "ready", Hibernate: true}
276 if err := reg.Upsert(other); err != nil {
277 t.Fatal(err)
278 }
279 promote("canary/y", "v1")
280 out := pass()
281 if !strings.Contains(out, release.ErrVersionReused.Error()) {
282 t.Fatalf("pass printed %q; want the refusal %q", out, release.ErrVersionReused)
283 }
284 if b, err := os.ReadFile(stagedA); err != nil || !bytes.Equal(b, buildA) {
285 t.Fatalf("A's staged copy = %q, %v; want A's bytes untouched", b, err)
286 }
287 if r2, _ := reg.Get(other.Host); r2 == nil || r2.Version == "v1" {
288 t.Fatalf("%s row = %+v; the reused name must not install", other.Host, r2)
289 }
290
291 // The worker crashes, and its recycle onto A verifies and runs.
292 os.Remove(argv0)
293 a.mu.Lock()
294 old := a.entries[host].proc
295 a.mu.Unlock()
296 if err := old.Stop(ctx); err != nil {
297 t.Fatalf("crash the worker: %v", err)
298 }
299 a.sweepOnce()
300 if got := strings.TrimSpace(waitForFile(t, argv0, 5*time.Second)); got != stagedA {
301 t.Fatalf("the recycle exec'd %q; want A's staged path %s", got, stagedA)
302 }
303 waitFor(t, "the recycle to land", func() bool {
304 a.mu.Lock()
305 defer a.mu.Unlock()
306 e := a.entries[host]
307 return e != nil && !e.workerRespawning && e.proc != old
308 })
309 a.mu.Lock()
310 e := a.entries[host]
311 serving, proc := e.workerServing, e.proc
312 a.mu.Unlock()
313 if serving != captured {
314 t.Fatalf("serving after the recycle = %+v; want the captured A %+v", serving, captured)
315 }
316 time.Sleep(100 * time.Millisecond)
317 if proc.Exited() {
318 t.Fatal("the worker recycled onto A is not running")
319 }
320}
321
322// readGenerationsFile is the whole generations file; it fails the test when
323// the file does not read.
324func readGenerationsFile(t *testing.T, path string) *release.Generations {
325 t.Helper()
326 g, err := release.ReadGenerations(path)
327 if err != nil {
328 t.Fatalf("ReadGenerations: %v", err)
329 }
330 if g == nil {
331 return &release.Generations{}
332 }
333 return g
334}
335
336func describeGenerations(gs []*release.Generations) string {
337 var b strings.Builder
338 for i, g := range gs {
339 fmt.Fprintf(&b, "[%d] hosts %v paths %v ", i, g.Hosts, g.Paths)
340 }
341 return b.String()
342}
343
344// Ruling R19 item 3 (Astra r3 F1(c)): the build a spawn is about to exec is
345// in the generations file BEFORE the exec, for a wake (from the live link)
346// and for a converge onto a newly installed build. Once the spawn has
347// installed, the captured build replaces the pending one.
348func TestWorkerSpawnPublishesItsBuildBeforeTheExec(t *testing.T) {
349 t.Parallel()
350 a, b, clk, _, ver := stagedActivator(t)
351 path := release.GenerationsPath(t.TempDir())
352 a.GenerationsFile = path
353 host := "bot.x.internal"
354 var atExec []*release.Generations
355 b.workerStartHook = func(registry.Route) {
356 g, err := release.ReadGenerations(path)
357 if err != nil || g == nil {
358 t.Errorf("at the exec the generations file = %+v, %v; want it written", g, err)
359 g = &release.Generations{}
360 }
361 atExec = append(atExec, g)
362 }
363 if err := a.WakeWorker(alwaysOnWorkerRoute(host)); err != nil {
364 t.Fatalf("WakeWorker: %v", err)
365 }
366 if len(atExec) != 1 || !reflect.DeepEqual(atExec[0].Hosts[host], []string{"v1"}) || !reflect.DeepEqual(atExec[0].Paths[host], []string{fakeIdentity("v1").StagedPath}) {
367 t.Fatalf("at the wake's exec the file named %s; want %s: v1 by version and path", describeGenerations(atExec), host)
368 }
369
370 ver.Store("v2")
371 a.sweepOnce() // the drift clock starts
372 clk.advance(ConvergeGrace + time.Minute)
373 a.sweepOnce()
374 waitFor(t, "the converge onto v2", func() bool { return a.entrySpawnVersion(host) == "v2" && !a.entryRespawning(host) })
375 if len(atExec) != 2 || !reflect.DeepEqual(atExec[1].Hosts[host], []string{"v1", "v2"}) {
376 t.Fatalf("at the converge's exec the file named %s; want %s: the serving v1 and the pending v2", describeGenerations(atExec), host)
377 }
378 waitForGenerations(t, path, map[string][]string{host: {"v2"}})
379 if g := readGenerationsFile(t, path); !reflect.DeepEqual(g.Paths, map[string][]string{host: {fakeIdentity("v2").StagedPath}}) {
380 t.Fatalf("after the converge the file's paths = %v; want only v2's", g.Paths)
381 }
382}
383
384// Ruling R19 items 3 and 4: when the build cannot be published first, the
385// spawn does not happen, for a wake and for a crash recycle. Once the file
386// can be written again, the sweep's retry publishes the set without the
387// refused build.
388func TestWorkerSpawnRefusedWhenItsBuildCannotBePublished(t *testing.T) {
389 t.Parallel()
390 a, b, _, _, _ := stagedActivator(t)
391 data := filepath.Join(t.TempDir(), "data")
392 if err := os.WriteFile(data, []byte("a file where the data dir should be"), 0o644); err != nil {
393 t.Fatal(err)
394 }
395 path := release.GenerationsPath(data)
396 a.GenerationsFile = path
397 if err := a.WakeWorker(alwaysOnWorkerRoute("first.x.internal")); err == nil || !errors.Is(err, ErrBuildNotPublished) {
398 t.Fatalf("WakeWorker with an unwritable generations file = %v; want ErrBuildNotPublished", err)
399 }
400 if n := b.workerStarts(); n != 0 {
401 t.Fatalf("workerStarts = %d; a build that could not be published must not be exec'd", n)
402 }
403
404 // The file becomes writable; a worker wakes. Then publication breaks
405 // again and the child crashes: the recycle is not spawned either.
406 if err := os.Remove(data); err != nil {
407 t.Fatal(err)
408 }
409 if err := os.MkdirAll(data, 0o755); err != nil {
410 t.Fatal(err)
411 }
412 host := "bot.x.internal"
413 if err := a.WakeWorker(alwaysOnWorkerRoute(host)); err != nil {
414 t.Fatalf("WakeWorker: %v", err)
415 }
416 if n := b.workerStarts(); n != 1 {
417 t.Fatalf("workerStarts = %d after the wake; want 1", n)
418 }
419 zdd := filepath.Dir(path)
420 if err := os.RemoveAll(zdd); err != nil {
421 t.Fatal(err)
422 }
423 if err := os.WriteFile(zdd, []byte("a file where the zdd dir should be"), 0o644); err != nil {
424 t.Fatal(err)
425 }
426 asked := len(b.calls())
427 b.lastWorker().kill()
428 a.sweepOnce()
429 waitFor(t, "the recycle attempt to finish", func() bool { return len(b.calls()) == asked+1 && !a.entryRespawning(host) })
430 if n := b.workerStarts(); n != 1 {
431 t.Fatalf("workerStarts = %d after the recycle; a build that could not be published must not be exec'd", n)
432 }
433
434 if err := os.Remove(zdd); err != nil {
435 t.Fatal(err)
436 }
437 a.sweepOnce()
438 waitForGenerations(t, path, map[string][]string{host: {"v1"}})
439}
440
441// Ruling R19 item 2 (Astra r3 F1(b)), with a real ExecBackend: a worker
442// exec'd through a chained live link (live/<host> -> operator-current ->
443// versions/v1/app) has Version "", and is published by its staged path,
444// which root's pin set then holds without any registry row.
445func TestWorkerFromAChainedLinkIsPublishedByPath(t *testing.T) {
446 t.Parallel()
447 eb := testExecBackend(t)
448 fb := &fakeBackend{}
449 reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
450 if err != nil {
451 t.Fatal(err)
452 }
453 a := New(Config{Bucket: "test-bucket", Idle: time.Hour}, &realWorkerBackend{fakeBackend: fb, exec: eb}, reg)
454 a.liveVer = eb.LiveVersion
455 a.GenerationsFile = release.GenerationsPath(t.TempDir())
456 t.Cleanup(func() { a.Shutdown(context.Background()) })
457
458 host := "bot.example.com"
459 argv0 := filepath.Join(t.TempDir(), "argv0")
460 staged := stageArgv0Worker(t, eb, host, "v1", argv0)
461 current := filepath.Join(t.TempDir(), "operator-current")
462 if err := os.Symlink(staged, current); err != nil {
463 t.Fatal(err)
464 }
465 if err := os.Remove(eb.liveLink(host)); err != nil {
466 t.Fatal(err)
467 }
468 if err := os.Symlink(current, eb.liveLink(host)); err != nil {
469 t.Fatal(err)
470 }
471 rt := registry.Route{Host: host, Kind: "sidecar", Args: `["sidecar","run"]`, Status: "ready"}
472 if err := a.WakeWorker(rt); err != nil {
473 t.Fatalf("WakeWorker: %v", err)
474 }
475 if got := strings.TrimSpace(waitForFile(t, argv0, 5*time.Second)); got != staged {
476 t.Fatalf("the wake exec'd %q; want %s", got, staged)
477 }
478 if id := a.entryServing(host); id.Version != "" || id.StagedPath != staged {
479 t.Fatalf("captured %+v; want no Version and the staged path %s", id, staged)
480 }
481 g := readGenerationsFile(t, a.GenerationsFile)
482 if len(g.Hosts) != 0 || !reflect.DeepEqual(g.Paths, map[string][]string{host: {staged}}) {
483 t.Fatalf("generations file = hosts %v, paths %v; want only %s by path", g.Hosts, g.Paths, staged)
484 }
485 pins, err := release.GenerationPins(a.GenerationsFile, func() ([]registry.Route, error) { return nil, nil }).StagedPins(context.Background(), eb.OptDir)
486 if err != nil || !pins.Has(staged) {
487 t.Fatalf("root's generation pins = %v, %v; want %s pinned with no row", pins, err, staged)
488 }
489}
490