| new file mode 100644 |
| index 000000000..131258718 |
| --- /dev/null |
| +++ b/internal/activator/worker_generations_test.go |
| @@ -0,0 +1,374 @@ |
| +package activator |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "os" |
| + "path/filepath" |
| + "reflect" |
| + "runtime" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// readGenerations is the generations file's hosts, or nil when it is absent. |
| +func readGenerations(t *testing.T, path string) map[string][]string { |
| + t.Helper() |
| + g, err := release.ReadGenerations(path) |
| + if err != nil { |
| + t.Fatalf("ReadGenerations: %v", err) |
| + } |
| + if g == nil { |
| + return nil |
| + } |
| + return g.Hosts |
| +} |
| + |
| +func waitForGenerations(t *testing.T, path string, want map[string][]string) { |
| + t.Helper() |
| + waitFor(t, "generations file naming "+stringOf(want), func() bool { |
| + g, err := release.ReadGenerations(path) |
| + return err == nil && g != nil && reflect.DeepEqual(g.Hosts, want) |
| + }) |
| +} |
| + |
| +func stringOf(m map[string][]string) string { |
| + var b strings.Builder |
| + for h, vs := range m { |
| + b.WriteString(h + "=" + strings.Join(vs, ",") + " ") |
| + } |
| + return b.String() |
| +} |
| + |
| +// Ruling R17: the file exists, even empty, before any worker runs. |
| +func TestWorkerGenerationsFileIsWrittenBeforeTheFirstSpawn(t *testing.T) { |
| + a, b, _, _, _ := stagedActivator(t) |
| + path := release.GenerationsPath(t.TempDir()) |
| + a.GenerationsFile = path |
| + seen := "spawn never ran" |
| + b.workerStartHook = func(registry.Route) { |
| + g, err := release.ReadGenerations(path) |
| + switch { |
| + case err != nil: |
| + seen = "unreadable: " + err.Error() |
| + case g == nil: |
| + seen = "absent" |
| + default: |
| + seen = "hosts=" + stringOf(g.Hosts) |
| + } |
| + } |
| + if err := a.WakeWorker(alwaysOnWorkerRoute("bot.x.internal")); err != nil { |
| + t.Fatalf("WakeWorker: %v", err) |
| + } |
| + if seen != "hosts=" { |
| + t.Fatalf("at the first spawn the generations file was %s; want it written, with no hosts", seen) |
| + } |
| +} |
| + |
| +// Ruling R17: the file names a worker's captured build after its wake, keeps |
| +// naming it across a crash recycle while live/<host> names another build, |
| +// follows a converge, and drops it once the worker stops. |
| +func TestWorkerGenerationsFileFollowsTheCapturedBuild(t *testing.T) { |
| + a, b, clk, _, ver := stagedActivator(t) |
| + path := release.GenerationsPath(t.TempDir()) |
| + a.GenerationsFile = path |
| + host := "bot.x.internal" |
| + if err := a.WakeWorker(alwaysOnWorkerRoute(host)); err != nil { |
| + t.Fatalf("WakeWorker: %v", err) |
| + } |
| + if got := readGenerations(t, path); !reflect.DeepEqual(got, map[string][]string{host: {"v1"}}) { |
| + t.Fatalf("after the wake the file names %v; want %s: [v1]", got, host) |
| + } |
| + |
| + ver.Store("v2") // root installs v2 under the running child |
| + b.lastWorker().kill() |
| + a.sweepOnce() |
| + waitForRestart(t, a, b.fakeBackend, host, 2) |
| + if calls := b.calls(); calls[1] == nil || *calls[1] != fakeIdentity("v1") { |
| + t.Fatalf("crash recycle asked for %v; want the captured v1", calls) |
| + } |
| + waitForGenerations(t, path, map[string][]string{host: {"v1"}}) |
| + |
| + clk.advance(ConvergeGrace + time.Minute) |
| + a.sweepOnce() |
| + waitFor(t, "the converge onto v2", func() bool { return a.entrySpawnVersion(host) == "v2" && !a.entryRespawning(host) }) |
| + waitForGenerations(t, path, map[string][]string{host: {"v2"}}) |
| + |
| + // The stop every path takes (a hibernation, a stand-down, a shutdown). |
| + a.mu.Lock() |
| + a.beginStopLocked(a.entries[host]) |
| + a.mu.Unlock() |
| + waitFor(t, "the worker's stop", func() bool { |
| + a.mu.Lock() |
| + defer a.mu.Unlock() |
| + return a.entries[host] == nil |
| + }) |
| + waitForGenerations(t, path, map[string][]string{}) |
| +} |
| + |
| +// A captured build with no version, or a version name the store could never |
| +// hold, came from a link outside the version store: nothing restages it, so |
| +// it is not listed, and it never makes the file unwritable. |
| +func TestWorkerGenerationsListOnlyVersionStoreBuilds(t *testing.T) { |
| + a, _, _, _, ver := stagedActivator(t) |
| + path := release.GenerationsPath(t.TempDir()) |
| + a.GenerationsFile = path |
| + ver.Store("") |
| + if err := a.WakeWorker(alwaysOnWorkerRoute("empty.x.internal")); err != nil { |
| + t.Fatalf("WakeWorker: %v", err) |
| + } |
| + ver.Store("not/a/version") |
| + if err := a.WakeWorker(alwaysOnWorkerRoute("odd.x.internal")); err != nil { |
| + t.Fatalf("WakeWorker: %v", err) |
| + } |
| + ver.Store("v3") |
| + if err := a.WakeWorker(alwaysOnWorkerRoute("bot.x.internal")); err != nil { |
| + t.Fatalf("WakeWorker: %v", err) |
| + } |
| + if got := readGenerations(t, path); !reflect.DeepEqual(got, map[string][]string{"bot.x.internal": {"v3"}}) { |
| + t.Fatalf("file names %v; want only bot.x.internal: [v3]", got) |
| + } |
| +} |
| + |
| +// Ruling R17: a write that fails never blocks a spawn, and the sweep retries |
| +// it. |
| +func TestWorkerGenerationsWriteFailureDoesNotBlockTheSpawnAndTheSweepRetries(t *testing.T) { |
| + a, b, _, _, _ := stagedActivator(t) |
| + data := filepath.Join(t.TempDir(), "data") |
| + if err := os.WriteFile(data, []byte("a file where the data dir should be"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + path := release.GenerationsPath(data) |
| + a.GenerationsFile = path |
| + host := "bot.x.internal" |
| + if err := a.WakeWorker(alwaysOnWorkerRoute(host)); err != nil { |
| + t.Fatalf("WakeWorker with an unwritable generations file: %v", err) |
| + } |
| + if b.workerStarts() != 1 || b.lastWorker().Exited() { |
| + t.Fatalf("workerStarts = %d; a failed publication must not block the spawn", b.workerStarts()) |
| + } |
| + if _, err := os.Stat(path); err == nil { |
| + t.Fatal("the generations file exists under a file; the test's failure did not happen") |
| + } |
| + a.sweepOnce() // still failing: retried and logged, nothing else |
| + if err := os.Remove(data); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.MkdirAll(data, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + a.sweepOnce() |
| + if got := readGenerations(t, path); !reflect.DeepEqual(got, map[string][]string{host: {"v1"}}) { |
| + t.Fatalf("after the sweep's retry the file names %v; want %s: [v1]", got, host) |
| + } |
| +} |
| + |
| +// realWorkerBackend is the fake's lease, restore and replication around a |
| +// REAL ExecBackend worker spawn: the child is a process exec'd by staged path, |
| +// and a recycle runs release.VerifyStaged on real bytes. |
| +type realWorkerBackend struct { |
| + *fakeBackend |
| + exec *ExecBackend |
| +} |
| + |
| +func (b *realWorkerBackend) StartWorkerStaged(ctx context.Context, rt registry.Route, env map[string]string, onto *release.StagedIdentity) (Proc, release.StagedIdentity, error) { |
| + return b.exec.StartWorkerStaged(ctx, rt, env, onto) |
| +} |
| + |
| +// captureStdout runs fn with os.Stdout redirected, and returns what it |
| +// printed: the adoption pass reports its refusals there. |
| +func captureStdout(t *testing.T, fn func()) string { |
| + t.Helper() |
| + r, w, err := os.Pipe() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + orig := os.Stdout |
| + os.Stdout = w |
| + done := make(chan struct{}) |
| + var buf bytes.Buffer |
| + go func() { |
| + buf.ReadFrom(r) |
| + close(done) |
| + }() |
| + func() { |
| + defer func() { os.Stdout = orig }() |
| + fn() |
| + }() |
| + w.Close() |
| + <-done |
| + return buf.String() |
| +} |
| + |
| +// Ruling R17, end to end (spec §4.2 "a staged copy that something refers to |
| +// never changes underneath it"). A worker is captured on build A (v1). Root |
| +// installs B and then C on its host, so the live link names C and the row |
| +// says Version C, PrevVersion B: only the generations file still names A. |
| +// A is then re-shipped under the same name with different bytes, and a pass |
| +// wants it for another host. The pass refuses with ErrVersionReused and A's |
| +// staged bytes do not change, so the worker's crash recycle onto A passes |
| +// VerifyStaged and it keeps running. |
| +func TestWorkerCapturedBuildSurvivesARestageOfItsReusedName(t *testing.T) { |
| + ctx := context.Background() |
| + eb := testExecBackend(t) |
| + opt, dataDir := eb.OptDir, t.TempDir() |
| + fb := &fakeBackend{} |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + a := New(Config{Bucket: "test-bucket", Idle: time.Hour}, &realWorkerBackend{fakeBackend: fb, exec: eb}, reg) |
| + a.liveVer = eb.LiveVersion |
| + a.GenerationsFile = release.GenerationsPath(dataDir) |
| + t.Cleanup(func() { a.Shutdown(context.Background()) }) |
| + |
| + store := release.NewDirStore(t.TempDir()) |
| + seed, pubB64, err := release.GenerateKey() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + priv, err := release.ParseSigningKey(seed, "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + pub, err := release.ParsePublicKey(pubB64, "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + plat := runtime.GOOS + "-" + runtime.GOARCH |
| + argv0 := filepath.Join(t.TempDir(), "argv0") |
| + build := func(name string) []byte { |
| + return []byte("#!/bin/sh\n# build " + name + "\nprintf '%s\\n' \"$0\" > '" + argv0 + ".tmp' && mv '" + argv0 + ".tmp' '" + argv0 + "'\nexec sleep 30\n") |
| + } |
| + ship := func(ver string, body []byte) { |
| + t.Helper() |
| + m := release.Manifest{App: "app", Version: ver, Kind: "binary", Artifacts: map[string]release.Artifact{plat: {}}} |
| + if err := release.Ship(ctx, store, "bab", m, map[string][]byte{plat: body}); err != nil { |
| + t.Fatalf("Ship %s: %v", ver, err) |
| + } |
| + } |
| + promote := func(channel, ver string) { |
| + t.Helper() |
| + if err := release.Promote(ctx, store, priv, "bab", "app", channel, ver); err != nil { |
| + t.Fatalf("Promote %s %s: %v", channel, ver, err) |
| + } |
| + } |
| + pass := func() string { |
| + t.Helper() |
| + return captureStdout(t, func() { |
| + if err := release.Pass(ctx, reg, store, pub, opt, func(string) error { return nil }, release.BakeDir(dataDir)); err != nil { |
| + t.Fatalf("Pass: %v", err) |
| + } |
| + }) |
| + } |
| + |
| + host := "bot.example.com" |
| + rt := registry.Route{Host: host, Kind: "sidecar", App: "app", Channel: "canary/x", Args: `["sidecar","run"]`, Status: "ready"} |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + stagedA := release.VersionPath(opt, "bab", "v1", "app") |
| + |
| + // A (v1) is installed and the worker is captured on it. |
| + buildA := build("A") |
| + ship("v1", buildA) |
| + promote("canary/x", "v1") |
| + pass() |
| + if err := a.WakeWorker(rt); err != nil { |
| + t.Fatalf("WakeWorker: %v", err) |
| + } |
| + // The child's own startup write, waited out so it cannot land after the |
| + // removal below and pass for the recycle's. |
| + if got := strings.TrimSpace(waitForFile(t, argv0, 5*time.Second)); got != stagedA { |
| + t.Fatalf("the wake exec'd %q; want A's staged path %s", got, stagedA) |
| + } |
| + captured := a.entryServing(host) |
| + if captured.Version != "v1" || captured.StagedPath != stagedA { |
| + t.Fatalf("captured %+v; want v1 at %s", captured, stagedA) |
| + } |
| + if got := readGenerations(t, a.GenerationsFile); !reflect.DeepEqual(got, map[string][]string{host: {"v1"}}) { |
| + t.Fatalf("generations file names %v; want %s: [v1]", got, host) |
| + } |
| + |
| + // B (v2), then C (v3): the link and the row move on. |
| + ship("v2", build("B")) |
| + promote("canary/x", "v2") |
| + pass() |
| + ship("v3", build("C")) |
| + promote("canary/x", "v3") |
| + pass() |
| + row, err := reg.Get(host) |
| + if err != nil || row == nil || row.Version != "v3" || row.PrevVersion != "v2" { |
| + t.Fatalf("row = %+v, %v; want Version v3, PrevVersion v2", row, err) |
| + } |
| + if lv, _ := release.LiveVersion(opt, host); lv != "v3" { |
| + t.Fatalf("live = %q, want v3", lv) |
| + } |
| + rows, err := reg.List() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if pins, err := release.BoxPins(opt, rows); err != nil || pins.Has(stagedA) { |
| + t.Fatalf("BoxPins = %v, %v; want A unpinned by links and rows, so only the generations file protects it", pins, err) |
| + } |
| + |
| + // A is pruned from the store and re-shipped under the same name with |
| + // different bytes, and another host wants it. |
| + for _, key := range []string{release.ManifestKey("bab", "app", "v1"), release.ArtifactKey("bab", "app", "v1", plat)} { |
| + if err := store.Delete(ctx, key); err != nil { |
| + t.Fatalf("prune %s: %v", key, err) |
| + } |
| + } |
| + ship("v1", build("A, re-shipped")) |
| + // Hibernating, so the sweep's always-on arm never starts it: only the |
| + // pass acts on it here. |
| + other := registry.Route{Host: "bot2.example.com", Kind: "sidecar", App: "app", Channel: "canary/y", Args: `["sidecar","run"]`, Status: "ready", Hibernate: true} |
| + if err := reg.Upsert(other); err != nil { |
| + t.Fatal(err) |
| + } |
| + promote("canary/y", "v1") |
| + out := pass() |
| + if !strings.Contains(out, release.ErrVersionReused.Error()) { |
| + t.Fatalf("pass printed %q; want the refusal %q", out, release.ErrVersionReused) |
| + } |
| + if b, err := os.ReadFile(stagedA); err != nil || !bytes.Equal(b, buildA) { |
| + t.Fatalf("A's staged copy = %q, %v; want A's bytes untouched", b, err) |
| + } |
| + if r2, _ := reg.Get(other.Host); r2 == nil || r2.Version == "v1" { |
| + t.Fatalf("%s row = %+v; the reused name must not install", other.Host, r2) |
| + } |
| + |
| + // The worker crashes, and its recycle onto A verifies and runs. |
| + os.Remove(argv0) |
| + a.mu.Lock() |
| + old := a.entries[host].proc |
| + a.mu.Unlock() |
| + if err := old.Stop(ctx); err != nil { |
| + t.Fatalf("crash the worker: %v", err) |
| + } |
| + a.sweepOnce() |
| + if got := strings.TrimSpace(waitForFile(t, argv0, 5*time.Second)); got != stagedA { |
| + t.Fatalf("the recycle exec'd %q; want A's staged path %s", got, stagedA) |
| + } |
| + waitFor(t, "the recycle to land", func() bool { |
| + a.mu.Lock() |
| + defer a.mu.Unlock() |
| + e := a.entries[host] |
| + return e != nil && !e.workerRespawning && e.proc != old |
| + }) |
| + a.mu.Lock() |
| + e := a.entries[host] |
| + serving, proc := e.workerServing, e.proc |
| + a.mu.Unlock() |
| + if serving != captured { |
| + t.Fatalf("serving after the recycle = %+v; want the captured A %+v", serving, captured) |
| + } |
| + time.Sleep(100 * time.Millisecond) |
| + if proc.Exited() { |
| + t.Fatal("the worker recycled onto A is not running") |
| + } |
| +} |