| 1 | package activator |
| 2 | |
| 3 | import ( |
| 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. |
| 21 | func 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 | |
| 33 | func 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 | |
| 41 | func 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. |
| 52 | func 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). |
| 94 | func 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. |
| 119 | type realWorkerBackend struct { |
| 120 | *fakeBackend |
| 121 | exec *ExecBackend |
| 122 | } |
| 123 | |
| 124 | func (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. |
| 130 | func 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. |
| 161 | func 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. |
| 324 | func 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 | |
| 336 | func 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. |
| 348 | func 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. |
| 388 | func 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. |
| 445 | func 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 | |