| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "log" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "strings" |
| 12 | "sync" |
| 13 | "testing" |
| 14 | "time" |
| 15 | |
| 16 | "github.com/carlosframework/platform/internal/activator" |
| 17 | "github.com/carlosframework/platform/internal/hostclaim" |
| 18 | "github.com/carlosframework/platform/internal/provision" |
| 19 | "github.com/carlosframework/platform/internal/registry" |
| 20 | "github.com/carlosframework/platform/internal/release" |
| 21 | "github.com/carlosframework/platform/internal/runs" |
| 22 | ) |
| 23 | |
| 24 | const runHost = "acme.example.com" |
| 25 | |
| 26 | func runRecord() provision.Record { |
| 27 | return provision.Record{Schema: 1, Host: runHost, Slug: "acme", App: "shop", Account: "bab", Region: "ie", Channel: "stable", CreatedAt: time.Now().UTC()} |
| 28 | } |
| 29 | |
| 30 | func runOwnedRoute(host string) func(string) (*registry.Route, error) { |
| 31 | return func(h string) (*registry.Route, error) { |
| 32 | if h != host { |
| 33 | return nil, nil |
| 34 | } |
| 35 | return ®istry.Route{Host: host, Kind: "instance", Hibernate: true, Socket: "/run/x.sock", Provisioned: true, App: "shop", Account: "bab"}, nil |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | func noRoutes(string) (*registry.Route, error) { return nil, nil } |
| 40 | |
| 41 | // queueRun writes a record whose run is queued, with its request.json. |
| 42 | func queueRun(t *testing.T, s release.Store, deadline time.Time) (provision.Record, string) { |
| 43 | t.Helper() |
| 44 | rec := runRecord() |
| 45 | if err := provision.CreateRecord(context.Background(), s, rec); err != nil { |
| 46 | t.Fatal(err) |
| 47 | } |
| 48 | id := "run_abcdefghijklmnopqrstuvwxyz" |
| 49 | inc, _ := hostclaimIncarnation(rec) |
| 50 | req := runs.Request{Schema: 1, ID: id, Account: rec.Account, App: rec.App, Host: rec.Host, Incarnation: inc, Argv: []string{"import"}, TimeoutS: 60, RequestedAt: time.Now()} |
| 51 | putJSONObject(t, s, runs.RequestKey(rec.Account, rec.App, id), req) |
| 52 | got, err := provision.SetRecordRun(context.Background(), s, rec.Account, rec.App, rec.Host, provision.RunAdmit(provision.RunAdmission{ID: id, By: "alice@keymail.dev", Deadline: deadline})) |
| 53 | if err != nil { |
| 54 | t.Fatal(err) |
| 55 | } |
| 56 | return *got, id |
| 57 | } |
| 58 | |
| 59 | type execLog struct { |
| 60 | mu sync.Mutex |
| 61 | ids []string |
| 62 | } |
| 63 | |
| 64 | func (l *execLog) exec(_ context.Context, _ provision.Record, req runs.Request, _ registry.Route, _ *runHandle) { |
| 65 | l.mu.Lock() |
| 66 | l.ids = append(l.ids, req.ID) |
| 67 | l.mu.Unlock() |
| 68 | } |
| 69 | func (l *execLog) count() int { l.mu.Lock(); defer l.mu.Unlock(); return len(l.ids) } |
| 70 | |
| 71 | // testWorker execs inline, so a test counts launches as soon as consider |
| 72 | // returns. |
| 73 | func testWorker(s release.Store, routes func(string) (*registry.Route, error), box, boot string, log *execLog) *runsWorker { |
| 74 | w := newRunsWorker(s, routes, nil, "/data", box, boot) |
| 75 | w.exec = log.exec |
| 76 | w.start = func(f func()) { f() } |
| 77 | w.booted.Store(true) // a test of the boot gate unsets it |
| 78 | return w |
| 79 | } |
| 80 | |
| 81 | func currentRun(t *testing.T, s release.Store) *provision.RunState { |
| 82 | t.Helper() |
| 83 | rec, err := provision.GetRecord(context.Background(), s, "bab", "shop", runHost) |
| 84 | if err != nil { |
| 85 | t.Fatal(err) |
| 86 | } |
| 87 | return rec.ActiveRun |
| 88 | } |
| 89 | |
| 90 | func TestRunsWorkerPicksOnlyRunsOnItsOwnRows(t *testing.T) { |
| 91 | s := release.NewDirStore(t.TempDir()) |
| 92 | rec, id := queueRun(t, s, time.Now().Add(time.Minute)) |
| 93 | var other, mine execLog |
| 94 | testWorker(s, noRoutes, "box-2", "boot-2", &other).consider(context.Background(), rec) |
| 95 | if other.count() != 0 || currentRun(t, s).State != provision.RunQueued { |
| 96 | t.Fatal("a box that does not own the row acted on the run") |
| 97 | } |
| 98 | testWorker(s, runOwnedRoute(runHost), "box-1", "boot-1", &mine).consider(context.Background(), rec) |
| 99 | r := currentRun(t, s) |
| 100 | if mine.count() != 1 || r.State != provision.RunPicked || r.Box != "box-1" || r.BootID != "boot-1" || r.ID != id { |
| 101 | t.Fatalf("pickup: execs %d, run %+v", mine.count(), r) |
| 102 | } |
| 103 | if d := time.Until(r.Deadline); d < 50*time.Second || d > 61*time.Second { |
| 104 | t.Errorf("the picked deadline is the run's timeout (60 s), got %s", d) |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | func TestRunsWorkerPickupRaceLaunchesOnce(t *testing.T) { |
| 109 | s := release.NewDirStore(t.TempDir()) |
| 110 | rec, _ := queueRun(t, s, time.Now().Add(time.Minute)) |
| 111 | var a, b execLog |
| 112 | wa := testWorker(s, runOwnedRoute(runHost), "box-1", "boot-1", &a) |
| 113 | wb := testWorker(s, runOwnedRoute(runHost), "box-2", "boot-2", &b) |
| 114 | var wg sync.WaitGroup |
| 115 | for _, w := range []*runsWorker{wa, wb} { |
| 116 | wg.Add(1) |
| 117 | go func() { defer wg.Done(); w.consider(context.Background(), rec) }() |
| 118 | } |
| 119 | wg.Wait() |
| 120 | if a.count()+b.count() != 1 { |
| 121 | t.Fatalf("launched %d times, want once", a.count()+b.count()) |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | func TestRunsWorkerLeavesARunPastItsPickupDeadline(t *testing.T) { |
| 126 | s := release.NewDirStore(t.TempDir()) |
| 127 | rec, _ := queueRun(t, s, time.Now().Add(-time.Second)) |
| 128 | var l execLog |
| 129 | testWorker(s, runOwnedRoute(runHost), "box-1", "boot-1", &l).consider(context.Background(), rec) |
| 130 | if l.count() != 0 || currentRun(t, s).State != provision.RunQueued { |
| 131 | t.Fatal("a box picked a run past its pickup deadline: that run is the expiry's") |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | func TestRunsWorkerNeverPicksACancelledOrExpiredRun(t *testing.T) { |
| 136 | for _, mut := range []func(string) func(*provision.Record) error{provision.RunCancelTransition, provision.RunExpire} { |
| 137 | s := release.NewDirStore(t.TempDir()) |
| 138 | rec, id := queueRun(t, s, time.Now().Add(time.Minute)) |
| 139 | if _, err := provision.SetRecordRun(context.Background(), s, "bab", "shop", runHost, mut(id)); err != nil { |
| 140 | t.Fatal(err) |
| 141 | } |
| 142 | var l execLog |
| 143 | // rec is the stale read the pass noted, still queued: the CAS refuses. |
| 144 | testWorker(s, runOwnedRoute(runHost), "box-1", "boot-1", &l).consider(context.Background(), rec) |
| 145 | if l.count() != 0 { |
| 146 | t.Fatal("a cancelled or expired run was launched (codex R2-6)") |
| 147 | } |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | func TestRunsWorkerClosesOutARunPickedUnderAnEarlierBoot(t *testing.T) { |
| 152 | s := release.NewDirStore(t.TempDir()) |
| 153 | _, id := queueRun(t, s, time.Now().Add(time.Minute)) |
| 154 | got, err := provision.SetRecordRun(context.Background(), s, "bab", "shop", runHost, provision.RunPick(id, "box-1", "boot-old", time.Now().Add(time.Hour))) |
| 155 | if err != nil { |
| 156 | t.Fatal(err) |
| 157 | } |
| 158 | if _, err := s.Put(context.Background(), runs.ChunkKey("bab", "shop", id, 4, runs.StreamOut), []byte("x"), ""); err != nil { |
| 159 | t.Fatal(err) |
| 160 | } |
| 161 | var l execLog |
| 162 | testWorker(s, runOwnedRoute(runHost), "box-1", "boot-new", &l).consider(context.Background(), *got) |
| 163 | if l.count() != 0 { |
| 164 | t.Fatal("a run picked before an agent restart was run again") |
| 165 | } |
| 166 | res, err := runs.ReadResult(context.Background(), s, "bab", "shop", id) |
| 167 | if err != nil || res == nil || res.Interrupted != runs.InterruptedAgentRestart || res.LastSeq != 4 || res.Box != "box-1" { |
| 168 | t.Fatalf("result %+v, %v", res, err) |
| 169 | } |
| 170 | if r := currentRun(t, s); r.State != provision.RunDone { |
| 171 | t.Fatalf("run %+v, want done", r) |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | func TestRunsWorkerForwardsACancelToItsRun(t *testing.T) { |
| 176 | s := release.NewDirStore(t.TempDir()) |
| 177 | _, id := queueRun(t, s, time.Now().Add(time.Minute)) |
| 178 | var l execLog |
| 179 | w := testWorker(s, runOwnedRoute(runHost), "box-1", "boot-1", &l) |
| 180 | h := newRunHandle() |
| 181 | w.active[id] = h |
| 182 | got, err := provision.SetRecordRun(context.Background(), s, "bab", "shop", runHost, provision.RunPick(id, "box-1", "boot-1", time.Now().Add(time.Hour))) |
| 183 | if err != nil { |
| 184 | t.Fatal(err) |
| 185 | } |
| 186 | if got, err = provision.SetRecordRun(context.Background(), s, "bab", "shop", runHost, provision.RunCancelTransition(id)); err != nil { |
| 187 | t.Fatal(err) |
| 188 | } |
| 189 | w.consider(context.Background(), *got) |
| 190 | select { |
| 191 | case <-h.cancel: |
| 192 | default: |
| 193 | t.Fatal("the cancel did not reach the run") |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | func TestBootPassPicksARunQueuedBeforeStart(t *testing.T) { |
| 198 | s := release.NewDirStore(t.TempDir()) |
| 199 | queueRun(t, s, time.Now().Add(time.Minute)) |
| 200 | var l execLog |
| 201 | w := testWorker(s, runOwnedRoute(runHost), "box-1", "boot-1", &l) |
| 202 | w.dataDir = t.TempDir() |
| 203 | w.bootPass(context.Background()) |
| 204 | if l.count() != 1 { |
| 205 | t.Fatal("the boot pass did not pick a run queued before agent start") |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | func TestRunsWorkerNotesAndPasses(t *testing.T) { |
| 210 | s := release.NewDirStore(t.TempDir()) |
| 211 | rec, _ := queueRun(t, s, time.Now().Add(time.Minute)) |
| 212 | var l execLog |
| 213 | w := testWorker(s, runOwnedRoute(runHost), "box-1", "boot-1", &l) |
| 214 | w.note(rec) |
| 215 | w.note(rec) |
| 216 | w.pass(context.Background()) |
| 217 | w.pass(context.Background()) |
| 218 | if l.count() != 1 { |
| 219 | t.Fatalf("execs %d, want 1: a noted record is acted on once", l.count()) |
| 220 | } |
| 221 | } |
| 222 | |
| 223 | func TestRunDirNamesAgree(t *testing.T) { |
| 224 | if runs.DirName != activator.CmdRunDirName { |
| 225 | t.Fatalf("runs.DirName %q, activator.CmdRunDirName %q", runs.DirName, activator.CmdRunDirName) |
| 226 | } |
| 227 | } |
| 228 | |
| 229 | func hostclaimIncarnation(rec provision.Record) (string, error) { |
| 230 | return hostclaim.Incarnation(rec.Account, rec.App, rec.Host, rec.CreatedAt) |
| 231 | } |
| 232 | |
| 233 | func putJSONObject(t *testing.T, s release.Store, key string, v any) { |
| 234 | t.Helper() |
| 235 | raw, err := json.Marshal(v) |
| 236 | if err != nil { |
| 237 | t.Fatal(err) |
| 238 | } |
| 239 | if _, err := s.Put(context.Background(), key, raw, ""); err != nil { |
| 240 | t.Fatal(err) |
| 241 | } |
| 242 | } |
| 243 | |
| 244 | // A run another box picked is that box's: this box neither closes it out |
| 245 | // nor forwards anything to it, whatever boot picked it. |
| 246 | func TestRunsWorkerLeavesARunAnotherBoxPicked(t *testing.T) { |
| 247 | s := release.NewDirStore(t.TempDir()) |
| 248 | _, id := queueRun(t, s, time.Now().Add(time.Minute)) |
| 249 | got, err := provision.SetRecordRun(context.Background(), s, "bab", "shop", runHost, provision.RunPick(id, "box-2", "boot-old", time.Now().Add(time.Hour))) |
| 250 | if err != nil { |
| 251 | t.Fatal(err) |
| 252 | } |
| 253 | var l execLog |
| 254 | testWorker(s, runOwnedRoute(runHost), "box-1", "boot-new", &l).consider(context.Background(), *got) |
| 255 | if res, err := runs.ReadResult(context.Background(), s, "bab", "shop", id); err != nil || res != nil { |
| 256 | t.Fatalf("a box wrote a result for a run another box picked: %+v, %v", res, err) |
| 257 | } |
| 258 | if r := currentRun(t, s); l.count() != 0 || r.State != provision.RunPicked || r.Box != "box-2" { |
| 259 | t.Fatalf("execs %d, run %+v", l.count(), r) |
| 260 | } |
| 261 | } |
| 262 | |
| 263 | // A run this box picked under its own boot is not an agent restart, even |
| 264 | // when the worker holds no handle for it (a stale record noted after the |
| 265 | // run's goroutine let go). |
| 266 | func TestRunsWorkerLeavesItsOwnRunUnderThisBoot(t *testing.T) { |
| 267 | s := release.NewDirStore(t.TempDir()) |
| 268 | _, id := queueRun(t, s, time.Now().Add(time.Minute)) |
| 269 | got, err := provision.SetRecordRun(context.Background(), s, "bab", "shop", runHost, provision.RunPick(id, "box-1", "boot-1", time.Now().Add(time.Hour))) |
| 270 | if err != nil { |
| 271 | t.Fatal(err) |
| 272 | } |
| 273 | var l execLog |
| 274 | testWorker(s, runOwnedRoute(runHost), "box-1", "boot-1", &l).consider(context.Background(), *got) |
| 275 | if res, err := runs.ReadResult(context.Background(), s, "bab", "shop", id); err != nil || res != nil { |
| 276 | t.Fatalf("a run of this boot was closed out as interrupted: %+v, %v", res, err) |
| 277 | } |
| 278 | if r := currentRun(t, s); l.count() != 0 || r.State != provision.RunPicked { |
| 279 | t.Fatalf("execs %d, run %+v", l.count(), r) |
| 280 | } |
| 281 | } |
| 282 | |
| 283 | // A run of this boot that is no longer executing and has its result, but is |
| 284 | // still picked (its done CAS failed past the finish bound), is CASed to done |
| 285 | // on the next pass, so it does not hold the instance until the lost sweep. |
| 286 | func TestRunsWorkerFinishesItsOwnStrandedRun(t *testing.T) { |
| 287 | for _, state := range []string{provision.RunPicked, provision.RunCancel} { |
| 288 | s := release.NewDirStore(t.TempDir()) |
| 289 | _, id := queueRun(t, s, time.Now().Add(time.Minute)) |
| 290 | got, err := provision.SetRecordRun(context.Background(), s, "bab", "shop", runHost, provision.RunPick(id, "box-1", "boot-1", time.Now().Add(time.Hour))) |
| 291 | if err != nil { |
| 292 | t.Fatal(err) |
| 293 | } |
| 294 | if state == provision.RunCancel { |
| 295 | if got, err = provision.SetRecordRun(context.Background(), s, "bab", "shop", runHost, provision.RunCancelTransition(id)); err != nil { |
| 296 | t.Fatal(err) |
| 297 | } |
| 298 | } |
| 299 | if err := runs.WriteResult(context.Background(), s, "bab", "shop", id, runs.Result{Exit: 3, Box: "box-1"}); err != nil { |
| 300 | t.Fatal(err) |
| 301 | } |
| 302 | var l execLog |
| 303 | testWorker(s, runOwnedRoute(runHost), "box-1", "boot-1", &l).consider(context.Background(), *got) |
| 304 | if r := currentRun(t, s); l.count() != 0 || r.State != provision.RunDone { |
| 305 | t.Fatalf("%s: execs %d, run %+v, want done", state, l.count(), r) |
| 306 | } |
| 307 | if res, _ := runs.ReadResult(context.Background(), s, "bab", "shop", id); res == nil || res.Exit != 3 { |
| 308 | t.Fatalf("%s: the box's result changed: %+v", state, res) |
| 309 | } |
| 310 | } |
| 311 | } |
| 312 | |
| 313 | // Only a provisioned row for the record's account and app that the |
| 314 | // activator manages is this box's to pick for. |
| 315 | func TestRunsWorkerPicksOnlyForActivatorManagedRowsOfTheRecord(t *testing.T) { |
| 316 | for name, mut := range map[string]func(*registry.Route){ |
| 317 | "unprovisioned": func(rt *registry.Route) { rt.Provisioned = false }, |
| 318 | "another account": func(rt *registry.Route) { rt.Account = "other" }, |
| 319 | "another app": func(rt *registry.Route) { rt.App = "other" }, |
| 320 | "not hibernating": func(rt *registry.Route) { rt.Hibernate = false }, |
| 321 | "unit-backed": func(rt *registry.Route) { rt.Backing = "unit" }, |
| 322 | "not an instance row": func(rt *registry.Route) { rt.Kind = "service" }, |
| 323 | } { |
| 324 | s := release.NewDirStore(t.TempDir()) |
| 325 | rec, _ := queueRun(t, s, time.Now().Add(time.Minute)) |
| 326 | routes := func(h string) (*registry.Route, error) { |
| 327 | rt, err := runOwnedRoute(runHost)(h) |
| 328 | if rt != nil { |
| 329 | mut(rt) |
| 330 | } |
| 331 | return rt, err |
| 332 | } |
| 333 | var l execLog |
| 334 | testWorker(s, routes, "box-1", "boot-1", &l).consider(context.Background(), rec) |
| 335 | if l.count() != 0 || currentRun(t, s).State != provision.RunQueued { |
| 336 | t.Errorf("%s: the box picked a run for a row it does not run", name) |
| 337 | } |
| 338 | } |
| 339 | } |
| 340 | |
| 341 | // A request.json that is not the run the record names, or whose timeout is |
| 342 | // outside 1 s to 6 h, is never picked: the run is left for the expiry. |
| 343 | func TestRunsWorkerLeavesARequestThatDoesNotMatchItsRecord(t *testing.T) { |
| 344 | for name, mut := range map[string]func(*runs.Request){ |
| 345 | "no timeout": func(r *runs.Request) { r.TimeoutS = 0 }, |
| 346 | "timeout over 6h": func(r *runs.Request) { r.TimeoutS = 6*3600 + 1 }, |
| 347 | "another id": func(r *runs.Request) { r.ID = "run_bbbbbbbbbbbbbbbbbbbbbbbbbb" }, |
| 348 | "another account": func(r *runs.Request) { r.Account = "other" }, |
| 349 | "another app": func(r *runs.Request) { r.App = "other" }, |
| 350 | "another host": func(r *runs.Request) { r.Host = "other.example.com" }, |
| 351 | } { |
| 352 | s := release.NewDirStore(t.TempDir()) |
| 353 | rec, id := queueRun(t, s, time.Now().Add(time.Minute)) |
| 354 | req, err := runs.ReadRequest(context.Background(), s, rec.Account, rec.App, id) |
| 355 | if err != nil { |
| 356 | t.Fatal(err) |
| 357 | } |
| 358 | mut(req) |
| 359 | key := runs.RequestKey(rec.Account, rec.App, id) |
| 360 | if err := s.Delete(context.Background(), key); err != nil { |
| 361 | t.Fatal(err) |
| 362 | } |
| 363 | putJSONObject(t, s, key, req) |
| 364 | var l execLog |
| 365 | w := testWorker(s, runOwnedRoute(runHost), "box-1", "boot-1", &l) |
| 366 | w.consider(context.Background(), rec) |
| 367 | if l.count() != 0 || currentRun(t, s).State != provision.RunQueued || len(w.active) != 0 { |
| 368 | t.Errorf("%s: picked (execs %d, run %+v)", name, l.count(), currentRun(t, s)) |
| 369 | } |
| 370 | } |
| 371 | } |
| 372 | |
| 373 | // A refused request.json is logged once, not on every pass that sees the |
| 374 | // run still queued. |
| 375 | func TestRunsWorkerLogsAnUnfitRequestOnce(t *testing.T) { |
| 376 | s := release.NewDirStore(t.TempDir()) |
| 377 | rec, id := queueRun(t, s, time.Now().Add(time.Minute)) |
| 378 | req, err := runs.ReadRequest(context.Background(), s, rec.Account, rec.App, id) |
| 379 | if err != nil { |
| 380 | t.Fatal(err) |
| 381 | } |
| 382 | req.TimeoutS = 0 |
| 383 | key := runs.RequestKey(rec.Account, rec.App, id) |
| 384 | if err := s.Delete(context.Background(), key); err != nil { |
| 385 | t.Fatal(err) |
| 386 | } |
| 387 | putJSONObject(t, s, key, req) |
| 388 | var buf bytes.Buffer |
| 389 | log.SetOutput(&buf) |
| 390 | defer log.SetOutput(os.Stderr) |
| 391 | var l execLog |
| 392 | w := testWorker(s, runOwnedRoute(runHost), "box-1", "boot-1", &l) |
| 393 | for range 3 { |
| 394 | w.consider(context.Background(), rec) |
| 395 | } |
| 396 | if n := strings.Count(buf.String(), "not picked"); n != 1 { |
| 397 | t.Fatalf("logged %d times, want once:\n%s", n, buf.String()) |
| 398 | } |
| 399 | } |
| 400 | |
| 401 | // lostAnswer answers every Put of the record with an error; when land is |
| 402 | // set the write lands first, as a request whose answer was lost does. |
| 403 | type lostAnswer struct { |
| 404 | release.Store |
| 405 | land bool |
| 406 | } |
| 407 | |
| 408 | func (l *lostAnswer) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| 409 | if key != provision.RecordKey("bab", "shop", runHost) { |
| 410 | return l.Store.Put(ctx, key, body, ifMatch) |
| 411 | } |
| 412 | if l.land { |
| 413 | if _, err := l.Store.Put(ctx, key, body, ifMatch); err != nil { |
| 414 | return "", err |
| 415 | } |
| 416 | } |
| 417 | return "", errors.New("connection reset by peer") |
| 418 | } |
| 419 | |
| 420 | // A pickup CAS whose answer was lost is read back: if the record names |
| 421 | // this run as this box's under this boot, the box launches it, so the run |
| 422 | // is not stranded until the sweep calls it lost. |
| 423 | func TestRunsWorkerLaunchesAPickupWhoseAnswerWasLost(t *testing.T) { |
| 424 | dir := release.NewDirStore(t.TempDir()) |
| 425 | rec, id := queueRun(t, dir, time.Now().Add(time.Minute)) |
| 426 | var l execLog |
| 427 | w := testWorker(&lostAnswer{Store: dir, land: true}, runOwnedRoute(runHost), "box-1", "boot-1", &l) |
| 428 | w.consider(context.Background(), rec) |
| 429 | if r := currentRun(t, dir); l.count() != 1 || r.State != provision.RunPicked || r.Box != "box-1" || r.ID != id { |
| 430 | t.Fatalf("execs %d, run %+v", l.count(), r) |
| 431 | } |
| 432 | } |
| 433 | |
| 434 | // A pickup CAS that failed and did not land is forgotten: the run stays |
| 435 | // queued and nothing is launched. |
| 436 | func TestRunsWorkerForgetsAPickupThatDidNotLand(t *testing.T) { |
| 437 | dir := release.NewDirStore(t.TempDir()) |
| 438 | rec, _ := queueRun(t, dir, time.Now().Add(time.Minute)) |
| 439 | var l execLog |
| 440 | w := testWorker(&lostAnswer{Store: dir}, runOwnedRoute(runHost), "box-1", "boot-1", &l) |
| 441 | w.consider(context.Background(), rec) |
| 442 | if l.count() != 0 || currentRun(t, dir).State != provision.RunQueued || len(w.active) != 0 { |
| 443 | t.Fatalf("execs %d, run %+v, active %d", l.count(), currentRun(t, dir), len(w.active)) |
| 444 | } |
| 445 | } |
| 446 | |
| 447 | func TestRunsHookIsOnlyThereWhenArmed(t *testing.T) { |
| 448 | armedRuns.Store(nil) |
| 449 | if cfg := withRunsHook(provision.ReconcileConfig{}); cfg.RunSeen != nil { |
| 450 | t.Fatal("an unarmed box must not hook reconcile") |
| 451 | } |
| 452 | runRunsPass(context.Background()) // no-op, no panic |
| 453 | runRunsBootPass(context.Background()) // ditto |
| 454 | var l execLog |
| 455 | w := testWorker(release.NewDirStore(t.TempDir()), noRoutes, "box-1", "boot-1", &l) |
| 456 | armedRuns.Store(w) |
| 457 | t.Cleanup(func() { armedRuns.Store(nil) }) |
| 458 | cfg := withRunsHook(provision.ReconcileConfig{}) |
| 459 | if cfg.RunSeen == nil { |
| 460 | t.Fatal("an armed box hooks reconcile") |
| 461 | } |
| 462 | cfg.RunSeen(runRecord()) |
| 463 | if len(w.noted) != 1 { |
| 464 | t.Fatal("the hook does not reach the worker") |
| 465 | } |
| 466 | } |
| 467 | |
| 468 | // listGate holds every List until released, so a test can act while the |
| 469 | // boot pass is part-way through. |
| 470 | type listGate struct { |
| 471 | release.Store |
| 472 | entered chan struct{} |
| 473 | proceed chan struct{} |
| 474 | once sync.Once |
| 475 | } |
| 476 | |
| 477 | func (g *listGate) List(ctx context.Context, prefix string) ([]string, error) { |
| 478 | g.once.Do(func() { close(g.entered) }) |
| 479 | <-g.proceed |
| 480 | return g.Store.List(ctx, prefix) |
| 481 | } |
| 482 | |
| 483 | // The boot pass deletes every .carlos-run directory (run spec §5.8), so no |
| 484 | // reconcile-driven pass may pick or launch anything before it has finished: |
| 485 | // a run it launched would have its directory swept from under it. |
| 486 | func TestRunsPassDoesNothingUntilTheBootPassHasFinished(t *testing.T) { |
| 487 | dir := release.NewDirStore(t.TempDir()) |
| 488 | rec, _ := queueRun(t, dir, time.Now().Add(time.Minute)) |
| 489 | gate := &listGate{Store: dir, entered: make(chan struct{}), proceed: make(chan struct{})} |
| 490 | var l execLog |
| 491 | w := testWorker(gate, runOwnedRoute(runHost), "box-1", "boot-1", &l) |
| 492 | w.booted.Store(false) |
| 493 | w.dataDir = t.TempDir() |
| 494 | stale := filepath.Join(provision.InstanceDir(w.dataDir, runHost), runs.DirName, "run_old") |
| 495 | if err := os.MkdirAll(stale, 0o700); err != nil { |
| 496 | t.Fatal(err) |
| 497 | } |
| 498 | armedRuns.Store(w) |
| 499 | t.Cleanup(func() { armedRuns.Store(nil) }) |
| 500 | |
| 501 | // Reconcile ran before the boot pass: the hook notes, the pass waits. |
| 502 | withRunsHook(provision.ReconcileConfig{}).RunSeen(rec) |
| 503 | runRunsPass(context.Background()) |
| 504 | if l.count() != 0 || currentRun(t, dir).State != provision.RunQueued { |
| 505 | t.Fatal("a pass picked a run before the boot pass ran") |
| 506 | } |
| 507 | |
| 508 | done := make(chan struct{}) |
| 509 | go func() { runRunsBootPass(context.Background()); close(done) }() |
| 510 | <-gate.entered // the sweep is done, the boot pass is reading records |
| 511 | if _, err := os.Stat(stale); !errors.Is(err, os.ErrNotExist) { |
| 512 | t.Fatalf("the boot pass did not sweep first: %v", err) |
| 513 | } |
| 514 | runRunsPass(context.Background()) |
| 515 | if l.count() != 0 || currentRun(t, dir).State != provision.RunQueued { |
| 516 | t.Fatal("a pass picked a run while the boot pass was still running") |
| 517 | } |
| 518 | close(gate.proceed) |
| 519 | <-done |
| 520 | if l.count() != 1 { |
| 521 | t.Fatalf("execs %d, want 1: the boot pass picks the run queued before start", l.count()) |
| 522 | } |
| 523 | } |
| 524 | |
| 525 | // A boot pass that cannot read the records still opens the gate: the runs |
| 526 | // it could not see are the next passes' to pick. |
| 527 | func TestRunsPassActsAfterABootPassThatFailed(t *testing.T) { |
| 528 | dir := release.NewDirStore(t.TempDir()) |
| 529 | rec, _ := queueRun(t, dir, time.Now().Add(time.Minute)) |
| 530 | var l execLog |
| 531 | w := testWorker(listFails{dir}, runOwnedRoute(runHost), "box-1", "boot-1", &l) |
| 532 | w.booted.Store(false) |
| 533 | w.dataDir = t.TempDir() |
| 534 | w.note(rec) |
| 535 | w.pass(context.Background()) |
| 536 | if l.count() != 0 { |
| 537 | t.Fatal("a pass acted before the boot pass") |
| 538 | } |
| 539 | w.bootPass(context.Background()) |
| 540 | w.pass(context.Background()) |
| 541 | if l.count() != 1 { |
| 542 | t.Fatalf("execs %d, want 1", l.count()) |
| 543 | } |
| 544 | } |
| 545 | |
| 546 | type listFails struct{ release.Store } |
| 547 | |
| 548 | func (listFails) List(context.Context, string) ([]string, error) { |
| 549 | return nil, errors.New("store down") |
| 550 | } |
| 551 | |