| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/hex" |
| 7 | "encoding/json" |
| 8 | "errors" |
| 9 | "log" |
| 10 | "os" |
| 11 | "path/filepath" |
| 12 | "reflect" |
| 13 | "slices" |
| 14 | "strings" |
| 15 | "sync" |
| 16 | "testing" |
| 17 | "time" |
| 18 | |
| 19 | "github.com/carlosframework/platform/internal/activator" |
| 20 | "github.com/carlosframework/platform/internal/appconfig" |
| 21 | "github.com/carlosframework/platform/internal/cgroup" |
| 22 | "github.com/carlosframework/platform/internal/hostwrite" |
| 23 | "github.com/carlosframework/platform/internal/instancectl" |
| 24 | "github.com/carlosframework/platform/internal/registry" |
| 25 | "github.com/carlosframework/platform/internal/release" |
| 26 | "github.com/carlosframework/platform/internal/rollout" |
| 27 | ) |
| 28 | |
| 29 | // rolloutFixture is an activator over a fresh registry, built with the |
| 30 | // generations switch as gens says (runAgent sets it from swapArming before |
| 31 | // New). The package's rollout state is reset when the test ends. |
| 32 | func rolloutFixture(t *testing.T, gens bool) (*activator.Service, *registry.Registry, string) { |
| 33 | t.Helper() |
| 34 | return rolloutFixtureWith(t, gens, &activator.SplitBackend{}) |
| 35 | } |
| 36 | |
| 37 | // rolloutFixtureWith is rolloutFixture over backend. |
| 38 | func rolloutFixtureWith(t *testing.T, gens bool, backend activator.Backend) (*activator.Service, *registry.Registry, string) { |
| 39 | t.Helper() |
| 40 | prev := activator.GenerationsEnabled() |
| 41 | activator.SetGenerations(gens) |
| 42 | t.Cleanup(func() { activator.SetGenerations(prev) }) |
| 43 | resetRollout(t) |
| 44 | dd := t.TempDir() |
| 45 | reg, err := registry.Open(filepath.Join(dd, "registry.db")) |
| 46 | if err != nil { |
| 47 | t.Fatal(err) |
| 48 | } |
| 49 | t.Cleanup(func() { reg.Close() }) |
| 50 | svc := activator.New(activator.Config{Bucket: "b", Idle: time.Hour}, backend, reg) |
| 51 | return svc, reg, dd |
| 52 | } |
| 53 | |
| 54 | func resetRollout(t *testing.T) { |
| 55 | t.Helper() |
| 56 | clear := func() { |
| 57 | rolloutMgr.Store(nil) |
| 58 | rolloutDown.Store(nil) |
| 59 | rolloutDeclined.Store(nil) |
| 60 | declinedHolds.Store(nil) |
| 61 | select { |
| 62 | case <-statusNudge: |
| 63 | default: |
| 64 | } |
| 65 | } |
| 66 | clear() |
| 67 | t.Cleanup(clear) |
| 68 | } |
| 69 | |
| 70 | func overlapEnv(pub string) func(string) string { |
| 71 | env := map[string]string{"CARLOS_SWAP": "overlap", "CARLOS_RELEASE_PUBKEY": pub} |
| 72 | return func(k string) string { return env[k] } |
| 73 | } |
| 74 | |
| 75 | func noEnv(string) string { return "" } |
| 76 | |
| 77 | // stubPlacementCheck replaces swapArming's arm-time placement check with |
| 78 | // one that answers err, and counts its calls. A temp-dir cgroup root can |
| 79 | // never pass the real check (cgroup.Manager.VerifyPlacement), so every test |
| 80 | // that expects swapArming to arm uses this with a nil err. |
| 81 | func stubPlacementCheck(t *testing.T, err error) *int { |
| 82 | t.Helper() |
| 83 | orig := placementCheck |
| 84 | calls := new(int) |
| 85 | placementCheck = func(*cgroup.Manager) error { *calls++; return err } |
| 86 | t.Cleanup(func() { placementCheck = orig }) |
| 87 | return calls |
| 88 | } |
| 89 | |
| 90 | // forbidPlacementCheck fails the test if swapArming runs the placement |
| 91 | // check: with the flag off, or after an earlier check declined, it must not. |
| 92 | func forbidPlacementCheck(t *testing.T) { |
| 93 | t.Helper() |
| 94 | orig := placementCheck |
| 95 | placementCheck = func(*cgroup.Manager) error { |
| 96 | t.Error("swapArming ran the placement check") |
| 97 | return nil |
| 98 | } |
| 99 | t.Cleanup(func() { placementCheck = orig }) |
| 100 | } |
| 101 | |
| 102 | // armableBackend is an exec backend that passes every check swapArming makes |
| 103 | // before the placement check: claims off, a delegated subtree (over a temp |
| 104 | // root), and a control channel that speaks the generation verbs. |
| 105 | func armableBackend(t *testing.T) *activator.ExecBackend { |
| 106 | t.Helper() |
| 107 | root := t.TempDir() |
| 108 | for _, f := range []string{"cgroup.procs", "cgroup.subtree_control"} { |
| 109 | if err := os.WriteFile(filepath.Join(root, f), nil, 0o644); err != nil { |
| 110 | t.Fatal(err) |
| 111 | } |
| 112 | } |
| 113 | cgm, err := cgroup.SetupAt(root, t.Logf) |
| 114 | if err != nil { |
| 115 | t.Fatal(err) |
| 116 | } |
| 117 | return &activator.ExecBackend{Cgroups: cgm, Control: instancectl.NewClient(filepath.Join(t.TempDir(), "ctl.sock"))} |
| 118 | } |
| 119 | |
| 120 | func unusedStore(context.Context) (release.Store, error) { return nil, errors.New("unused") } |
| 121 | |
| 122 | func funcPtr(f any) uintptr { return reflect.ValueOf(f).Pointer() } |
| 123 | |
| 124 | // legsAreTodays asserts every leg the scheduler could take over is today's: |
| 125 | // the converge leg, the adopt bounce, config sync's callbacks and the |
| 126 | // restart leg's bounce. |
| 127 | func legsAreTodays(t *testing.T, svc *activator.Service, reg *registry.Registry) { |
| 128 | t.Helper() |
| 129 | if fastConvergeLeg(svc, reg) == nil { |
| 130 | t.Error("no converge leg: convergeAdopted must run") |
| 131 | } |
| 132 | if b := adoptBounceFn(svc); b == nil || funcPtr(b) != funcPtr(svc.BounceAdopted) { |
| 133 | t.Error("the adopt bounce is not BounceAdopted") |
| 134 | } |
| 135 | if _, ok := rolloutConfigCallbacks(context.Background(), release.NewDirStore(t.TempDir()), configLegs{}); ok { |
| 136 | t.Error("config sync left today's SyncExec") |
| 137 | } |
| 138 | ctx, s := genKit(t) |
| 139 | routes := []registry.Route{execRoute("exec.example.com", "bab", "eleven")} |
| 140 | var bounced []string |
| 141 | bounce := func(h string) bool { bounced = append(bounced, h); return true } |
| 142 | mem := newRestartMemory() |
| 143 | askRestart(t, ctx, s, "bab", "eleven") |
| 144 | restartRequestLeg(ctx, routes, s, t.TempDir(), bounce, nil, mem) |
| 145 | askRestart(t, ctx, s, "bab", "eleven") |
| 146 | restartRequestLeg(ctx, routes, s, t.TempDir(), bounce, nil, mem) |
| 147 | if !slices.Equal(bounced, []string{"exec.example.com"}) { |
| 148 | t.Errorf("restart bounced %v, want today's bounce of the exec instance", bounced) |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | // Cross-plan review focus 1: the flag off changes nothing. |
| 153 | func TestTheFlagOffArmsNothing(t *testing.T) { |
| 154 | svc, reg, dd := rolloutFixture(t, false) |
| 155 | opened := false |
| 156 | m := armRollout(context.Background(), noEnv, svc, reg, dd, nil, |
| 157 | func(context.Context) (release.Store, error) { opened = true; return nil, errors.New("unused") }) |
| 158 | if m != nil || armedRollout() != nil || opened { |
| 159 | t.Fatal("CARLOS_SWAP unset armed the scheduler") |
| 160 | } |
| 161 | if svc.Planner != nil || svc.OnGen != nil || svc.OnDrift != nil || svc.CoalesceRecycle != nil || svc.GenerationsFile != "" { |
| 162 | t.Fatal("CARLOS_SWAP unset set an activator hook") |
| 163 | } |
| 164 | if adoptBounceFn(svc) == nil { |
| 165 | t.Fatal("the adopt bounce must stay BounceAdopted") |
| 166 | } |
| 167 | forbidPlacementCheck(t) |
| 168 | if swapArming(noEnv, nil) || activator.GenerationsEnabled() { |
| 169 | t.Fatal("CARLOS_SWAP unset turned generations on") |
| 170 | } |
| 171 | if r := rolloutStatusProbe(); r != nil { |
| 172 | t.Fatalf("status with the flag off = %+v, want nil", r) |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | // Carried obligation 5: with the flag off every leg is today's, and the only |
| 177 | // hook set is armAlways's OnWorkerServing. |
| 178 | func TestTheFlagOffLegsAreTodays(t *testing.T) { |
| 179 | svc, reg, dd := rolloutFixture(t, false) |
| 180 | armAlways(svc, reg, dd) |
| 181 | armRollout(context.Background(), noEnv, svc, reg, dd, nil, unusedStore) |
| 182 | legsAreTodays(t, svc, reg) |
| 183 | if len(statusNudge) != 0 { |
| 184 | t.Fatal("the status loop was nudged with the flag off") |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | // PF14 (plan 4 R17): runAgent sets the generations file whatever the flag, |
| 189 | // so a worker's captured serving build is pinned with the flag off too. |
| 190 | // armAlways publishes the worker's serving version and touches nothing |
| 191 | // else; armRollout never sets the file either. |
| 192 | func TestTheFlagOffStillPublishesWorkerArtifacts(t *testing.T) { |
| 193 | svc, reg, dd := rolloutFixture(t, false) |
| 194 | armAlways(svc, reg, dd) |
| 195 | armRollout(context.Background(), noEnv, svc, reg, dd, nil, unusedStore) |
| 196 | if svc.OnWorkerServing == nil { |
| 197 | t.Fatal("no worker serving publication with the flag off") |
| 198 | } |
| 199 | if svc.GenerationsFile != "" { |
| 200 | t.Fatalf("generations file = %q: armAlways and armRollout must leave it to runAgent", svc.GenerationsFile) |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | func TestTheFlagOnArmsTheScheduler(t *testing.T) { |
| 205 | svc, reg, dd := rolloutFixture(t, true) |
| 206 | svc.GenerationsFile = release.GenerationsPath(dd) // as runAgent sets it, before armRollout |
| 207 | _, pub, err := release.GenerateKey() |
| 208 | if err != nil { |
| 209 | t.Fatal(err) |
| 210 | } |
| 211 | if err := reg.Upsert(registry.Route{Host: "a.example.com", Kind: "instance", App: "blog", Account: "bab", Status: "ready", |
| 212 | Hibernate: true, Version: "v2"}); err != nil { |
| 213 | t.Fatal(err) |
| 214 | } |
| 215 | store := release.NewDirStore(t.TempDir()) |
| 216 | ctx, cancel := context.WithCancel(context.Background()) // ends the schedulers' goroutines |
| 217 | t.Cleanup(cancel) |
| 218 | m := armRollout(ctx, overlapEnv(pub), svc, reg, dd, nil, |
| 219 | func(context.Context) (release.Store, error) { return store, nil }) |
| 220 | if m == nil || armedRollout() != m { |
| 221 | t.Fatal("CARLOS_SWAP=overlap did not arm the scheduler") |
| 222 | } |
| 223 | if svc.Planner == nil || svc.OnGen == nil || svc.OnDrift == nil || svc.CoalesceRecycle == nil { |
| 224 | t.Fatal("a hook is missing") |
| 225 | } |
| 226 | if svc.GenerationsFile != release.GenerationsPath(dd) { |
| 227 | t.Fatalf("generations file = %q", svc.GenerationsFile) |
| 228 | } |
| 229 | // The legs are the scheduler's. |
| 230 | if b := adoptBounceFn(svc); b == nil || funcPtr(b) != funcPtr(m.Adopted) { |
| 231 | t.Fatal("the adopt bounce is not the scheduler's Adopted") |
| 232 | } |
| 233 | if fastConvergeLeg(svc, reg) == nil { |
| 234 | t.Fatal("no converge leg: the scheduler's pass must run") |
| 235 | } |
| 236 | cb, ok := rolloutConfigCallbacks(context.Background(), store, configLegs{}) |
| 237 | if !ok || cb.Delivered == nil || cb.Ack == nil || cb.Restart == nil || cb.Restart("a.example.com") != nil { |
| 238 | t.Fatalf("config callbacks = %+v, %v: want the scheduler's delivery and deferred ack, and no restart", cb, ok) |
| 239 | } |
| 240 | // The pass at arming saw the managed route, and the status probe is |
| 241 | // the scheduler's report. |
| 242 | r := rolloutStatusProbe() |
| 243 | if r == nil || r.Unavailable != "" { |
| 244 | t.Fatalf("status = %+v, want the scheduler's report", r) |
| 245 | } |
| 246 | if got := r.Hosts["a.example.com"]; got.Installed != "v2" { |
| 247 | t.Fatalf("status host = %+v, want the managed route installed on v2", got) |
| 248 | } |
| 249 | } |
| 250 | |
| 251 | // Spec §6.4, §8.3, §8.4, P2 #1: with the flag on, generations on and the |
| 252 | // scheduler's state unavailable, managed starts are refused, the reason |
| 253 | // code reported and the bounce legs stand down. Falling back to today's |
| 254 | // live-link wakes and legacy convergence (a nil Planner) would exec the |
| 255 | // failed installed build the unreadable records hold down. |
| 256 | func TestUnavailableStateRefusesManagedStarts(t *testing.T) { |
| 257 | _, pub, err := release.GenerateKey() |
| 258 | if err != nil { |
| 259 | t.Fatal(err) |
| 260 | } |
| 261 | dirStore := func(context.Context) (release.Store, error) { return release.NewDirStore(t.TempDir()), nil } |
| 262 | for name, tc := range map[string]struct { |
| 263 | env func(string) string |
| 264 | store func(context.Context) (release.Store, error) |
| 265 | book bool // an unreadable failure book in the data dir |
| 266 | code string |
| 267 | }{ |
| 268 | "no deploy store": {overlapEnv(pub), |
| 269 | func(context.Context) (release.Store, error) { return nil, errors.New("no deployment store") }, false, rolloutNoStore}, |
| 270 | "no public key": {overlapEnv(""), dirStore, false, rolloutNoReleaseKey}, |
| 271 | "unreadable book": {overlapEnv(pub), dirStore, true, rolloutBookUnreadable}, |
| 272 | "book with a bad schema": {overlapEnv(pub), dirStore, true, rolloutBookUnreadable}, |
| 273 | } { |
| 274 | t.Run(name, func(t *testing.T) { |
| 275 | svc, reg, dd := rolloutFixture(t, true) |
| 276 | if tc.book { |
| 277 | body := "{not json" |
| 278 | if name == "book with a bad schema" { |
| 279 | body = `{"schema": 9}` |
| 280 | } |
| 281 | if err := os.WriteFile(filepath.Join(dd, rollout.FailureFile), []byte(body), 0o644); err != nil { |
| 282 | t.Fatal(err) |
| 283 | } |
| 284 | } |
| 285 | m := armRollout(context.Background(), tc.env, svc, reg, dd, nil, tc.store) |
| 286 | if m != nil || armedRollout() != nil { |
| 287 | t.Fatal("armed without its required state") |
| 288 | } |
| 289 | if svc.Planner == nil { |
| 290 | t.Fatal("no Planner: the box fell back to today's live-link wakes") |
| 291 | } |
| 292 | managed := registry.Route{Host: "a.example.com", Kind: "instance", App: "blog", Account: "bab", Status: "ready", Hibernate: true, Version: "v2"} |
| 293 | p := svc.Planner(context.Background(), managed) |
| 294 | if p.Refuse == "" { |
| 295 | t.Fatalf("managed wake planned %+v, want refused", p) |
| 296 | } |
| 297 | if p.Refuse != refuseTexts[tc.code] { |
| 298 | t.Fatalf("refusal %q, want the fixed text for %s (never the raw error)", p.Refuse, tc.code) |
| 299 | } |
| 300 | unit := registry.Route{Host: "u.example.com", Kind: "instance", App: "blog", Account: "bab", Status: "ready", Unit: "blog.service"} |
| 301 | if p := svc.Planner(context.Background(), unit); p.Refuse != "" || p.Boot.StagedPath != "" { |
| 302 | t.Fatalf("an unmanaged wake was changed: %+v", p) |
| 303 | } |
| 304 | if r := rolloutStatusProbe(); r == nil || r.Unavailable != tc.code { |
| 305 | t.Fatalf("status = %+v, want the reason code %q", r, tc.code) |
| 306 | } |
| 307 | // The bounce legs stand down. |
| 308 | if adoptBounceFn(svc) != nil { |
| 309 | t.Fatal("the adopt bounce still stops instances whose next wake is refused") |
| 310 | } |
| 311 | if fastConvergeLeg(svc, reg) != nil { |
| 312 | t.Fatal("legacy convergence still stops instances whose next wake is refused") |
| 313 | } |
| 314 | if svc.OnDrift == nil { |
| 315 | t.Fatal("the sweep's convergence stop is still armed") |
| 316 | } |
| 317 | if svc.CoalesceRecycle == nil || svc.CoalesceRecycle("bot.internal") { |
| 318 | t.Fatal("a worker recycle can still converge onto the live link") |
| 319 | } |
| 320 | if svc.OnGen != nil { |
| 321 | t.Fatal("OnGen set with no scheduler to hear it") |
| 322 | } |
| 323 | store := release.NewDirStore(t.TempDir()) |
| 324 | cb, ok := rolloutConfigCallbacks(context.Background(), store, configLegs{}) |
| 325 | // Nothing is awake here: the restart is none, and says the file |
| 326 | // is read on the next wake (final review F7 (3)). |
| 327 | if !ok || cb.Delivered != nil || cb.Restart == nil || !errors.Is(cb.Restart("a.example.com"), appconfig.ErrDeliveredOnWake) || cb.Ack == nil { |
| 328 | t.Fatalf("config callbacks = %+v, %v: want delivery and the ack as today, and no restart", cb, ok) |
| 329 | } |
| 330 | ctx, s := genKit(t) |
| 331 | routes := []registry.Route{execRoute("exec.example.com", "bab", "eleven")} |
| 332 | var bounced []string |
| 333 | bounce := func(h string) bool { bounced = append(bounced, h); return true } |
| 334 | mem := newRestartMemory() |
| 335 | askRestart(t, ctx, s, "bab", "eleven") |
| 336 | restartRequestLeg(ctx, routes, s, t.TempDir(), bounce, nil, mem) |
| 337 | askRestart(t, ctx, s, "bab", "eleven") |
| 338 | if !restartRequestLeg(ctx, routes, s, t.TempDir(), bounce, nil, mem) { |
| 339 | t.Fatal("the root half of the restart was not asked for") |
| 340 | } |
| 341 | if len(bounced) != 0 { |
| 342 | t.Fatalf("restart bounced %v while managed starts are refused", bounced) |
| 343 | } |
| 344 | }) |
| 345 | } |
| 346 | } |
| 347 | |
| 348 | // leaveIntent leaves in's intent for host in dd's failure book, as an agent |
| 349 | // that died mid-execution would have. |
| 350 | func leaveIntent(t *testing.T, dd, host string, in rollout.Intent) { |
| 351 | t.Helper() |
| 352 | b, err := rollout.OpenFailureBook(dd) |
| 353 | if err != nil { |
| 354 | t.Fatal(err) |
| 355 | } |
| 356 | if err := b.Begin(host, in); err != nil { |
| 357 | t.Fatal(err) |
| 358 | } |
| 359 | } |
| 360 | |
| 361 | // Task 5 rule 5: an armed box whose book holds an intent an earlier agent |
| 362 | // left turns it into the failure it may have been, and saves that before |
| 363 | // the scheduler runs: a reopen finds the record and no intent. |
| 364 | func TestAnArmedBoxReconcilesAnIntentLeftInItsBook(t *testing.T) { |
| 365 | svc, reg, dd := rolloutFixture(t, true) |
| 366 | svc.GenerationsFile = release.GenerationsPath(dd) |
| 367 | _, pub, err := release.GenerateKey() |
| 368 | if err != nil { |
| 369 | t.Fatal(err) |
| 370 | } |
| 371 | if err := reg.Upsert(registry.Route{Host: "a.example.com", Kind: "instance", App: "blog", Account: "bab", Status: "ready", |
| 372 | Hibernate: true, Version: "v2", GoodVersion: "v1", GoodDigest: "sha256:v1"}); err != nil { |
| 373 | t.Fatal(err) |
| 374 | } |
| 375 | leaveIntent(t, dd, "a.example.com", rollout.Intent{Account: "bab", App: "blog", Build: "v2", Mode: "hold", ID: "a3"}) |
| 376 | store := release.NewDirStore(t.TempDir()) |
| 377 | ctx, cancel := context.WithCancel(context.Background()) |
| 378 | t.Cleanup(cancel) |
| 379 | m := armRollout(ctx, overlapEnv(pub), svc, reg, dd, nil, func(context.Context) (release.Store, error) { return store, nil }) |
| 380 | if m == nil || rolloutUnavailable() != "" { |
| 381 | t.Fatalf("not armed (%q)", rolloutUnavailable()) |
| 382 | } |
| 383 | b, err := rollout.OpenFailureBook(dd) |
| 384 | if err != nil { |
| 385 | t.Fatal(err) |
| 386 | } |
| 387 | if b.Reconciled() != 0 { |
| 388 | t.Fatal("the reconciled book was not saved: the intent is still on disk") |
| 389 | } |
| 390 | if !b.HeldDown("bab", "blog", "v2", "a.example.com") { |
| 391 | t.Fatal("the hold intent's failure was not kept") |
| 392 | } |
| 393 | if p := svc.Planner(ctx, *mustGet(t, reg, "a.example.com")); p.Refuse != rollout.HeldDownRefusal("v2") { |
| 394 | t.Fatalf("plan %+v: want the held-down refusal", p) |
| 395 | } |
| 396 | } |
| 397 | |
| 398 | // Task 5 rule 5: a reconciled book the box cannot save refuses every |
| 399 | // managed start, as an unreadable one does: the scheduler must not run while |
| 400 | // a restart would find the intents again with no way to keep their records. |
| 401 | func TestAnArmedBoxThatCannotSaveItsReconciledBookRefusesManagedStarts(t *testing.T) { |
| 402 | if os.Geteuid() == 0 { |
| 403 | t.Skip("running as root: a read-only directory does not fail a save") |
| 404 | } |
| 405 | svc, reg, dd := rolloutFixture(t, true) |
| 406 | _, pub, err := release.GenerateKey() |
| 407 | if err != nil { |
| 408 | t.Fatal(err) |
| 409 | } |
| 410 | leaveIntent(t, dd, "a.example.com", rollout.Intent{Account: "bab", App: "blog", Build: "v2", Mode: "overlap", ID: "w4"}) |
| 411 | if err := os.Chmod(dd, 0o500); err != nil { |
| 412 | t.Fatal(err) |
| 413 | } |
| 414 | t.Cleanup(func() { os.Chmod(dd, 0o700) }) |
| 415 | m := armRollout(context.Background(), overlapEnv(pub), svc, reg, dd, nil, |
| 416 | func(context.Context) (release.Store, error) { return release.NewDirStore(t.TempDir()), nil }) |
| 417 | if m != nil || armedRollout() != nil { |
| 418 | t.Fatal("armed over a book it could not save") |
| 419 | } |
| 420 | if rolloutUnavailable() != rolloutBookUnreadable { |
| 421 | t.Fatalf("unavailable %q, want %q", rolloutUnavailable(), rolloutBookUnreadable) |
| 422 | } |
| 423 | managed := registry.Route{Host: "a.example.com", Kind: "instance", App: "blog", Account: "bab", Status: "ready", Hibernate: true, Version: "v2"} |
| 424 | if p := svc.Planner(context.Background(), managed); p.Refuse != refuseTexts[rolloutBookUnreadable] { |
| 425 | t.Fatalf("plan %+v: want the unreadable-book refusal", p) |
| 426 | } |
| 427 | } |
| 428 | |
| 429 | // Final review round 1, finding 3, restated for the hard rule (readiness |
| 430 | // round 2 Task 1; FailureBook's doc, rules 2 to 5): every open of the book |
| 431 | // owes a save before the scheduler arms, intents or none. A new book starts |
| 432 | // at sequence 0 with a crypto/rand identity and is written as schema 3 with |
| 433 | // durable_epochs; a box that cannot save that could publish epochs it never |
| 434 | // stored, so it refuses managed starts. A box that can save it arms, with |
| 435 | // the identity and the durable marker on disk. |
| 436 | func TestAnArmedBoxSavesItsBookOnOpenOrRefusesManagedStarts(t *testing.T) { |
| 437 | if os.Geteuid() == 0 { |
| 438 | t.Skip("running as root: a read-only directory does not fail a save") |
| 439 | } |
| 440 | _, pub, err := release.GenerateKey() |
| 441 | if err != nil { |
| 442 | t.Fatal(err) |
| 443 | } |
| 444 | store := func(context.Context) (release.Store, error) { return release.NewDirStore(t.TempDir()), nil } |
| 445 | |
| 446 | svc, reg, dd := rolloutFixture(t, true) |
| 447 | if err := os.Chmod(dd, 0o500); err != nil { |
| 448 | t.Fatal(err) |
| 449 | } |
| 450 | locked := dd |
| 451 | t.Cleanup(func() { os.Chmod(locked, 0o700) }) |
| 452 | if m := armRollout(context.Background(), overlapEnv(pub), svc, reg, dd, nil, store); m != nil || armedRollout() != nil { |
| 453 | t.Fatal("armed over a book whose open it could not save") |
| 454 | } |
| 455 | if rolloutUnavailable() != rolloutBookUnreadable { |
| 456 | t.Fatalf("unavailable %q, want %q", rolloutUnavailable(), rolloutBookUnreadable) |
| 457 | } |
| 458 | |
| 459 | svc, reg, dd = rolloutFixture(t, true) |
| 460 | if m := armRollout(context.Background(), overlapEnv(pub), svc, reg, dd, nil, store); m == nil { |
| 461 | t.Fatal("did not arm over a writable data dir") |
| 462 | } |
| 463 | raw, err := os.ReadFile(filepath.Join(dd, rollout.FailureFile)) |
| 464 | if err != nil { |
| 465 | t.Fatalf("the open's advance was not saved before arming: %v", err) |
| 466 | } |
| 467 | var f struct { |
| 468 | Schema int `json:"schema"` |
| 469 | Seq uint64 `json:"seq"` |
| 470 | Book string `json:"book"` |
| 471 | Durable bool `json:"durable_epochs"` |
| 472 | Records []json.RawMessage `json:"records"` |
| 473 | } |
| 474 | if err := json.Unmarshal(raw, &f); err != nil { |
| 475 | t.Fatalf("saved book %s: %v", raw, err) |
| 476 | } |
| 477 | if f.Schema != 3 || !f.Durable { |
| 478 | t.Fatalf("saved book %s: want schema 3 with durable_epochs true", raw) |
| 479 | } |
| 480 | if id, err := hex.DecodeString(f.Book); err != nil || len(id) != 16 { |
| 481 | t.Fatalf("saved book %s (%v): want a 16-byte hex identity", raw, err) |
| 482 | } |
| 483 | // This fixture has no records, intents or markers, so the highest value |
| 484 | // the book ever held is 0 and a new book hands out none before its first |
| 485 | // change: the saved sequence is at least that, and no more than the |
| 486 | // open itself needs (the old 2^20 advance is gone). |
| 487 | if len(f.Records) != 0 || f.Seq >= 1<<20 { |
| 488 | t.Fatalf("saved book %s: want an empty new book with a small sequence", raw) |
| 489 | } |
| 490 | } |
| 491 | |
| 492 | // The unreadable-book refusal is one approved text, shared with the |
| 493 | // scheduler's refusal of a wake whose intent it could not write (Task 5). |
| 494 | func TestTheUnreadableBookRefusalIsShared(t *testing.T) { |
| 495 | if refuseTexts[rolloutBookUnreadable] != rollout.BookUnreadableRefusal { |
| 496 | t.Fatalf("host %q, rollout %q", refuseTexts[rolloutBookUnreadable], rollout.BookUnreadableRefusal) |
| 497 | } |
| 498 | } |
| 499 | |
| 500 | func mustGet(t *testing.T, reg *registry.Registry, host string) *registry.Route { |
| 501 | t.Helper() |
| 502 | rt, err := reg.Get(host) |
| 503 | if err != nil || rt == nil { |
| 504 | t.Fatalf("get %s: %v", host, err) |
| 505 | } |
| 506 | return rt |
| 507 | } |
| 508 | |
| 509 | // PF15: with the flag on but no delegated cgroup subtree, the flag is |
| 510 | // refused before the activator exists. Generations and containment stay |
| 511 | // off, no manager is armed, no hook is set, every leg is today's, and the |
| 512 | // status carries the reason code. |
| 513 | func TestNoDelegationDeclinesTheFlag(t *testing.T) { |
| 514 | _, pub, err := release.GenerateKey() |
| 515 | if err != nil { |
| 516 | t.Fatal(err) |
| 517 | } |
| 518 | resetRollout(t) |
| 519 | forbidPlacementCheck(t) |
| 520 | eb := &activator.ExecBackend{Control: instancectl.NewClient(filepath.Join(t.TempDir(), "ctl.sock"))} |
| 521 | if swapArming(overlapEnv(pub), eb) { |
| 522 | t.Fatal("armed generations with no delegated cgroup subtree") |
| 523 | } |
| 524 | if got := rolloutUnarmed(); got != rolloutNoDelegation { |
| 525 | t.Fatalf("declined with %q, want %q", got, rolloutNoDelegation) |
| 526 | } |
| 527 | // runAgent: SetGenerations(swapOn), then New, then armRollout. |
| 528 | svc, reg, dd := rolloutFixture(t, false) |
| 529 | declineRollout(rolloutNoDelegation) // rolloutFixture reset it; runAgent would not |
| 530 | opened := false |
| 531 | m := armRollout(context.Background(), overlapEnv(pub), svc, reg, dd, nil, |
| 532 | func(context.Context) (release.Store, error) { |
| 533 | opened = true |
| 534 | return release.NewDirStore(t.TempDir()), nil |
| 535 | }) |
| 536 | if m != nil || armedRollout() != nil || opened { |
| 537 | t.Fatal("armed a scheduler over an activator without generations") |
| 538 | } |
| 539 | if svc.Planner != nil || svc.OnGen != nil || svc.OnDrift != nil || svc.CoalesceRecycle != nil { |
| 540 | t.Fatal("a declined flag set an activator hook") |
| 541 | } |
| 542 | if r := rolloutStatusProbe(); r == nil || r.Unavailable != rolloutNoDelegation { |
| 543 | t.Fatalf("status = %+v, want the reason code %q", r, rolloutNoDelegation) |
| 544 | } |
| 545 | if rolloutUnavailable() != "" { |
| 546 | t.Fatal("a declined flag refuses managed starts") |
| 547 | } |
| 548 | legsAreTodays(t, svc, reg) |
| 549 | } |
| 550 | |
| 551 | // The armed half of PF15: a delegated subtree and a control channel that |
| 552 | // speaks the generation verbs arm generations; a channel without them does |
| 553 | // not. |
| 554 | func TestDelegationArmsTheFlag(t *testing.T) { |
| 555 | _, pub, err := release.GenerateKey() |
| 556 | if err != nil { |
| 557 | t.Fatal(err) |
| 558 | } |
| 559 | resetRollout(t) |
| 560 | calls := stubPlacementCheck(t, nil) |
| 561 | eb := armableBackend(t) |
| 562 | if !swapArming(overlapEnv(pub), eb) || rolloutUnarmed() != "" { |
| 563 | t.Fatalf("delegation present: declined with %q", rolloutUnarmed()) |
| 564 | } |
| 565 | if *calls != 1 { |
| 566 | t.Fatalf("the armed flag ran the placement check %d times, want once", *calls) |
| 567 | } |
| 568 | if swapArming(noEnv, eb) { |
| 569 | t.Fatal("the flag off armed generations") |
| 570 | } |
| 571 | if *calls != 1 { |
| 572 | t.Fatal("the flag off ran the placement check") |
| 573 | } |
| 574 | eb.Control = nil |
| 575 | if swapArming(overlapEnv(pub), eb) || rolloutUnarmed() != rolloutNoGenControl { |
| 576 | t.Fatalf("no generation control: armed, or declined with %q", rolloutUnarmed()) |
| 577 | } |
| 578 | if *calls != 1 { |
| 579 | t.Fatal("a box that declined on its generation control still ran the placement check") |
| 580 | } |
| 581 | } |
| 582 | |
| 583 | // The second dev-box check (#272): a box armed CARLOS_SWAP=overlap and then |
| 584 | // failed every generation start, because no process could be placed in its |
| 585 | // cgroup under carlos-edge.service's hardening. Arming now does one real |
| 586 | // placement into a scratch cgroup and declines when it fails, with a code |
| 587 | // of its own, a loud operator line, and the code in the box's status. |
| 588 | func TestSwapArmingDeclinesWhenPlacementFails(t *testing.T) { |
| 589 | _, pub, err := release.GenerateKey() |
| 590 | if err != nil { |
| 591 | t.Fatal(err) |
| 592 | } |
| 593 | resetRollout(t) |
| 594 | var buf bytes.Buffer |
| 595 | log.SetOutput(&buf) |
| 596 | t.Cleanup(func() { log.SetOutput(os.Stderr) }) |
| 597 | eb := armableBackend(t) |
| 598 | var got *cgroup.Manager |
| 599 | orig := placementCheck |
| 600 | placementCheck = func(m *cgroup.Manager) error { got = m; return errors.New("probe exited before exec") } |
| 601 | t.Cleanup(func() { placementCheck = orig }) |
| 602 | if swapArming(overlapEnv(pub), eb) { |
| 603 | t.Fatal("armed with a failed placement check") |
| 604 | } |
| 605 | if got != eb.Cgroups { |
| 606 | t.Fatal("the placement check was not run against the backend's cgroup manager") |
| 607 | } |
| 608 | if rolloutUnarmed() != rolloutPlacementFailed { |
| 609 | t.Fatalf("declined as %q", rolloutUnarmed()) |
| 610 | } |
| 611 | if rolloutUnavailable() != "" { |
| 612 | t.Fatal("a placement decline refuses managed starts") |
| 613 | } |
| 614 | if s := buf.String(); !strings.Contains(s, "REFUSING CARLOS_SWAP=overlap (cgroup-placement-failed)") || |
| 615 | !strings.Contains(s, "probe exited before exec") { |
| 616 | t.Fatalf("no operator line naming the placement refusal and its error: %q", s) |
| 617 | } |
| 618 | if r := rolloutStatusProbe(); r == nil || r.Unavailable != rolloutPlacementFailed { |
| 619 | t.Fatalf("status %+v: want Unavailable %q", r, rolloutPlacementFailed) |
| 620 | } |
| 621 | } |
| 622 | |
| 623 | // A box that declined for a failed placement honours the failure records an |
| 624 | // earlier armed run left, exactly as the other declined codes do |
| 625 | // (honourDeclinedFailures keys on the activator built without generations, |
| 626 | // not on the code), and publishes its own code beside the honoured book. |
| 627 | func TestPlacementDeclineHonoursFailureRecords(t *testing.T) { |
| 628 | t.Setenv("CARLOS_OPT_DIR", t.TempDir()) |
| 629 | svc, reg, dd := rolloutFixture(t, false) |
| 630 | acct := release.RouteAccount(managedRoute("x", "")) |
| 631 | writeFailureBook(t, dd, []rollout.FailureRecord{{ |
| 632 | Account: acct, App: "blog", Build: "v2", Host: "sib.example.com", Cause: "adopt", Mode: "hold", Reason: "health", |
| 633 | }}, nil) |
| 634 | _, pub, err := release.GenerateKey() |
| 635 | if err != nil { |
| 636 | t.Fatal(err) |
| 637 | } |
| 638 | stubPlacementCheck(t, errors.New("the shim exited before placement")) |
| 639 | if swapArming(overlapEnv(pub), armableBackend(t)) { |
| 640 | t.Fatal("armed with a failed placement check") |
| 641 | } |
| 642 | m := armRollout(context.Background(), overlapEnv(pub), svc, reg, dd, nil, unusedStore) |
| 643 | if m != nil || armedRollout() != nil { |
| 644 | t.Fatal("a declined box armed the scheduler") |
| 645 | } |
| 646 | if heldHolds() == nil || svc.Planner == nil { |
| 647 | t.Fatal("a placement decline did not honour the failure records") |
| 648 | } |
| 649 | blocked := managedRoute("a.example.com", "v2") |
| 650 | blocked.GoodVersion, blocked.GoodDigest = "v1", "sha256:v1" |
| 651 | if p := svc.Planner(context.Background(), blocked); p.Refuse != refuseNoSwaps { |
| 652 | t.Fatalf("a wake onto a held build planned %+v, want refused", p) |
| 653 | } |
| 654 | if r := rolloutStatusProbe(); r == nil || r.Unavailable != rolloutPlacementFailed || r.FailureBook != rolloutBookHonoured { |
| 655 | t.Fatalf("status = %+v, want the decline code %q and the book %q", r, rolloutPlacementFailed, rolloutBookHonoured) |
| 656 | } |
| 657 | } |
| 658 | |
| 659 | // A box with host claims on declines the swap flag (#272): claims-backed |
| 660 | // launches cannot yet carry a swap's recovery choice, execution evidence, |
| 661 | // generations file or good build. The claims check comes before delegation's, |
| 662 | // so a box that has neither says claims. |
| 663 | func TestSwapArmingDeclinesWhenHostClaimsAreOn(t *testing.T) { |
| 664 | resetRollout(t) |
| 665 | forbidPlacementCheck(t) |
| 666 | var buf bytes.Buffer |
| 667 | log.SetOutput(&buf) |
| 668 | t.Cleanup(func() { log.SetOutput(os.Stderr) }) |
| 669 | // No cgroups either: the claims check must come first, so the code |
| 670 | // says claims, not delegation. |
| 671 | eb := &activator.ExecBackend{Cfg: activator.Config{HostWrites: hostwrite.Config{Enabled: true}}} |
| 672 | if swapArming(overlapEnv(""), eb) { |
| 673 | t.Fatal("armed a box with host claims on") |
| 674 | } |
| 675 | if got := rolloutUnarmed(); got != rolloutHostClaims { |
| 676 | t.Fatalf("decline code %q, want %q", got, rolloutHostClaims) |
| 677 | } |
| 678 | if !strings.Contains(buf.String(), "REFUSING CARLOS_SWAP=overlap (host-claims-on)") { |
| 679 | t.Fatalf("no operator line naming the claims refusal: %q", buf.String()) |
| 680 | } |
| 681 | r := rolloutStatusProbe() |
| 682 | if r == nil || r.Unavailable != rolloutHostClaims { |
| 683 | t.Fatalf("status %+v: want Unavailable %q", r, rolloutHostClaims) |
| 684 | } |
| 685 | } |
| 686 | |
| 687 | func TestSwapArmingIgnoresClaimsWithTheFlagOff(t *testing.T) { |
| 688 | resetRollout(t) |
| 689 | forbidPlacementCheck(t) |
| 690 | eb := &activator.ExecBackend{Cfg: activator.Config{HostWrites: hostwrite.Config{Enabled: true}}} |
| 691 | if swapArming(noEnv, eb) || rolloutUnarmed() != "" { |
| 692 | t.Fatal("flag off: a claims box must say nothing") |
| 693 | } |
| 694 | } |
| 695 | |
| 696 | func TestRestartsFeedTheSchedulerWithTheirIdentity(t *testing.T) { |
| 697 | routes := []registry.Route{ |
| 698 | {Host: "a.example.com", Kind: "instance", App: "blog", Account: "bab", Status: "ready", Hibernate: true}, |
| 699 | {Host: "bot.internal", Kind: "sidecar", App: "blog", Account: "bab", Status: "ready"}, |
| 700 | {Host: "u.example.com", Kind: "instance", App: "blog", Account: "bab", Status: "ready", Unit: "blog.service"}, |
| 701 | {Host: "o.example.com", Kind: "instance", App: "other", Account: "bab", Status: "ready", Hibernate: true}, |
| 702 | } |
| 703 | k := [2]string{"bab", "blog"} |
| 704 | got := map[string]string{} |
| 705 | n := feedRestarts(routes, [][2]string{k}, nil, map[[2]string]string{k: "etag-7"}, func(h, id string) bool { got[h] = id; return true }) |
| 706 | if n != 2 || got["a.example.com"] != "gen:etag-7" || got["bot.internal"] != "gen:etag-7" { |
| 707 | t.Fatalf("fed %d: %v; want the instance and the sidecar, never the named unit or another app", n, got) |
| 708 | } |
| 709 | scoped := []scopedRestart{{app: k, requests: []release.RestartRequest{ |
| 710 | {ID: "r1", Scope: release.RestartScope{Kind: release.RestartScopeHost, Value: "a.example.com"}}, |
| 711 | {ID: "r2", Scope: release.RestartScope{Kind: release.RestartScopeHost, Value: "a.example.com"}}, |
| 712 | }}} |
| 713 | got = map[string]string{} |
| 714 | if n := feedRestarts(routes, nil, scoped, nil, func(h, id string) bool { got[h] = id; return true }); n != 1 || got["a.example.com"] != "scope:r2" { |
| 715 | t.Fatalf("fed %d: %v; want the narrowed host, identified by the newest request", n, got) |
| 716 | } |
| 717 | } |
| 718 | |
| 719 | // Carried obligation 4 (T13): OnChange is a non-blocking nudge that |
| 720 | // coalesces, so a burst of transitions is one early status tick. |
| 721 | func TestOnChangeCoalescesIntoOneNudge(t *testing.T) { |
| 722 | resetRollout(t) |
| 723 | done := make(chan struct{}) |
| 724 | go func() { |
| 725 | for range 100 { |
| 726 | nudgeStatus() |
| 727 | } |
| 728 | close(done) |
| 729 | }() |
| 730 | select { |
| 731 | case <-done: |
| 732 | case <-time.After(5 * time.Second): |
| 733 | t.Fatal("nudgeStatus blocked") |
| 734 | } |
| 735 | if len(statusNudge) != 1 { |
| 736 | t.Fatalf("%d nudges queued, want one", len(statusNudge)) |
| 737 | } |
| 738 | } |
| 739 | |
| 740 | // Carried obligation 3 (T11): OnWorkerServing returns at once even while |
| 741 | // the registry write is slow, and the newest identity a host was handed is |
| 742 | // the one that lands last. |
| 743 | func TestTheWorkerServingWriteIsCheapAndKeepsTheNewest(t *testing.T) { |
| 744 | release1 := make(chan struct{}) |
| 745 | var mu sync.Mutex |
| 746 | var writes []string |
| 747 | w := newServingWriter(func(host, ver string) error { |
| 748 | if ver == "v1" { |
| 749 | <-release1 |
| 750 | } |
| 751 | mu.Lock() |
| 752 | writes = append(writes, host+"="+ver) |
| 753 | mu.Unlock() |
| 754 | return nil |
| 755 | }) |
| 756 | w.publish("a", "v1") // the drain blocks inside this write |
| 757 | start := time.Now() |
| 758 | w.publish("a", "v2") |
| 759 | w.publish("b", "v1b") |
| 760 | w.publish("a", "v3") |
| 761 | if d := time.Since(start); d > time.Second { |
| 762 | t.Fatalf("publish waited %s on a slow write", d) |
| 763 | } |
| 764 | close(release1) |
| 765 | deadline := time.Now().Add(5 * time.Second) |
| 766 | for { |
| 767 | w.mu.Lock() |
| 768 | idle := !w.running |
| 769 | w.mu.Unlock() |
| 770 | if idle { |
| 771 | break |
| 772 | } |
| 773 | if time.Now().After(deadline) { |
| 774 | t.Fatal("the writer never drained") |
| 775 | } |
| 776 | time.Sleep(5 * time.Millisecond) |
| 777 | } |
| 778 | mu.Lock() |
| 779 | defer mu.Unlock() |
| 780 | var lastA string |
| 781 | for _, x := range writes { |
| 782 | if x[:2] == "a=" { |
| 783 | lastA = x |
| 784 | } |
| 785 | } |
| 786 | if lastA != "a=v3" || !slices.Contains(writes, "b=v1b") { |
| 787 | t.Fatalf("writes %v: want a's newest (v3) last and b's written", writes) |
| 788 | } |
| 789 | if slices.Contains(writes, "a=v2") { |
| 790 | t.Fatalf("writes %v: a stale intermediate version was written", writes) |
| 791 | } |
| 792 | } |
| 793 | |
| 794 | // armAlways's callback reaches the registry, through the writer. |
| 795 | func TestArmAlwaysRecordsTheServingVersion(t *testing.T) { |
| 796 | t.Parallel() |
| 797 | svc, reg, dd := rolloutFixture(t, false) |
| 798 | host := "bot.internal" |
| 799 | if err := reg.Upsert(registry.Route{Host: host, Kind: "sidecar", App: "blog", Account: "bab", Status: "ready"}); err != nil { |
| 800 | t.Fatal(err) |
| 801 | } |
| 802 | armAlways(svc, reg, dd) |
| 803 | svc.OnWorkerServing(host, release.StagedIdentity{Version: "v4"}) |
| 804 | deadline := time.Now().Add(5 * time.Second) |
| 805 | for { |
| 806 | if rt, _ := reg.Get(host); rt != nil && rt.ServingVersion == "v4" { |
| 807 | return |
| 808 | } |
| 809 | if time.Now().After(deadline) { |
| 810 | t.Fatal("the serving version never reached the registry") |
| 811 | } |
| 812 | time.Sleep(5 * time.Millisecond) |
| 813 | } |
| 814 | } |
| 815 | |
| 816 | // outageStore fails every read the way a bucket outage does. |
| 817 | type outageStore struct{ release.Store } |
| 818 | |
| 819 | func (outageStore) Get(context.Context, string) ([]byte, string, error) { |
| 820 | return nil, "", errors.New("503 slow down") |
| 821 | } |
| 822 | |
| 823 | // Carried obligation 1 (T3): the policy loader answers an error for any |
| 824 | // store failure, and only a confirmed missing object as the defaults. A |
| 825 | // policy never read is hold (rollout's policyCache). |
| 826 | func TestThePolicyLoaderFailsOnAnyStoreError(t *testing.T) { |
| 827 | ctx := context.Background() |
| 828 | if m, err := rolloutPolicy(outageStore{})(ctx, "bab", "blog"); err == nil { |
| 829 | t.Fatalf("a store failure read as %+v", m) |
| 830 | } |
| 831 | s := release.NewDirStore(t.TempDir()) |
| 832 | m, err := rolloutPolicy(s)(ctx, "bab", "blog") |
| 833 | if err != nil { |
| 834 | t.Fatalf("a missing config object: %v", err) |
| 835 | } |
| 836 | if got := rollout.PolicyFromManifest(m); got != rollout.DefaultPolicy() { |
| 837 | t.Fatalf("a missing config object read as %+v, want the defaults", got) |
| 838 | } |
| 839 | if _, err := s.Put(ctx, appconfig.Key("bab", "blog"), []byte("{not json"), ""); err != nil { |
| 840 | t.Fatal(err) |
| 841 | } |
| 842 | if m, err := rolloutPolicy(s)(ctx, "bab", "blog"); err == nil { |
| 843 | t.Fatalf("an undecodable config object read as %+v", m) |
| 844 | } |
| 845 | } |
| 846 | |
| 847 | // A refusing box must not tell a member an awake managed instance was |
| 848 | // restarted: the instance still runs its old config and nothing will restart |
| 849 | // it (spec §8.1, an ack only once active). Its ack carries a fixed |
| 850 | // RestartError and Restarted stays false. A sleeper reads the file on its |
| 851 | // next wake, so its ack says delivered on wake (Hibernating, final review F7 |
| 852 | // (3)), never Restarted. An exec route the scheduler does not manage is |
| 853 | // restarted as today and acked as restarted (F7 (2)). |
| 854 | func TestRefusingConfigSyncDoesNotAckAnAwakeManagedHostAsRestarted(t *testing.T) { |
| 855 | ctx := context.Background() |
| 856 | svc, reg, dd := rolloutFixture(t, true) |
| 857 | _, pub, err := release.GenerateKey() |
| 858 | if err != nil { |
| 859 | t.Fatal(err) |
| 860 | } |
| 861 | noStore := func(context.Context) (release.Store, error) { |
| 862 | return nil, errors.New("no deployment store: secret detail") |
| 863 | } |
| 864 | if m := armRollout(ctx, overlapEnv(pub), svc, reg, dd, nil, noStore); m != nil { |
| 865 | t.Fatal("armed without a store") |
| 866 | } |
| 867 | store := release.NewDirStore(t.TempDir()) |
| 868 | privText, _, err := appconfig.GenerateKey() |
| 869 | if err != nil { |
| 870 | t.Fatal(err) |
| 871 | } |
| 872 | priv, err := appconfig.ParsePrivateKey(privText, "test") |
| 873 | if err != nil { |
| 874 | t.Fatal(err) |
| 875 | } |
| 876 | if err := appconfig.SetEnv(ctx, store, "bab", "eleven", map[string]string{"LOG_LEVEL": "debug"}); err != nil { |
| 877 | t.Fatal(err) |
| 878 | } |
| 879 | for _, h := range []string{"awake.example.com", "asleep.example.com"} { |
| 880 | rt := execRoute(h, "bab", "eleven") |
| 881 | rt.Hibernate = true |
| 882 | if err := reg.Upsert(rt); err != nil { |
| 883 | t.Fatal(err) |
| 884 | } |
| 885 | } |
| 886 | // Always on, so not managed (rollout.Managed): today's restart. |
| 887 | if err := reg.Upsert(execRoute("plain.example.com", "bab", "eleven")); err != nil { |
| 888 | t.Fatal(err) |
| 889 | } |
| 890 | var restarted []string |
| 891 | cb, ok := rolloutConfigCallbacks(ctx, store, configLegs{ |
| 892 | awake: func(h string) bool { return h == "awake.example.com" }, |
| 893 | restart: func(h string) error { restarted = append(restarted, h); return nil }, |
| 894 | }) |
| 895 | if !ok { |
| 896 | t.Fatal("refusing mode has no config callbacks") |
| 897 | } |
| 898 | acks := map[string]appconfig.ConfigAck{} |
| 899 | cb.Ack = func(a appconfig.ConfigAck) { acks[a.Host] = a } |
| 900 | if err := appconfig.SyncExecWithCallbacks(ctx, reg, store, priv, t.TempDir(), "", cb); err != nil { |
| 901 | t.Fatal(err) |
| 902 | } |
| 903 | awake, ok := acks["awake.example.com"] |
| 904 | if !ok { |
| 905 | t.Fatalf("no ack for the awake host: %+v", acks) |
| 906 | } |
| 907 | if awake.Restarted || awake.RestartError != rolloutRestartRefused { |
| 908 | t.Fatalf("awake ack = %+v, want Restarted false and RestartError %q", awake, rolloutRestartRefused) |
| 909 | } |
| 910 | asleep, ok := acks["asleep.example.com"] |
| 911 | if !ok { |
| 912 | t.Fatalf("no ack for the sleeping host: %+v", acks) |
| 913 | } |
| 914 | if asleep.RestartError != "" || asleep.Restarted || !asleep.Hibernating { |
| 915 | t.Fatalf("sleeper ack = %+v, want delivered on wake: Hibernating, not restarted, no error", asleep) |
| 916 | } |
| 917 | if !slices.Equal(restarted, []string{"plain.example.com"}) { |
| 918 | t.Fatalf("today's restart ran for %v, want only the unmanaged route", restarted) |
| 919 | } |
| 920 | if plain := acks["plain.example.com"]; !plain.Restarted || plain.RestartError != "" || plain.Hibernating { |
| 921 | t.Fatalf("unmanaged ack = %+v, want today's: restarted", plain) |
| 922 | } |
| 923 | } |
| 924 | |
| 925 | // Final review F7 (1) and (2), armed: config sync reports a managed native |
| 926 | // sidecar's delivery to the scheduler, which owns its restart, and restarts |
| 927 | // an exec route the scheduler does not manage as today (svc.Bounce, through |
| 928 | // configLegs.restart), acking it as restarted at once; neither managed route |
| 929 | // is restarted by config sync, and the unmanaged one is not reported. |
| 930 | func TestArmedConfigSyncSplitsManagedFromUnmanaged(t *testing.T) { |
| 931 | ctx, cancel := context.WithCancel(context.Background()) |
| 932 | t.Cleanup(cancel) |
| 933 | svc, reg, dd := rolloutFixture(t, true) |
| 934 | svc.GenerationsFile = release.GenerationsPath(dd) |
| 935 | _, pub, err := release.GenerateKey() |
| 936 | if err != nil { |
| 937 | t.Fatal(err) |
| 938 | } |
| 939 | managed := execRoute("m.example.com", "bab", "eleven") |
| 940 | managed.Hibernate, managed.Version = true, "v1" |
| 941 | bot := registry.Route{Host: "bot.eleven.bab.sidecar.internal", Kind: "sidecar", App: "eleven", Account: "bab", Status: "ready", Version: "v1"} |
| 942 | plain := execRoute("plain.example.com", "bab", "eleven") // always on: not managed |
| 943 | for _, rt := range []registry.Route{managed, bot, plain} { |
| 944 | if err := reg.Upsert(rt); err != nil { |
| 945 | t.Fatal(err) |
| 946 | } |
| 947 | } |
| 948 | if !rollout.Managed(managed) || !rollout.Managed(bot) || rollout.Managed(plain) { |
| 949 | t.Fatal("fixture: the routes are not managed as this test assumes") |
| 950 | } |
| 951 | store := release.NewDirStore(t.TempDir()) |
| 952 | m := armRollout(ctx, overlapEnv(pub), svc, reg, dd, nil, func(context.Context) (release.Store, error) { return store, nil }) |
| 953 | if m == nil { |
| 954 | t.Fatal("not armed") |
| 955 | } |
| 956 | privText, _, err := appconfig.GenerateKey() |
| 957 | if err != nil { |
| 958 | t.Fatal(err) |
| 959 | } |
| 960 | priv, err := appconfig.ParsePrivateKey(privText, "test") |
| 961 | if err != nil { |
| 962 | t.Fatal(err) |
| 963 | } |
| 964 | if err := appconfig.SetEnv(ctx, store, "bab", "eleven", map[string]string{"LOG_LEVEL": "debug"}); err != nil { |
| 965 | t.Fatal(err) |
| 966 | } |
| 967 | var restarted, delivered []string |
| 968 | cb, ok := rolloutConfigCallbacks(ctx, store, configLegs{restart: func(h string) error { restarted = append(restarted, h); return nil }}) |
| 969 | if !ok || cb.Delivered == nil { |
| 970 | t.Fatal("armed mode has no delivery callback") |
| 971 | } |
| 972 | report := cb.Delivered |
| 973 | cb.Delivered = func(h, rev string) { delivered = append(delivered, h); report(h, rev) } |
| 974 | acks := map[string]appconfig.ConfigAck{} |
| 975 | cb.Ack = m.DeferAck(func(_ context.Context, a appconfig.ConfigAck) { acks[a.Host] = a }) |
| 976 | if err := appconfig.SyncExecWithCallbacks(ctx, reg, store, priv, t.TempDir(), "", cb); err != nil { |
| 977 | t.Fatal(err) |
| 978 | } |
| 979 | // The manager's writer writes the acks (Task 11); Close writes what is |
| 980 | // still pending before it returns, so the map is complete after it. |
| 981 | m.Close(ctx) |
| 982 | slices.Sort(delivered) |
| 983 | if want := []string{bot.Host, managed.Host}; !slices.Equal(delivered, want) { |
| 984 | t.Fatalf("delivered %v, want %v: the managed sidecar's revision must reach the scheduler, the unmanaged route's must not", delivered, want) |
| 985 | } |
| 986 | if !slices.Equal(restarted, []string{plain.Host}) { |
| 987 | t.Fatalf("today's restart ran for %v, want only the unmanaged route", restarted) |
| 988 | } |
| 989 | if a, ok := acks[plain.Host]; !ok || !a.Restarted || a.RestartError != "" { |
| 990 | t.Fatalf("unmanaged ack = %+v (written %v), want today's: restarted, at once", a, ok) |
| 991 | } |
| 992 | } |
| 993 | |
| 994 | // The awake test is the activator's: a managed host it owns. Without an |
| 995 | // activator, or for a host it does not own, nothing is awake. |
| 996 | func TestManagedAwakeNeedsTheActivatorToOwnAManagedHost(t *testing.T) { |
| 997 | svc, reg, _ := rolloutFixture(t, true) |
| 998 | rt := execRoute("m.example.com", "bab", "eleven") |
| 999 | rt.Hibernate = true |
| 1000 | if err := reg.Upsert(rt); err != nil { |
| 1001 | t.Fatal(err) |
| 1002 | } |
| 1003 | if managedAwake(nil, reg)("m.example.com") { |
| 1004 | t.Error("awake with no activator") |
| 1005 | } |
| 1006 | if managedAwake(svc, reg)("m.example.com") || managedAwake(svc, reg)("gone.example.com") { |
| 1007 | t.Error("awake though the activator owns nothing") |
| 1008 | } |
| 1009 | } |
| 1010 | |