| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "reflect" |
| 11 | "slices" |
| 12 | "sort" |
| 13 | "strings" |
| 14 | "sync" |
| 15 | "testing" |
| 16 | "time" |
| 17 | |
| 18 | "github.com/carlosframework/platform/internal/activator" |
| 19 | "github.com/carlosframework/platform/internal/cgroup" |
| 20 | "github.com/carlosframework/platform/internal/domains" |
| 21 | "github.com/carlosframework/platform/internal/hostwrite" |
| 22 | "github.com/carlosframework/platform/internal/instancectl" |
| 23 | "github.com/carlosframework/platform/internal/layout" |
| 24 | "github.com/carlosframework/platform/internal/moves" |
| 25 | "github.com/carlosframework/platform/internal/provision" |
| 26 | "github.com/carlosframework/platform/internal/registry" |
| 27 | "github.com/carlosframework/platform/internal/release" |
| 28 | ) |
| 29 | |
| 30 | // The offline operator move's box side (Task 3 of |
| 31 | // docs/superpowers/plans/2026-10-20-offline-operator-move.md), tested |
| 32 | // through the functions the agent calls: newMovesWorker, armOffline, |
| 33 | // reconcileSerializedWith, the production edges-file writer and movesPass, |
| 34 | // over one DirStore, real registries and real activator services (the |
| 35 | // package's fake wake backend, which only the fence needs). |
| 36 | |
| 37 | const ( |
| 38 | offHost = "shop.example.com" |
| 39 | offID = "mv_zyxwvutsrqponmlkjihgfedcba" |
| 40 | offID2 = "mv_abcdefghijklmnopqrstuvwxy2" |
| 41 | ) |
| 42 | |
| 43 | type offWorld struct { |
| 44 | t *testing.T |
| 45 | s release.Store |
| 46 | acct string |
| 47 | mu sync.Mutex |
| 48 | now time.Time |
| 49 | } |
| 50 | |
| 51 | func (wd *offWorld) clock() time.Time { |
| 52 | wd.mu.Lock() |
| 53 | defer wd.mu.Unlock() |
| 54 | return wd.now |
| 55 | } |
| 56 | |
| 57 | func (wd *offWorld) advance(d time.Duration) { |
| 58 | wd.mu.Lock() |
| 59 | wd.now = wd.now.Add(d) |
| 60 | wd.mu.Unlock() |
| 61 | } |
| 62 | |
| 63 | // newOffWorld is a deployment with one account, one app and one instance |
| 64 | // record for offHost at the home placement (""). |
| 65 | func newOffWorld(t *testing.T) *offWorld { |
| 66 | t.Helper() |
| 67 | t.Setenv("CARLOS_HOST_CLAIM", "") // the appconfig and provision guards read the process env |
| 68 | ctx := context.Background() |
| 69 | wd := &offWorld{t: t, s: release.NewDirStore(t.TempDir()), acct: domains.Sqid(2), now: time.Date(2026, 10, 20, 9, 0, 0, 0, time.UTC)} |
| 70 | manifest, err := json.Marshal(map[string]any{"id": 2, "sqid": wd.acct, "name": "fixture", "created_at": wd.now}) |
| 71 | if err != nil { |
| 72 | t.Fatal(err) |
| 73 | } |
| 74 | if _, err := wd.s.Put(ctx, "accounts/"+wd.acct+"/account.json", manifest, ""); err != nil { |
| 75 | t.Fatal(err) |
| 76 | } |
| 77 | if err := provision.PutManifest(ctx, wd.s, wd.acct, "app", provision.Manifest{Schema: 1, App: "app", Domain: "example.com"}); err != nil { |
| 78 | t.Fatal(err) |
| 79 | } |
| 80 | wd.create(offHost) |
| 81 | return wd |
| 82 | } |
| 83 | |
| 84 | func (wd *offWorld) create(host string) { |
| 85 | wd.t.Helper() |
| 86 | rec := provision.Record{Schema: 1, Host: host, Slug: strings.SplitN(host, ".", 2)[0], Account: wd.acct, App: "app", Region: "", Channel: "stable", CreatedAt: wd.now} |
| 87 | if err := provision.CreateRecord(context.Background(), wd.s, rec); err != nil { |
| 88 | wd.t.Fatal(err) |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | // move applies one offline transition to offHost's record. |
| 93 | func (wd *offWorld) move(mut func(*provision.Record) error) provision.Record { |
| 94 | wd.t.Helper() |
| 95 | return wd.moveHost(offHost, mut) |
| 96 | } |
| 97 | |
| 98 | func (wd *offWorld) moveHost(host string, mut func(*provision.Record) error) provision.Record { |
| 99 | wd.t.Helper() |
| 100 | rec, err := provision.SetRecordMove(context.Background(), wd.s, wd.acct, "app", host, mut) |
| 101 | if err != nil { |
| 102 | wd.t.Fatal(err) |
| 103 | } |
| 104 | return *rec |
| 105 | } |
| 106 | |
| 107 | func (wd *offWorld) record() provision.Record { |
| 108 | wd.t.Helper() |
| 109 | rec, err := provision.GetRecord(context.Background(), wd.s, wd.acct, "app", offHost) |
| 110 | if err != nil { |
| 111 | wd.t.Fatal(err) |
| 112 | } |
| 113 | return *rec |
| 114 | } |
| 115 | |
| 116 | // The legal path to a state, from the record at rest. |
| 117 | func (wd *offWorld) start() { |
| 118 | wd.move(provision.OfflineStart(offID, "", "uk", "op@example.com", wd.clock())) |
| 119 | } |
| 120 | func (wd *offWorld) seal() { wd.move(provision.OfflineSeal(offID, "ie-a", []string{"ie-a", "ie-b"})) } |
| 121 | func (wd *offWorld) sealed() { |
| 122 | wd.move(provision.OfflineSealedBy(offID, "ie-a", provision.MoveSource{})) |
| 123 | } |
| 124 | func (wd *offWorld) flip() { wd.move(provision.OfflineFlip(offID)) } |
| 125 | func (wd *offWorld) landed() { wd.move(provision.OfflineLandedBy(offID, "uk")) } |
| 126 | func (wd *offWorld) open() { wd.move(provision.OfflineOpenTransition(offID, wd.clock())) } |
| 127 | func (wd *offWorld) finishing() { |
| 128 | wd.move(provision.OfflineFinishingTransition(offID)) |
| 129 | } |
| 130 | func (wd *offWorld) abort() { wd.move(provision.OfflineAbort(offID)) } |
| 131 | |
| 132 | // report reads one box's report for offID, or fails. |
| 133 | func (wd *offWorld) report(role, box string) moves.OfflineReport { |
| 134 | wd.t.Helper() |
| 135 | r, ok := wd.reportIf(role, box) |
| 136 | if !ok { |
| 137 | wd.t.Fatalf("no %s report from %s", role, box) |
| 138 | } |
| 139 | return r |
| 140 | } |
| 141 | |
| 142 | func (wd *offWorld) reportIf(role, box string) (moves.OfflineReport, bool) { |
| 143 | wd.t.Helper() |
| 144 | body, _, err := wd.s.Get(context.Background(), moves.OfflineReportKey(offHost, offID, role, box)) |
| 145 | if errors.Is(err, os.ErrNotExist) { |
| 146 | return moves.OfflineReport{}, false |
| 147 | } |
| 148 | if err != nil { |
| 149 | wd.t.Fatal(err) |
| 150 | } |
| 151 | var r moves.OfflineReport |
| 152 | if err := json.Unmarshal(body, &r); err != nil { |
| 153 | wd.t.Fatal(err) |
| 154 | } |
| 155 | if err := moves.ValidateOfflineReport(r); err != nil { |
| 156 | wd.t.Fatal(err) |
| 157 | } |
| 158 | return r, true |
| 159 | } |
| 160 | |
| 161 | // offBox is one box: its own data directory, registry, activator and moves |
| 162 | // worker, armed as armMovesPass arms it (armOffline). |
| 163 | type offBox struct { |
| 164 | wd *offWorld |
| 165 | name, region string |
| 166 | dd, opt, cg string |
| 167 | env map[string]string |
| 168 | reg *registry.Registry |
| 169 | svc *activator.Service |
| 170 | w *movesWorker |
| 171 | nested bool |
| 172 | last []byte |
| 173 | } |
| 174 | |
| 175 | func (wd *offWorld) box(name, region string, env map[string]string) *offBox { |
| 176 | wd.t.Helper() |
| 177 | b := &offBox{wd: wd, name: name, region: region, dd: wd.t.TempDir(), opt: wd.t.TempDir(), cg: wd.t.TempDir(), env: env} |
| 178 | reg, err := registry.Open(filepath.Join(b.dd, "registry.db")) |
| 179 | if err != nil { |
| 180 | wd.t.Fatal(err) |
| 181 | } |
| 182 | wd.t.Cleanup(func() { reg.Close() }) |
| 183 | b.reg = reg |
| 184 | b.restart() |
| 185 | return b |
| 186 | } |
| 187 | |
| 188 | func (b *offBox) getenv(k string) string { |
| 189 | if v, ok := b.env[k]; ok { |
| 190 | return v |
| 191 | } |
| 192 | if k == "CARLOS_HIBERNATE_BUCKET" { |
| 193 | return "data" |
| 194 | } |
| 195 | return "" |
| 196 | } |
| 197 | |
| 198 | // restart is an agent restart: a fresh activator (no fences) and a fresh |
| 199 | // worker over the same registry and data directory. |
| 200 | func (b *offBox) restart() { |
| 201 | b.svc = activator.New(activator.Config{Bucket: "data", Idle: time.Hour}, &fakeWakeBackend{}, b.reg) |
| 202 | cfg := moveCfg{Region: b.region, Box: b.name, DataDir: b.dd, OptDir: b.opt, Quiesced: b.svc.Quiesced, Route: b.reg.Get, Now: b.wd.clock} |
| 203 | armOffline(&cfg, b.dd, b.reg, b.svc, nil, hostwrite.Config{}, b.getenv) |
| 204 | // The stage-requested marker is root's business, and its throttled |
| 205 | // timer would outlive the test's directories. |
| 206 | cfg.Offline = newOfflineView(cfg, nil) |
| 207 | cfg.ReleaseHold = releaseHeld(b.reg, nil) |
| 208 | cfg.Procs = func(host string) (int, error) { return cgroup.HostProcs(b.cg, host, "") } |
| 209 | b.w = newMovesWorker(cfg) |
| 210 | } |
| 211 | |
| 212 | func (b *offBox) reconcile() { |
| 213 | b.wd.t.Helper() |
| 214 | cfg := provision.ReconcileConfig{Region: b.region, DataDir: b.dd} |
| 215 | if b.nested { |
| 216 | cfg.Layout = func(context.Context, release.Store) (string, error) { return release.LayoutModeNested, nil } |
| 217 | } else { |
| 218 | cfg.Layout = func(context.Context, release.Store) (string, error) { return release.LayoutModeFlat, nil } |
| 219 | } |
| 220 | if err := reconcileSerializedWith(context.Background(), b.wd.s, b.reg, cfg, b.w); err != nil { |
| 221 | b.wd.t.Fatal(err) |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | func (b *offBox) edges() { |
| 226 | b.wd.t.Helper() |
| 227 | if err := edgesFileWrite(context.Background(), b.wd.s, b.reg, edgesFile{Box: b.name, Placement: b.region}, "", &b.last, false); err != nil { |
| 228 | b.wd.t.Fatal(err) |
| 229 | } |
| 230 | } |
| 231 | |
| 232 | func (b *offBox) pass() { |
| 233 | b.wd.t.Helper() |
| 234 | if err := movesPass(context.Background(), b.wd.s, b.w); err != nil { |
| 235 | b.wd.t.Fatal(err) |
| 236 | } |
| 237 | } |
| 238 | |
| 239 | // tick is the agent's order on a serial bump: reconcile, the edges file, |
| 240 | // the moves pass. |
| 241 | func (b *offBox) tick() { |
| 242 | b.wd.t.Helper() |
| 243 | b.reconcile() |
| 244 | b.edges() |
| 245 | b.pass() |
| 246 | } |
| 247 | |
| 248 | func (b *offBox) row(host string) *registry.Route { |
| 249 | b.wd.t.Helper() |
| 250 | rt, err := b.reg.Get(host) |
| 251 | if err != nil { |
| 252 | b.wd.t.Fatal(err) |
| 253 | } |
| 254 | return rt |
| 255 | } |
| 256 | |
| 257 | // lists reports whether this box's published edges file lists host. |
| 258 | func (b *offBox) lists(host string) bool { |
| 259 | b.wd.t.Helper() |
| 260 | body, _, err := b.wd.s.Get(context.Background(), edgesFileKey(b.name)) |
| 261 | if err != nil { |
| 262 | b.wd.t.Fatal(err) |
| 263 | } |
| 264 | var f edgesFile |
| 265 | if err := json.Unmarshal(body, &f); err != nil { |
| 266 | b.wd.t.Fatal(err) |
| 267 | } |
| 268 | _, ok := f.Hosts[host] |
| 269 | return ok |
| 270 | } |
| 271 | |
| 272 | func writeFile(t *testing.T, p, body string) { |
| 273 | t.Helper() |
| 274 | if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { |
| 275 | t.Fatal(err) |
| 276 | } |
| 277 | if err := os.WriteFile(p, []byte(body), 0o644); err != nil { |
| 278 | t.Fatal(err) |
| 279 | } |
| 280 | } |
| 281 | |
| 282 | // --- the hold --------------------------------------------------------------- |
| 283 | |
| 284 | // offlineHold's table: a pure function of the record and the box's |
| 285 | // placement. The home's "" and a named region; every state. |
| 286 | func TestOfflineHoldIsAFunctionOfTheRecord(t *testing.T) { |
| 287 | home := moveCfg{Region: ""} |
| 288 | uk := moveCfg{Region: "uk"} |
| 289 | fr := moveCfg{Region: "fr"} |
| 290 | rec := func(region, state string) provision.Record { |
| 291 | return provision.Record{Host: offHost, Region: region, Move: &provision.MoveState{Kind: provision.MoveKindOffline, ID: offID, From: "", To: "uk", State: state}} |
| 292 | } |
| 293 | cases := []struct { |
| 294 | name string |
| 295 | box moveCfg |
| 296 | rec provision.Record |
| 297 | wantStatus, wantOp string |
| 298 | }{ |
| 299 | {"source stopping", home, rec("", provision.OfflineStopping), "moving", offID}, |
| 300 | {"source sealing", home, rec("", provision.OfflineSealing), "moving", offID}, |
| 301 | {"source sealed", home, rec("", provision.OfflineSealed), "moving", offID}, |
| 302 | {"source reverting", home, rec("", provision.OfflineReverting), "", ""}, |
| 303 | {"source after the flip", home, rec("uk", provision.OfflineLanding), "", ""}, |
| 304 | {"target before the flip", uk, rec("", provision.OfflineSealed), "", ""}, |
| 305 | {"target landing", uk, rec("uk", provision.OfflineLanding), "landing", offID}, |
| 306 | {"target landed", uk, rec("uk", provision.OfflineLanded), "landing", offID}, |
| 307 | {"target landing, region not yet the target's", uk, rec("", provision.OfflineLanding), "", ""}, |
| 308 | {"target open", uk, rec("uk", provision.OfflineOpen), "", ""}, |
| 309 | {"target finishing", uk, rec("uk", provision.OfflineFinishing), "", ""}, |
| 310 | {"target reverting", uk, rec("", provision.OfflineReverting), "", ""}, |
| 311 | {"a third region", fr, rec("", provision.OfflineStopping), "", ""}, |
| 312 | {"a third region after the flip", fr, rec("uk", provision.OfflineLanding), "", ""}, |
| 313 | } |
| 314 | for _, c := range cases { |
| 315 | t.Run(c.name, func(t *testing.T) { |
| 316 | status, op, ok := offlineHold(c.box)(c.rec) |
| 317 | if status != c.wantStatus || op != c.wantOp || ok != (c.wantStatus != "") { |
| 318 | t.Fatalf("hold = (%q, %q, %v), want (%q, %q)", status, op, ok, c.wantStatus, c.wantOp) |
| 319 | } |
| 320 | }) |
| 321 | } |
| 322 | // A record at rest, a legacy move and a Phase 1 move are never held. |
| 323 | for _, m := range []*provision.MoveState{nil, {To: "uk", State: provision.MoveCutting}, {ID: offID, From: "", To: "uk", State: provision.OfflineStopping}} { |
| 324 | if _, _, ok := offlineHold(home)(provision.Record{Host: offHost, Move: m}); ok { |
| 325 | t.Fatalf("held: %+v", m) |
| 326 | } |
| 327 | } |
| 328 | } |
| 329 | |
| 330 | // B1, the pinning test: a fresh target worker whose keep view has never |
| 331 | // seen the move (built from a list with no offline record in it, as a |
| 332 | // box's view is before the flip) mints the row held "landing" on its first |
| 333 | // reconcile after the flip. And with the view never built at all, reconcile |
| 334 | // through the offline hooks alone does the same. |
| 335 | func TestOfflineFreshTargetMintsTheLandingRowHeld(t *testing.T) { |
| 336 | wd := newOffWorld(t) |
| 337 | wd.start() |
| 338 | wd.seal() |
| 339 | wd.sealed() |
| 340 | wd.flip() |
| 341 | |
| 342 | uk := wd.box("uk-box", "uk", nil) |
| 343 | uk.w.cfg.Offline.refresh(nil, nil) // stale: no offline move in it |
| 344 | uk.reconcile() |
| 345 | rt := uk.row(offHost) |
| 346 | if rt == nil || rt.Status != "landing" || rt.HoldOp != offID { |
| 347 | t.Fatalf("after the flip the target minted %+v, want held landing by %s", rt, offID) |
| 348 | } |
| 349 | uk.edges() |
| 350 | if uk.lists(offHost) { |
| 351 | t.Fatal("the target's edges file lists a held landing row") |
| 352 | } |
| 353 | |
| 354 | // The hooks with a view that was never built, straight into |
| 355 | // provision.Reconcile: the hold needs no view. |
| 356 | fresh := wd.box("uk-box-2", "uk", nil) |
| 357 | if fresh.w.cfg.Offline.built() { |
| 358 | t.Fatal("fixture: the view is built") |
| 359 | } |
| 360 | cfg := withOfflineHolds(provision.ReconcileConfig{Region: "uk", DataDir: fresh.dd}, offlineHold(fresh.w.cfg), fresh.w.cfg.Offline) |
| 361 | if err := provision.Reconcile(context.Background(), wd.s, fresh.reg, cfg); err != nil { |
| 362 | t.Fatal(err) |
| 363 | } |
| 364 | if rt := fresh.row(offHost); rt == nil || rt.Status != "landing" || rt.HoldOp != offID { |
| 365 | t.Fatalf("an unbuilt view: minted %+v", rt) |
| 366 | } |
| 367 | } |
| 368 | |
| 369 | // --- the source boxes ------------------------------------------------------- |
| 370 | |
| 371 | // start holds the row on both source boxes (one minting it held, one |
| 372 | // holding the row it has, keeping its other columns), both report held, |
| 373 | // fenced and quiesced, and neither edges file lists the host. An agent |
| 374 | // restart re-fences and re-reports. After the flip the rows stay, kept. |
| 375 | func TestOfflineStartHoldsEverySourceRow(t *testing.T) { |
| 376 | wd := newOffWorld(t) |
| 377 | a := wd.box("ie-a", "", nil) |
| 378 | b := wd.box("ie-b", "", nil) |
| 379 | uk := wd.box("uk-box", "uk", nil) |
| 380 | a.tick() // ie-a serves the host before the move; ie-b has no row yet |
| 381 | if rt := a.row(offHost); rt == nil || rt.Status != "ready" || !a.lists(offHost) { |
| 382 | t.Fatalf("fixture: ie-a does not serve the host: %+v", rt) |
| 383 | } |
| 384 | if err := a.reg.SetReplication(offHost, "daily"); err != nil { |
| 385 | t.Fatal(err) |
| 386 | } |
| 387 | if err := a.reg.SetNextDue(offHost, 1234); err != nil { |
| 388 | t.Fatal(err) |
| 389 | } |
| 390 | before := *a.row(offHost) |
| 391 | |
| 392 | wd.start() |
| 393 | a.tick() |
| 394 | b.tick() |
| 395 | uk.tick() |
| 396 | for _, bx := range []*offBox{a, b} { |
| 397 | rt := bx.row(offHost) |
| 398 | if rt == nil || rt.Status != "moving" || rt.HoldOp != offID { |
| 399 | t.Fatalf("%s: row %+v, want held moving by the move", bx.name, rt) |
| 400 | } |
| 401 | if bx.lists(offHost) { |
| 402 | t.Fatalf("%s's edges file lists a held host", bx.name) |
| 403 | } |
| 404 | if !bx.svc.Fenced(offHost) { |
| 405 | t.Fatalf("%s: no wake fence", bx.name) |
| 406 | } |
| 407 | r := wd.report(moves.OfflineRoleSource, bx.name) |
| 408 | if !r.Held || !r.Fenced || !r.Quiesced || !r.RowPresent || r.Procs != 0 || r.LayoutJournal || r.Phase != moves.OfflinePhaseStopped || !r.At.Equal(wd.clock()) { |
| 409 | t.Fatalf("%s: report %+v", bx.name, r) |
| 410 | } |
| 411 | } |
| 412 | after := *a.row(offHost) |
| 413 | after.Status, after.HoldOp = before.Status, before.HoldOp |
| 414 | if !reflect.DeepEqual(after, before) { |
| 415 | t.Fatalf("holding the existing row changed its other columns:\n before %+v\n after %+v", before, after) |
| 416 | } |
| 417 | if uk.row(offHost) != nil { |
| 418 | t.Fatal("the target minted a row before the flip") |
| 419 | } |
| 420 | if _, ok := wd.reportIf(moves.OfflineRoleSource, "uk-box"); ok { |
| 421 | t.Fatal("the target wrote a source report") |
| 422 | } |
| 423 | |
| 424 | // An agent restart: the fence is gone with the process, and the next |
| 425 | // pass installs it again and rewrites the report. |
| 426 | a.restart() |
| 427 | if a.svc.Fenced(offHost) { |
| 428 | t.Fatal("fixture: a fresh activator is fenced") |
| 429 | } |
| 430 | wd.advance(time.Minute) |
| 431 | a.tick() |
| 432 | if !a.svc.Fenced(offHost) { |
| 433 | t.Fatal("after a restart the pass did not fence again") |
| 434 | } |
| 435 | if r := wd.report(moves.OfflineRoleSource, "ie-a"); !r.Fenced || !r.At.Equal(wd.clock()) { |
| 436 | t.Fatalf("after a restart: %+v", r) |
| 437 | } |
| 438 | |
| 439 | // The flip: the record names uk; both source rows stay, held, and keep |
| 440 | // reporting; the target mints its row held landing. |
| 441 | wd.seal() |
| 442 | wd.sealed() |
| 443 | wd.flip() |
| 444 | wd.advance(time.Minute) |
| 445 | for _, bx := range []*offBox{a, b, uk} { |
| 446 | bx.tick() |
| 447 | } |
| 448 | for _, bx := range []*offBox{a, b} { |
| 449 | if rt := bx.row(offHost); rt == nil || rt.Status != "moving" || rt.HoldOp != offID { |
| 450 | t.Fatalf("%s after the flip: %+v, want the row kept, held", bx.name, rt) |
| 451 | } |
| 452 | if r := wd.report(moves.OfflineRoleSource, bx.name); !r.Held || !r.Fenced || !r.At.Equal(wd.clock()) { |
| 453 | t.Fatalf("%s after the flip: %+v", bx.name, r) |
| 454 | } |
| 455 | } |
| 456 | if rt := uk.row(offHost); rt == nil || rt.Status != "landing" || rt.HoldOp != offID { |
| 457 | t.Fatalf("target after the flip: %+v", rt) |
| 458 | } |
| 459 | if uk.lists(offHost) { |
| 460 | t.Fatal("the target lists the landing row") |
| 461 | } |
| 462 | } |
| 463 | |
| 464 | // What the report reads from disk: a box with only a meta dir has local |
| 465 | // state; a process left in the host's cgroup and a layout journal show. |
| 466 | func TestOfflineSourceReportReadsTheBox(t *testing.T) { |
| 467 | wd := newOffWorld(t) |
| 468 | a := wd.box("ie-a", "", nil) |
| 469 | b := wd.box("ie-b", "", nil) |
| 470 | a.tick() |
| 471 | wd.start() |
| 472 | // ie-b has no DB file, only the activator's meta dir with one LTX file. |
| 473 | meta := activatorMetaDir(provision.DefaultDBPath(b.dd, offHost)) |
| 474 | writeFile(t, filepath.Join(meta, "ltx", "0", "0000000000000001-0000000000000007.ltx"), "x") |
| 475 | // ie-a: files, env, a process in the host's exec cgroup, a journal. |
| 476 | writeFile(t, filepath.Join(a.dd, offHost, "instance.key"), "k") |
| 477 | writeFile(t, filepath.Join(a.dd, offHost+".env"), "CARLOS_INSTANCE_KEY=k\n") |
| 478 | writeFile(t, filepath.Join(a.cg, cgroup.ExecName(offHost), "cgroup.procs"), "4242\n") |
| 479 | writeFile(t, provision.LayoutJournalPath(a.dd, offHost), "{}") |
| 480 | a.tick() |
| 481 | b.tick() |
| 482 | rb := wd.report(moves.OfflineRoleSource, "ie-b") |
| 483 | if !rb.LocalDB || rb.LocalTXID != 7 || rb.Files || rb.Env || rb.Sidecar { |
| 484 | t.Fatalf("a meta dir alone: %+v", rb) |
| 485 | } |
| 486 | ra := wd.report(moves.OfflineRoleSource, "ie-a") |
| 487 | if ra.LocalDB || !ra.Files || !ra.Env || ra.Procs != 1 || !ra.LayoutJournal { |
| 488 | t.Fatalf("ie-a: %+v", ra) |
| 489 | } |
| 490 | // A box that cannot count its processes reports -1, never 0. |
| 491 | a.w.cfg.Procs = func(string) (int, error) { return 0, cgroup.ErrUnknown } |
| 492 | a.pass() |
| 493 | if r := wd.report(moves.OfflineRoleSource, "ie-a"); r.Procs != -1 { |
| 494 | t.Fatalf("unknown processes reported as %d", r.Procs) |
| 495 | } |
| 496 | // A sidecar file shows. |
| 497 | writeFile(t, filepath.Join(b.dd, offHost+"-sidecar.env"), "X=1\n") |
| 498 | b.pass() |
| 499 | if r := wd.report(moves.OfflineRoleSource, "ie-b"); !r.Sidecar { |
| 500 | t.Fatalf("sidecar not seen: %+v", r) |
| 501 | } |
| 502 | } |
| 503 | |
| 504 | // offline-reverting, before and after the flip: every source row is |
| 505 | // released and served again, the fence is lifted, the boxes report |
| 506 | // reverted, and the next reconcile does not hold the rows again. |
| 507 | func TestOfflineRevertReleasesEverySourceRow(t *testing.T) { |
| 508 | for _, afterFlip := range []bool{false, true} { |
| 509 | t.Run(map[bool]string{false: "before the flip", true: "after the flip"}[afterFlip], func(t *testing.T) { |
| 510 | wd := newOffWorld(t) |
| 511 | a := wd.box("ie-a", "", nil) |
| 512 | b := wd.box("ie-b", "", nil) |
| 513 | uk := wd.box("uk-box", "uk", nil) |
| 514 | a.tick() |
| 515 | b.tick() |
| 516 | wd.start() |
| 517 | if afterFlip { |
| 518 | wd.seal() |
| 519 | wd.sealed() |
| 520 | wd.flip() |
| 521 | } |
| 522 | for _, bx := range []*offBox{a, b, uk} { |
| 523 | bx.tick() |
| 524 | } |
| 525 | wd.abort() |
| 526 | for i := 0; i < 2; i++ { |
| 527 | for _, bx := range []*offBox{a, b, uk} { |
| 528 | bx.tick() |
| 529 | } |
| 530 | } |
| 531 | for _, bx := range []*offBox{a, b} { |
| 532 | rt := bx.row(offHost) |
| 533 | if rt == nil || rt.Status != "ready" || rt.HoldOp != "" { |
| 534 | t.Fatalf("%s: %+v, want ready and unheld", bx.name, rt) |
| 535 | } |
| 536 | if !bx.lists(offHost) { |
| 537 | t.Fatalf("%s does not serve the host again", bx.name) |
| 538 | } |
| 539 | if bx.svc.Fenced(offHost) { |
| 540 | t.Fatalf("%s: the fence is still up", bx.name) |
| 541 | } |
| 542 | if r := wd.report(moves.OfflineRoleSource, bx.name); r.Phase != moves.OfflinePhaseReverted || r.Held { |
| 543 | t.Fatalf("%s: %+v", bx.name, r) |
| 544 | } |
| 545 | } |
| 546 | // The move finishes; nothing holds the rows again. |
| 547 | wd.move(provision.OfflineFinish(offID, wd.clock())) |
| 548 | for _, bx := range []*offBox{a, b} { |
| 549 | bx.tick() |
| 550 | if rt := bx.row(offHost); rt == nil || rt.Status != "ready" || rt.HoldOp != "" { |
| 551 | t.Fatalf("%s after finish: %+v", bx.name, rt) |
| 552 | } |
| 553 | } |
| 554 | }) |
| 555 | } |
| 556 | } |
| 557 | |
| 558 | // offline-open on a flat source: every path of the source-retire row that |
| 559 | // is there is renamed into the park directory byte for byte, nothing is |
| 560 | // deleted, the fence is lifted, and the parked report lists every path with |
| 561 | // its action. A process left in the host's cgroup stops the park first. |
| 562 | // offline-finishing then removes the row and the box reports gone. |
| 563 | func TestOfflineOpenParksTheFlatSource(t *testing.T) { |
| 564 | wd := newOffWorld(t) |
| 565 | a := wd.box("ie-a", "", nil) |
| 566 | uk := wd.box("uk-box", "uk", nil) |
| 567 | a.tick() |
| 568 | db := a.row(offHost).DBPath |
| 569 | contents := map[string]string{ |
| 570 | db: "sqlite", |
| 571 | db + "-wal": "wal", |
| 572 | filepath.Join(activatorMetaDir(db), "ltx", "0", "0000000000000001-0000000000000003.ltx"): "ltx", |
| 573 | filepath.Join(a.dd, offHost, "instance.key"): "key", |
| 574 | filepath.Join(a.dd, offHost+".env"): "CARLOS_INSTANCE_KEY=k\n", |
| 575 | filepath.Join(a.dd, offHost+".config.env"): "SECRET=s\n", |
| 576 | filepath.Join(a.dd, "programs", offHost+".json"): `{"programs":[]}`, |
| 577 | filepath.Join(a.opt, "live", offHost, "app"): "binary", |
| 578 | filepath.Join(a.dd, "neighbour.example.com.db"): "not ours", |
| 579 | } |
| 580 | for p, body := range contents { |
| 581 | writeFile(t, p, body) |
| 582 | } |
| 583 | wd.start() |
| 584 | a.tick() // the source reports before the seal, as the seal door requires |
| 585 | wd.seal() |
| 586 | wd.sealed() |
| 587 | wd.flip() |
| 588 | wd.landed() |
| 589 | a.tick() |
| 590 | uk.tick() |
| 591 | wd.open() |
| 592 | |
| 593 | // A process still in the host's cgroup: nothing is renamed. |
| 594 | procs := filepath.Join(a.cg, cgroup.ExecName(offHost), "cgroup.procs") |
| 595 | writeFile(t, procs, "4242\n") |
| 596 | a.tick() |
| 597 | if _, err := os.Stat(db); err != nil { |
| 598 | t.Fatalf("parked with a process left: %v", err) |
| 599 | } |
| 600 | if _, ok := wd.reportIf(moves.OfflineRoleSource, "ie-a"); !ok { |
| 601 | t.Fatal("fixture: no report") |
| 602 | } |
| 603 | if r := wd.report(moves.OfflineRoleSource, "ie-a"); r.Phase == moves.OfflinePhaseParked { |
| 604 | t.Fatalf("reported parked with a process left: %+v", r) |
| 605 | } |
| 606 | writeFile(t, procs, "") |
| 607 | |
| 608 | a.tick() |
| 609 | park := moves.OfflineParkDir(a.dd, offHost, offID, moves.OfflineRoleSource) |
| 610 | for p, body := range contents { |
| 611 | switch { |
| 612 | case strings.HasPrefix(p, a.opt), strings.Contains(p, "neighbour"): |
| 613 | if got, err := os.ReadFile(p); err != nil || string(got) != body { |
| 614 | t.Fatalf("%s was touched: %q %v", p, got, err) |
| 615 | } |
| 616 | continue |
| 617 | } |
| 618 | if _, err := os.Lstat(p); !errors.Is(err, os.ErrNotExist) { |
| 619 | t.Fatalf("%s is still in place: %v", p, err) |
| 620 | } |
| 621 | } |
| 622 | for rel, body := range map[string]string{ |
| 623 | filepath.Base(db): "sqlite", |
| 624 | filepath.Base(db) + "-wal": "wal", |
| 625 | filepath.Join(filepath.Base(activatorMetaDir(db)), "ltx", "0", "0000000000000001-0000000000000003.ltx"): "ltx", |
| 626 | filepath.Join(offHost, "instance.key"): "key", |
| 627 | offHost + ".env": "CARLOS_INSTANCE_KEY=k\n", |
| 628 | offHost + ".config.env": "SECRET=s\n", |
| 629 | offHost + ".json": `{"programs":[]}`, |
| 630 | } { |
| 631 | if got, err := os.ReadFile(filepath.Join(park, rel)); err != nil || string(got) != body { |
| 632 | t.Fatalf("park/%s = %q %v, want %q", rel, got, err, body) |
| 633 | } |
| 634 | } |
| 635 | r := wd.report(moves.OfflineRoleSource, "ie-a") |
| 636 | if r.Phase != moves.OfflinePhaseParked || r.LocalDB || r.Files || r.Env || r.Fenced { |
| 637 | t.Fatalf("parked report: %+v", r) |
| 638 | } |
| 639 | if a.svc.Fenced(offHost) { |
| 640 | t.Fatal("the fence is still up after the park") |
| 641 | } |
| 642 | want := map[string]string{ |
| 643 | db: moves.RetiredParked, db + "-wal": moves.RetiredParked, db + "-shm": moves.RetiredAbsent, |
| 644 | db + "-litestream": moves.RetiredAbsent, activatorMetaDir(db): moves.RetiredParked, |
| 645 | filepath.Join(a.dd, offHost): moves.RetiredParked, filepath.Join(a.dd, offHost+".env"): moves.RetiredParked, |
| 646 | filepath.Join(a.dd, offHost+".config.env"): moves.RetiredParked, filepath.Join(a.dd, "programs", offHost+".json"): moves.RetiredParked, |
| 647 | filepath.Join(a.opt, "live", offHost): moves.RetiredLeft, |
| 648 | } |
| 649 | assertRetired(t, r.Retired, want) |
| 650 | |
| 651 | // A second pass changes nothing and reports the same inventory. |
| 652 | before := r.Retired |
| 653 | a.tick() |
| 654 | if r := wd.report(moves.OfflineRoleSource, "ie-a"); !reflect.DeepEqual(r.Retired, before) || r.Phase != moves.OfflinePhaseParked { |
| 655 | t.Fatalf("second pass: %+v", r.Retired) |
| 656 | } |
| 657 | // The row stays held through open. |
| 658 | if rt := a.row(offHost); rt == nil || rt.HoldOp != offID { |
| 659 | t.Fatalf("row at open: %+v", rt) |
| 660 | } |
| 661 | |
| 662 | // finishing: keep no longer holds the row; reconcile removes it and |
| 663 | // the box reports gone. The parked state stays. |
| 664 | // The view the reconcile reads is the last moves pass's, so the row |
| 665 | // goes on the pass after the one that first sees finishing. |
| 666 | wd.finishing() |
| 667 | a.tick() |
| 668 | a.tick() |
| 669 | if rt := a.row(offHost); rt != nil { |
| 670 | t.Fatalf("row at finishing: %+v", rt) |
| 671 | } |
| 672 | a.pass() |
| 673 | if r := wd.report(moves.OfflineRoleSource, "ie-a"); r.Phase != moves.OfflinePhaseGone || r.RowPresent || r.LocalDB { |
| 674 | t.Fatalf("gone report: %+v", r) |
| 675 | } |
| 676 | if got, err := os.ReadFile(filepath.Join(park, filepath.Base(db))); err != nil || string(got) != "sqlite" { |
| 677 | t.Fatalf("the parked DB: %q %v", got, err) |
| 678 | } |
| 679 | } |
| 680 | |
| 681 | func assertRetired(t *testing.T, got []moves.RetiredPath, want map[string]string) { |
| 682 | t.Helper() |
| 683 | seen := map[string]string{} |
| 684 | for _, rp := range got { |
| 685 | if _, dup := seen[rp.Path]; dup { |
| 686 | t.Fatalf("%s listed twice", rp.Path) |
| 687 | } |
| 688 | seen[rp.Path] = rp.Action |
| 689 | if rp.Action == moves.RetiredLeft && rp.Reason == "" { |
| 690 | t.Fatalf("%s left with no reason", rp.Path) |
| 691 | } |
| 692 | } |
| 693 | for p, action := range want { |
| 694 | if seen[p] != action { |
| 695 | t.Fatalf("%s: %q, want %q (inventory %+v)", p, seen[p], action, got) |
| 696 | } |
| 697 | } |
| 698 | if len(seen) != len(want) { |
| 699 | var extra []string |
| 700 | for p := range seen { |
| 701 | if _, ok := want[p]; !ok { |
| 702 | extra = append(extra, p+"="+seen[p]) |
| 703 | } |
| 704 | } |
| 705 | sort.Strings(extra) |
| 706 | t.Fatalf("unexpected entries: %v", extra) |
| 707 | } |
| 708 | } |
| 709 | |
| 710 | // M3: a path the agent can never rename (here a file in a directory it may |
| 711 | // not write, as a root-owned /data/programs is) is reported left once, with |
| 712 | // its reason, and is not tried again: later passes, an agent restart |
| 713 | // included, neither fence nor rename, and the report stays parked. |
| 714 | func TestOfflineParkLeavesWhatCannotBeRenamedOnce(t *testing.T) { |
| 715 | if os.Geteuid() == 0 { |
| 716 | t.Skip("root can rename anything") |
| 717 | } |
| 718 | wd := newOffWorld(t) |
| 719 | a := wd.box("ie-a", "", nil) |
| 720 | a.tick() |
| 721 | db := a.row(offHost).DBPath |
| 722 | writeFile(t, db, "sqlite") |
| 723 | programs := filepath.Join(a.dd, "programs") |
| 724 | writeFile(t, filepath.Join(programs, offHost+".json"), "{}") |
| 725 | if err := os.Chmod(programs, 0o555); err != nil { |
| 726 | t.Fatal(err) |
| 727 | } |
| 728 | t.Cleanup(func() { os.Chmod(programs, 0o755) }) |
| 729 | fences := 0 |
| 730 | count := func() { |
| 731 | inner := a.w.cfg.FenceAs |
| 732 | a.w.cfg.FenceAs = func(ctx context.Context, host string, gen uint64, owner string) error { |
| 733 | fences++ |
| 734 | return inner(ctx, host, gen, owner) |
| 735 | } |
| 736 | } |
| 737 | wd.start() |
| 738 | a.tick() |
| 739 | wd.seal() |
| 740 | wd.sealed() |
| 741 | wd.flip() |
| 742 | wd.landed() |
| 743 | a.tick() |
| 744 | wd.open() |
| 745 | count() |
| 746 | before := fences |
| 747 | a.tick() |
| 748 | if fences != before+1 { |
| 749 | t.Fatalf("the first park fenced %d times", fences-before) |
| 750 | } |
| 751 | r := wd.report(moves.OfflineRoleSource, "ie-a") |
| 752 | if r.Phase != moves.OfflinePhaseParked { |
| 753 | t.Fatalf("phase %s", r.Phase) |
| 754 | } |
| 755 | var left moves.RetiredPath |
| 756 | for _, rp := range r.Retired { |
| 757 | if rp.Path == filepath.Join(programs, offHost+".json") { |
| 758 | left = rp |
| 759 | } |
| 760 | if rp.Path == db && rp.Action != moves.RetiredParked { |
| 761 | t.Fatalf("the DB: %+v", rp) |
| 762 | } |
| 763 | } |
| 764 | if left.Action != moves.RetiredLeft || !strings.HasPrefix(left.Reason, leftCannot) { |
| 765 | t.Fatalf("the unrenameable path: %+v", left) |
| 766 | } |
| 767 | for i := 0; i < 2; i++ { |
| 768 | before = fences |
| 769 | a.tick() |
| 770 | if fences != before { |
| 771 | t.Fatal("a later pass fenced again for a path it cannot rename") |
| 772 | } |
| 773 | if again := wd.report(moves.OfflineRoleSource, "ie-a"); again.Phase != moves.OfflinePhaseParked || !reflect.DeepEqual(again.Retired, r.Retired) { |
| 774 | t.Fatalf("pass %d: %+v", i, again) |
| 775 | } |
| 776 | } |
| 777 | a.restart() |
| 778 | count() |
| 779 | before = fences |
| 780 | a.tick() |
| 781 | if fences != before || a.svc.Fenced(offHost) { |
| 782 | t.Fatal("after a restart the pass fenced again for a path it cannot rename") |
| 783 | } |
| 784 | if again := wd.report(moves.OfflineRoleSource, "ie-a"); !reflect.DeepEqual(again.Retired, r.Retired) { |
| 785 | t.Fatalf("after a restart: %+v", again.Retired) |
| 786 | } |
| 787 | if _, err := os.Stat(filepath.Join(programs, offHost+".json")); err != nil { |
| 788 | t.Fatalf("the left path: %v", err) |
| 789 | } |
| 790 | } |
| 791 | |
| 792 | // offline-open on a nested source: the contents of the raw instance |
| 793 | // directory are renamed into the park directory, the directory and its |
| 794 | // view stay, empty, and the inventory names each path. |
| 795 | func TestOfflineOpenParksTheNestedSource(t *testing.T) { |
| 796 | wd := newOffWorld(t) |
| 797 | a := wd.box("ie-a", "", nil) |
| 798 | a.nested = true |
| 799 | raw := layout.RawInstanceDir(a.dd, offHost) |
| 800 | if err := os.MkdirAll(raw, 0o700); err != nil { |
| 801 | t.Fatal(err) |
| 802 | } |
| 803 | // The bind mount at <dd>/instances, as a link in the fixture. |
| 804 | if err := os.Symlink(filepath.Base(filepath.Dir(raw)), filepath.Join(a.dd, "instances")); err != nil { |
| 805 | t.Fatal(err) |
| 806 | } |
| 807 | a.tick() |
| 808 | rt := a.row(offHost) |
| 809 | if rt == nil || !provision.NestedPaths(a.dd, *rt) { |
| 810 | t.Fatalf("fixture: not a nested row: %+v", rt) |
| 811 | } |
| 812 | contents := map[string]string{ |
| 813 | offHost + ".db": "sqlite", |
| 814 | offHost + ".db-shm": "shm", |
| 815 | offHost + ".env": "CARLOS_INSTANCE_KEY=k\n", |
| 816 | "instance.key": "key", |
| 817 | "uploads/a.png": "png", |
| 818 | } |
| 819 | for rel, body := range contents { |
| 820 | writeFile(t, filepath.Join(raw, rel), body) |
| 821 | } |
| 822 | wd.start() |
| 823 | a.tick() |
| 824 | if r := wd.report(moves.OfflineRoleSource, "ie-a"); !r.LocalDB || !r.Files || !r.Env { |
| 825 | t.Fatalf("nested report: %+v", r) |
| 826 | } |
| 827 | wd.seal() |
| 828 | wd.sealed() |
| 829 | wd.flip() |
| 830 | wd.landed() |
| 831 | wd.open() |
| 832 | a.tick() |
| 833 | park := moves.OfflineParkDir(a.dd, offHost, offID, moves.OfflineRoleSource) |
| 834 | for rel, body := range contents { |
| 835 | if got, err := os.ReadFile(filepath.Join(park, nestedParkPrefix, rel)); err != nil || string(got) != body { |
| 836 | t.Fatalf("park/instance/%s = %q %v", rel, got, err) |
| 837 | } |
| 838 | } |
| 839 | ents, err := os.ReadDir(raw) |
| 840 | if err != nil || len(ents) != 0 { |
| 841 | t.Fatalf("the instance directory: %v %v, want it there and empty", ents, err) |
| 842 | } |
| 843 | r := wd.report(moves.OfflineRoleSource, "ie-a") |
| 844 | if r.Phase != moves.OfflinePhaseParked || r.LocalDB || r.Files || r.Env { |
| 845 | t.Fatalf("parked report: %+v", r) |
| 846 | } |
| 847 | want := map[string]string{ |
| 848 | filepath.Join(raw, offHost+".db"): moves.RetiredParked, |
| 849 | filepath.Join(raw, offHost+".db-wal"): moves.RetiredAbsent, |
| 850 | filepath.Join(raw, offHost+".db-shm"): moves.RetiredParked, |
| 851 | filepath.Join(raw, offHost+".db-litestream"): moves.RetiredAbsent, |
| 852 | filepath.Join(raw, ".activator-"+offHost+".db-litestream"): moves.RetiredAbsent, |
| 853 | filepath.Join(raw, offHost+".env"): moves.RetiredParked, |
| 854 | filepath.Join(raw, offHost+".config.env"): moves.RetiredAbsent, |
| 855 | filepath.Join(raw, "instance.key"): moves.RetiredParked, |
| 856 | filepath.Join(raw, "uploads"): moves.RetiredParked, |
| 857 | filepath.Join(a.dd, "programs", offHost+".json"): moves.RetiredAbsent, |
| 858 | provision.InstanceDir(a.dd, offHost): moves.RetiredLeft, |
| 859 | raw: moves.RetiredLeft, |
| 860 | filepath.Join(a.opt, "live", offHost): moves.RetiredAbsent, |
| 861 | } |
| 862 | assertRetired(t, r.Retired, want) |
| 863 | // Re-run: the same inventory, from what is parked. |
| 864 | a.tick() |
| 865 | if again := wd.report(moves.OfflineRoleSource, "ie-a"); !reflect.DeepEqual(again.Retired, r.Retired) { |
| 866 | t.Fatalf("second pass:\n %+v\n %+v", again.Retired, r.Retired) |
| 867 | } |
| 868 | } |
| 869 | |
| 870 | // --- the dispatcher and the capability -------------------------------------- |
| 871 | |
| 872 | // An offline record reaches offlineRound with Phase 1 off, ahead of the |
| 873 | // Phase 1 skip and the lost-box gate: no host claim is read. A |
| 874 | // Phase-1-shaped record with Phase 1 off is still logged and skipped. |
| 875 | func TestOfflineDispatchPrecedesThePhase1Skip(t *testing.T) { |
| 876 | wd := newOffWorld(t) |
| 877 | wd.create("other.example.com") |
| 878 | a := wd.box("ie-a", "", nil) |
| 879 | a.tick() |
| 880 | wd.start() |
| 881 | // A Phase 1 move on the other host (its id is what makes it Phase 1). |
| 882 | if _, err := provision.SetRecordMove(context.Background(), wd.s, wd.acct, "app", "other.example.com", func(rec *provision.Record) error { |
| 883 | rec.Move = &provision.MoveState{ID: offID2, From: "", To: "uk", State: provision.MovePreparing, RequestedAt: wd.clock()} |
| 884 | return nil |
| 885 | }); err != nil { |
| 886 | t.Fatal(err) |
| 887 | } |
| 888 | a.tick() |
| 889 | if _, ok := wd.reportIf(moves.OfflineRoleSource, "ie-a"); !ok { |
| 890 | t.Fatal("the offline record never reached offlineRound") |
| 891 | } |
| 892 | if a.w.lost != nil { |
| 893 | t.Fatalf("the lost-box gate ran: %v", a.w.lost) |
| 894 | } |
| 895 | if _, ok := a.w.notices.Load("other.example.com#switch"); !ok { |
| 896 | t.Fatal("the Phase 1 record was not logged as waiting on the switch") |
| 897 | } |
| 898 | if rt := a.row("other.example.com"); rt == nil || rt.HoldOp != "" || rt.Status != "ready" { |
| 899 | t.Fatalf("the Phase 1 record's row was touched: %+v", rt) |
| 900 | } |
| 901 | keys, err := wd.s.List(context.Background(), "control/offline-moves/other.example.com/") |
| 902 | if err != nil || len(keys) != 0 { |
| 903 | t.Fatalf("the Phase 1 record was reported as offline: %v %v", keys, err) |
| 904 | } |
| 905 | } |
| 906 | |
| 907 | // The capability is the box's own witness: published with hibernation |
| 908 | // configured and both switches unset, withheld otherwise. A box without it |
| 909 | // runs no offline step and installs no offline hook: its row is never |
| 910 | // held, nothing is reported, and one notice is logged. The guard mode is |
| 911 | // not an input (platform-1e's ruling of 2026-10-20). |
| 912 | func TestOfflineCapabilityAndAnIncapableBox(t *testing.T) { |
| 913 | env := func(kv ...string) func(string) string { |
| 914 | m := map[string]string{"CARLOS_HIBERNATE_BUCKET": "data"} |
| 915 | for i := 0; i+1 < len(kv); i += 2 { |
| 916 | m[kv[i]] = kv[i+1] |
| 917 | } |
| 918 | return func(k string) string { return m[k] } |
| 919 | } |
| 920 | // The published capability is the armed worker's own answer (M5). |
| 921 | prev := armedMovesWorker.Load() |
| 922 | t.Cleanup(func() { armedMovesWorker.Store(prev) }) |
| 923 | armedMovesWorker.Store(nil) |
| 924 | if armedOfflineCapable() { |
| 925 | t.Fatal("capable with no worker armed") |
| 926 | } |
| 927 | if got := withOfflineCapability(edgesFileCapabilities(env()), armedOfflineCapable()); len(got) != 0 { |
| 928 | t.Fatalf("no worker armed: %v", got) |
| 929 | } |
| 930 | wdCap := newOffWorld(t) |
| 931 | armed := wdCap.box("armed", "", nil) |
| 932 | armedMovesWorker.Store(armed.w) |
| 933 | if !armedOfflineCapable() { |
| 934 | t.Fatal("a capable armed worker does not publish") |
| 935 | } |
| 936 | if got := withOfflineCapability(edgesFileCapabilities(env()), armedOfflineCapable()); !slices.Equal(got, []string{capOfflineMove1, capOfflineStage1}) { |
| 937 | t.Fatalf("capable box: %v", got) |
| 938 | } |
| 939 | base := edgesFileCapabilities(env("CARLOS_REGION_HA", "1")) |
| 940 | if got := withOfflineCapability(base, true); !slices.Equal(got, []string{capRegionHA1, capOfflineMove1, capOfflineStage1}) || !slices.Equal(base, []string{capRegionHA1}) { |
| 941 | t.Fatalf("region HA too: %v (base %v)", got, base) |
| 942 | } |
| 943 | for _, kv := range [][]string{ |
| 944 | {"CARLOS_HIBERNATE_BUCKET", ""}, |
| 945 | {"CARLOS_HOST_CLAIM", "1"}, |
| 946 | {"CARLOS_HOST_CLAIM", "0"}, // set at all |
| 947 | {moves.SwitchEnv, "on"}, |
| 948 | {moves.SwitchEnv, "off"}, // set at all |
| 949 | } { |
| 950 | if offlineCapable(env(kv...), hibernationConfigured(env(kv...))) { |
| 951 | t.Fatalf("%s=%q is capable", kv[0], kv[1]) |
| 952 | } |
| 953 | incapable := wdCap.box("incapable", "", map[string]string{kv[0]: kv[1]}) |
| 954 | armedMovesWorker.Store(incapable.w) |
| 955 | if got := withOfflineCapability(edgesFileCapabilities(env(kv...)), armedOfflineCapable()); slices.Contains(got, capOfflineMove1) || slices.Contains(got, capOfflineStage1) { |
| 956 | t.Fatalf("%s=%q publishes: %v", kv[0], kv[1], got) |
| 957 | } |
| 958 | } |
| 959 | armedMovesWorker.Store(prev) |
| 960 | |
| 961 | wd := newOffWorld(t) |
| 962 | a := wd.box("ie-a", "", map[string]string{"CARLOS_HOST_CLAIM": "1"}) |
| 963 | if a.w.cfg.OfflineCapable { |
| 964 | t.Fatal("armOffline: a claims box is capable") |
| 965 | } |
| 966 | a.tick() |
| 967 | wd.start() |
| 968 | a.tick() |
| 969 | if rt := a.row(offHost); rt == nil || rt.Status != "ready" || rt.HoldOp != "" { |
| 970 | t.Fatalf("an incapable box held the row: %+v", rt) |
| 971 | } |
| 972 | if a.svc.Fenced(offHost) { |
| 973 | t.Fatal("an incapable box fenced") |
| 974 | } |
| 975 | if _, ok := wd.reportIf(moves.OfflineRoleSource, "ie-a"); ok { |
| 976 | t.Fatal("an incapable box reported") |
| 977 | } |
| 978 | if _, ok := a.w.notices.Load(offHost + "#offline"); !ok { |
| 979 | t.Fatal("no notice from an incapable box") |
| 980 | } |
| 981 | } |
| 982 | |
| 983 | // --- dormant --------------------------------------------------------------- |
| 984 | |
| 985 | // flatLayout is a reconcile config's layout read, always flat. |
| 986 | func flatLayout(context.Context, release.Store) (string, error) { return release.LayoutModeFlat, nil } |
| 987 | |
| 988 | // dormantWorld is a deployment with no offline record anywhere: offHost at |
| 989 | // rest, gone.example.com (whose record the test deletes), a legacy move and |
| 990 | // a Phase 1 move on records of their own. |
| 991 | func dormantWorld(t *testing.T) *offWorld { |
| 992 | t.Helper() |
| 993 | wd := newOffWorld(t) |
| 994 | for _, h := range []string{"gone.example.com", "legacy.example.com", "phase1.example.com"} { |
| 995 | wd.create(h) |
| 996 | } |
| 997 | wd.moveHost("legacy.example.com", func(rec *provision.Record) error { |
| 998 | rec.Move = &provision.MoveState{To: "uk", State: provision.MovePreparing} |
| 999 | return nil |
| 1000 | }) |
| 1001 | wd.moveHost("phase1.example.com", func(rec *provision.Record) error { |
| 1002 | rec.Move = &provision.MoveState{ID: offID2, From: "", To: "uk", State: provision.MovePreparing, RequestedAt: wd.clock()} |
| 1003 | return nil |
| 1004 | }) |
| 1005 | return wd |
| 1006 | } |
| 1007 | |
| 1008 | // dormantBoxes are two home boxes minted from the same records with no |
| 1009 | // hooks at all, so their registries start equal: the plain one stands for |
| 1010 | // main, the hooked one has a capable worker whose view no listing has read. |
| 1011 | func dormantBoxes(t *testing.T, wd *offWorld) (plain, hooked *offBox) { |
| 1012 | t.Helper() |
| 1013 | plain, hooked = wd.box("main", "", nil), wd.box("hooked", "", nil) |
| 1014 | for _, b := range []*offBox{plain, hooked} { |
| 1015 | if err := reconcileSerializedWith(context.Background(), wd.s, b.reg, provision.ReconcileConfig{DataDir: b.dd, Layout: flatLayout}, nil); err != nil { |
| 1016 | t.Fatal(err) |
| 1017 | } |
| 1018 | if b.row("gone.example.com") == nil { |
| 1019 | t.Fatalf("fixture: %s has no row to retire", b.name) |
| 1020 | } |
| 1021 | } |
| 1022 | if hooked.w.cfg.Offline.built() || !hooked.w.cfg.OfflineCapable { |
| 1023 | t.Fatal("fixture: the hooked box's view is built, or the box is not capable") |
| 1024 | } |
| 1025 | return plain, hooked |
| 1026 | } |
| 1027 | |
| 1028 | // sameRegistries fails unless the two boxes' registries match, paths and |
| 1029 | // creation times aside (each box has its own data directory and clock). |
| 1030 | func sameRegistries(t *testing.T, a, b *offBox) { |
| 1031 | t.Helper() |
| 1032 | strip := func(rs []registry.Route) []registry.Route { |
| 1033 | out := append([]registry.Route(nil), rs...) |
| 1034 | for i := range out { |
| 1035 | out[i].DBPath, out[i].Socket, out[i].CreatedAt = "", "", 0 |
| 1036 | } |
| 1037 | return out |
| 1038 | } |
| 1039 | ar, err := a.reg.List() |
| 1040 | if err != nil { |
| 1041 | t.Fatal(err) |
| 1042 | } |
| 1043 | br, err := b.reg.List() |
| 1044 | if err != nil { |
| 1045 | t.Fatal(err) |
| 1046 | } |
| 1047 | if !reflect.DeepEqual(strip(ar), strip(br)) { |
| 1048 | t.Fatalf("the registries differ:\n %s %+v\n %s %+v", a.name, ar, b.name, br) |
| 1049 | } |
| 1050 | } |
| 1051 | |
| 1052 | // With no offline record anywhere (a legacy and a Phase 1 move included): |
| 1053 | // the very first reconcile on a box whose view no listing has read removes |
| 1054 | // a retired row exactly as main does; after it the hold answers false for |
| 1055 | // every record and keep false for every host; and the edges file differs |
| 1056 | // by the one capability string and nothing else. |
| 1057 | func TestOfflineDormantIsMain(t *testing.T) { |
| 1058 | ctx := context.Background() |
| 1059 | wd := dormantWorld(t) |
| 1060 | plain, hooked := dormantBoxes(t, wd) |
| 1061 | if err := wd.s.Delete(ctx, provision.RecordKey(wd.acct, "app", "gone.example.com")); err != nil { |
| 1062 | t.Fatal(err) |
| 1063 | } |
| 1064 | for _, b := range []*offBox{plain, hooked} { |
| 1065 | w := b.w |
| 1066 | if b == plain { |
| 1067 | w = nil |
| 1068 | } |
| 1069 | if err := reconcileSerializedWith(ctx, wd.s, b.reg, provision.ReconcileConfig{DataDir: b.dd, Layout: flatLayout}, w); err != nil { |
| 1070 | t.Fatal(err) |
| 1071 | } |
| 1072 | if rt := b.row("gone.example.com"); rt != nil { |
| 1073 | t.Fatalf("%s kept a retired row on its first pass: %+v", b.name, rt) |
| 1074 | } |
| 1075 | } |
| 1076 | sameRegistries(t, plain, hooked) |
| 1077 | v := hooked.w.cfg.Offline |
| 1078 | if !v.built() || v.failed() { |
| 1079 | t.Fatal("the first reconcile did not read the view") |
| 1080 | } |
| 1081 | for _, h := range []string{offHost, "gone.example.com", "legacy.example.com", "phase1.example.com", "never.example.com"} { |
| 1082 | if v.keep(h) { |
| 1083 | t.Fatalf("keep(%s)", h) |
| 1084 | } |
| 1085 | } |
| 1086 | recs, err := provision.ListAllRecords(ctx, wd.s) |
| 1087 | if err != nil { |
| 1088 | t.Fatal(err) |
| 1089 | } |
| 1090 | if len(recs) != 3 { |
| 1091 | t.Fatalf("fixture: %d records", len(recs)) |
| 1092 | } |
| 1093 | for _, rec := range recs { |
| 1094 | if _, _, ok := offlineHold(hooked.w.cfg)(rec); ok { |
| 1095 | t.Fatalf("held %s", rec.Host) |
| 1096 | } |
| 1097 | } |
| 1098 | // A further pass changes nothing on either box. |
| 1099 | for _, b := range []*offBox{plain, hooked} { |
| 1100 | w := b.w |
| 1101 | if b == plain { |
| 1102 | w = nil |
| 1103 | } |
| 1104 | if err := reconcileSerializedWith(ctx, wd.s, b.reg, provision.ReconcileConfig{DataDir: b.dd, Layout: flatLayout}, w); err != nil { |
| 1105 | t.Fatal(err) |
| 1106 | } |
| 1107 | } |
| 1108 | sameRegistries(t, plain, hooked) |
| 1109 | |
| 1110 | // The edges file: the one string, and no headroom with it. |
| 1111 | var last1, last2 []byte |
| 1112 | none := func(string) string { return "" } |
| 1113 | plainFile := edgesFile{Box: "plain", Placement: "", Capabilities: withOfflineCapability(edgesFileCapabilities(none), false)} |
| 1114 | capable := edgesFile{Box: "plain", Placement: "", Capabilities: withOfflineCapability(edgesFileCapabilities(none), true)} |
| 1115 | if headroomCapabilities(capable.Capabilities) != nil { |
| 1116 | t.Fatal("offline-move-1 asks for headroom") |
| 1117 | } |
| 1118 | s1, s2 := release.NewDirStore(t.TempDir()), release.NewDirStore(t.TempDir()) |
| 1119 | if err := edgesFileWrite(ctx, s1, plain.reg, plainFile, "1.2.3.4", &last1, false); err != nil { |
| 1120 | t.Fatal(err) |
| 1121 | } |
| 1122 | if err := edgesFileWrite(ctx, s2, plain.reg, capable, "1.2.3.4", &last2, false); err != nil { |
| 1123 | t.Fatal(err) |
| 1124 | } |
| 1125 | b1, _, _ := s1.Get(ctx, edgesFileKey("plain")) |
| 1126 | b2, _, _ := s2.Get(ctx, edgesFileKey("plain")) |
| 1127 | var m1, m2 map[string]any |
| 1128 | if json.Unmarshal(b1, &m1) != nil || json.Unmarshal(b2, &m2) != nil { |
| 1129 | t.Fatal("edges files do not parse") |
| 1130 | } |
| 1131 | if caps, _ := m2["capabilities"].([]any); len(caps) != 2 || caps[0] != capOfflineMove1 || caps[1] != capOfflineStage1 { |
| 1132 | t.Fatalf("capabilities: %v", m2["capabilities"]) |
| 1133 | } |
| 1134 | delete(m2, "capabilities") |
| 1135 | delete(m1, "generation") |
| 1136 | delete(m2, "generation") |
| 1137 | if !reflect.DeepEqual(m1, m2) { |
| 1138 | t.Fatalf("the edges file differs beyond the capability:\n %s\n %s", b1, b2) |
| 1139 | } |
| 1140 | if bytes.Contains(b1, []byte("capabilities")) { |
| 1141 | t.Fatal("fixture: the plain file has capabilities") |
| 1142 | } |
| 1143 | } |
| 1144 | |
| 1145 | // M1: a failed startup listing keeps every row only until the next |
| 1146 | // reconcile, which reads the listing again: the retired row then goes as on |
| 1147 | // main, without waiting for a moves pass. |
| 1148 | func TestOfflineFailedListingHealsOnTheNextReconcile(t *testing.T) { |
| 1149 | ctx := context.Background() |
| 1150 | wd := dormantWorld(t) |
| 1151 | plain, hooked := dormantBoxes(t, wd) |
| 1152 | v := hooked.w.cfg.Offline |
| 1153 | refreshOfflineView(ctx, failingListStore{Store: wd.s, prefix: "apps/", err: errors.New("listing refused")}, v) |
| 1154 | if !v.built() || !v.failed() || !v.keep(offHost) { |
| 1155 | t.Fatal("a failed startup listing does not keep") |
| 1156 | } |
| 1157 | if err := wd.s.Delete(ctx, provision.RecordKey(wd.acct, "app", "gone.example.com")); err != nil { |
| 1158 | t.Fatal(err) |
| 1159 | } |
| 1160 | for _, b := range []*offBox{plain, hooked} { |
| 1161 | w := b.w |
| 1162 | if b == plain { |
| 1163 | w = nil |
| 1164 | } |
| 1165 | if err := reconcileSerializedWith(ctx, wd.s, b.reg, provision.ReconcileConfig{DataDir: b.dd, Layout: flatLayout}, w); err != nil { |
| 1166 | t.Fatal(err) |
| 1167 | } |
| 1168 | } |
| 1169 | if v.failed() || v.keep("gone.example.com") { |
| 1170 | t.Fatal("the reconcile did not read the listing again") |
| 1171 | } |
| 1172 | if rt := hooked.row("gone.example.com"); rt != nil { |
| 1173 | t.Fatalf("the retired row stayed after the view healed: %+v", rt) |
| 1174 | } |
| 1175 | sameRegistries(t, plain, hooked) |
| 1176 | // While the listing keeps failing the view stays closed. |
| 1177 | refreshOfflineView(ctx, failingListStore{Store: wd.s, prefix: "apps/", err: errors.New("listing refused")}, v) |
| 1178 | if !v.keep(offHost) { |
| 1179 | t.Fatal("a failed listing does not keep") |
| 1180 | } |
| 1181 | unbuilt := newOfflineView(moveCfg{Region: ""}, nil) |
| 1182 | if !unbuilt.keep(offHost) { |
| 1183 | t.Fatal("an unbuilt view keeps nothing") |
| 1184 | } |
| 1185 | } |
| 1186 | |
| 1187 | // M4: a record that does not decode is kept by keep alone (its move cannot |
| 1188 | // be known), logged, and every other host is judged as usual. The moves |
| 1189 | // pass's listing carries the same answer. |
| 1190 | func TestOfflineUndecodableRecordIsKept(t *testing.T) { |
| 1191 | ctx := context.Background() |
| 1192 | wd := newOffWorld(t) |
| 1193 | wd.create("broken.example.com") |
| 1194 | if _, err := wd.s.Put(ctx, provision.RecordKey(wd.acct, "app", "broken.example.com"), []byte("{not json"), mustETag(t, wd.s, provision.RecordKey(wd.acct, "app", "broken.example.com"))); err != nil { |
| 1195 | t.Fatal(err) |
| 1196 | } |
| 1197 | a := wd.box("ie-a", "", nil) |
| 1198 | refreshOfflineView(ctx, wd.s, a.w.cfg.Offline) |
| 1199 | v := a.w.cfg.Offline |
| 1200 | if v.failed() || !v.keep("broken.example.com") || v.keep(offHost) { |
| 1201 | t.Fatalf("startup view: failed %v, keep(broken) %v, keep(%s) %v", v.failed(), v.keep("broken.example.com"), offHost, v.keep(offHost)) |
| 1202 | } |
| 1203 | fresh := newOfflineView(a.w.cfg, nil) |
| 1204 | a.w.cfg.Offline = fresh |
| 1205 | a.pass() |
| 1206 | if !fresh.keep("broken.example.com") || fresh.keep(offHost) { |
| 1207 | t.Fatal("the moves pass's listing does not keep the undecodable host") |
| 1208 | } |
| 1209 | if _, _, ok := offlineHold(a.w.cfg)(provision.Record{Host: "broken.example.com"}); ok { |
| 1210 | t.Fatal("held a record with no move") |
| 1211 | } |
| 1212 | } |
| 1213 | |
| 1214 | func mustETag(t *testing.T, s release.Store, key string) string { |
| 1215 | t.Helper() |
| 1216 | _, etag, err := s.Get(context.Background(), key) |
| 1217 | if err != nil { |
| 1218 | t.Fatal(err) |
| 1219 | } |
| 1220 | return etag |
| 1221 | } |
| 1222 | |
| 1223 | // hostSliceCgroup is the slice instancectl's units run in. |
| 1224 | func TestOfflineHostSliceIsInstancectlsSlice(t *testing.T) { |
| 1225 | got := hostSliceCgroup("/sys/fs/cgroup", offHost) |
| 1226 | want := filepath.Join("/sys/fs/cgroup", "carlos.slice", "carlos-instances.slice", instancectl.SliceName(offHost)) |
| 1227 | if got != want { |
| 1228 | t.Fatalf("hostSliceCgroup = %s, want %s", got, want) |
| 1229 | } |
| 1230 | if filepath.Dir(filepath.Join("/sys/fs/cgroup", filepath.FromSlash(instancectl.ClaimUnitControlGroup(offHost)))) != got { |
| 1231 | t.Fatal("the run unit is not in the slice") |
| 1232 | } |
| 1233 | } |
| 1234 | |
| 1235 | // The seal record's writer is create-only: the first body stays, a re-run |
| 1236 | // gets it back and writes nothing, and the report writer beside it replaces. |
| 1237 | func TestOfflineCreateOnlyKeepsTheFirstWrite(t *testing.T) { |
| 1238 | ctx := context.Background() |
| 1239 | s := release.NewDirStore(t.TempDir()) |
| 1240 | key := moves.OfflineReportPrefix(offHost, offID) + "seal.json" |
| 1241 | got, created, err := putOfflineCreateOnly(ctx, s, key, []byte(`{"n":1}`)) |
| 1242 | if err != nil || !created || string(got) != `{"n":1}` { |
| 1243 | t.Fatalf("first write: %q %v %v", got, created, err) |
| 1244 | } |
| 1245 | got, created, err = putOfflineCreateOnly(ctx, s, key, []byte(`{"n":2}`)) |
| 1246 | if err != nil || created || string(got) != `{"n":1}` { |
| 1247 | t.Fatalf("second write: %q %v %v, want the first body back, nothing written", got, created, err) |
| 1248 | } |
| 1249 | if body, _, err := s.Get(ctx, key); err != nil || string(body) != `{"n":1}` { |
| 1250 | t.Fatalf("stored: %q %v", body, err) |
| 1251 | } |
| 1252 | if _, _, err := putOfflineCreateOnly(ctx, s, "", nil); err == nil { |
| 1253 | t.Fatal("an empty key was written") |
| 1254 | } |
| 1255 | } |
| 1256 | |
| 1257 | // keep's table: the source keeps until finishing, the target until the |
| 1258 | // move is cleared, a third region never. |
| 1259 | func TestOfflineKeep(t *testing.T) { |
| 1260 | rec := func(region, state string) provision.Record { |
| 1261 | return provision.Record{Host: offHost, Region: region, Move: &provision.MoveState{Kind: provision.MoveKindOffline, ID: offID, From: "", To: "uk", State: state}} |
| 1262 | } |
| 1263 | states := []string{provision.OfflineStopping, provision.OfflineSealing, provision.OfflineSealed, provision.OfflineLanding, |
| 1264 | provision.OfflineLanded, provision.OfflineOpen, provision.OfflineFinishing, provision.OfflineReverting} |
| 1265 | for _, st := range states { |
| 1266 | recs := []provision.Record{rec("uk", st)} |
| 1267 | src := newOfflineView(moveCfg{Region: ""}, nil) |
| 1268 | src.refresh(recs, nil) |
| 1269 | if got, want := src.keep(offHost), st != provision.OfflineFinishing; got != want { |
| 1270 | t.Fatalf("source at %s: keep %v", st, got) |
| 1271 | } |
| 1272 | tgt := newOfflineView(moveCfg{Region: "uk"}, nil) |
| 1273 | tgt.refresh(recs, nil) |
| 1274 | if !tgt.keep(offHost) { |
| 1275 | t.Fatalf("target at %s: not kept", st) |
| 1276 | } |
| 1277 | other := newOfflineView(moveCfg{Region: "fr"}, nil) |
| 1278 | other.refresh(recs, nil) |
| 1279 | if other.keep(offHost) { |
| 1280 | t.Fatalf("a third region keeps at %s", st) |
| 1281 | } |
| 1282 | } |
| 1283 | } |
| 1284 | |
| 1285 | // readCountingStore counts the reads made through it. |
| 1286 | type readCountingStore struct { |
| 1287 | release.Store |
| 1288 | mu sync.Mutex |
| 1289 | reads int |
| 1290 | } |
| 1291 | |
| 1292 | func (c *readCountingStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 1293 | c.mu.Lock() |
| 1294 | c.reads++ |
| 1295 | c.mu.Unlock() |
| 1296 | return c.Store.Get(ctx, key) |
| 1297 | } |
| 1298 | |
| 1299 | func (c *readCountingStore) List(ctx context.Context, prefix string) ([]string, error) { |
| 1300 | c.mu.Lock() |
| 1301 | c.reads++ |
| 1302 | c.mu.Unlock() |
| 1303 | return c.Store.List(ctx, prefix) |
| 1304 | } |
| 1305 | |
| 1306 | // Harness Run 9, finding 7: between moves passes, a source box rewrites its |
| 1307 | // report on the re-assert tick while a door judges that report by age |
| 1308 | // (stopping to landed), and only then. It never runs the seal itself, it |
| 1309 | // reads nothing while no offline move is in flight, and a box that is |
| 1310 | // neither a source nor the sealer does nothing. |
| 1311 | func TestOfflineReassertRewritesOnlyTheReportsADoorReads(t *testing.T) { |
| 1312 | wd := newOffWorld(t) |
| 1313 | a := wd.box("ie-a", "", nil) |
| 1314 | uk := wd.box("uk-box", "uk", nil) |
| 1315 | a.tick() |
| 1316 | uk.tick() |
| 1317 | cs := &readCountingStore{Store: wd.s} |
| 1318 | ctx := context.Background() |
| 1319 | if n := a.w.offlineReassert(ctx, cs); n != 0 || cs.reads != 0 { |
| 1320 | t.Fatalf("no move in flight: acted on %d, %d store reads", n, cs.reads) |
| 1321 | } |
| 1322 | |
| 1323 | wd.start() |
| 1324 | a.tick() // the start door's bump |
| 1325 | uk.tick() |
| 1326 | wd.advance(3 * time.Minute) |
| 1327 | for _, state := range []string{provision.OfflineStopping, provision.OfflineSealing} { |
| 1328 | if state == provision.OfflineSealing { |
| 1329 | wd.seal() |
| 1330 | wd.advance(time.Minute) |
| 1331 | } |
| 1332 | if n := a.w.offlineReassert(ctx, cs); n != 1 { |
| 1333 | t.Fatalf("%s: the source acted on %d records, want 1", state, n) |
| 1334 | } |
| 1335 | if r := wd.report(moves.OfflineRoleSource, "ie-a"); !r.At.Equal(wd.clock()) || !r.Held || !r.Fenced { |
| 1336 | t.Fatalf("%s: the source's report %+v, want rewritten at %v", state, r, wd.clock()) |
| 1337 | } |
| 1338 | if n := uk.w.offlineReassert(ctx, cs); n != 0 { |
| 1339 | t.Fatalf("%s: the target acted on %d records", state, n) |
| 1340 | } |
| 1341 | } |
| 1342 | if _, _, err := wd.s.Get(ctx, moves.OfflineSealKey(offHost, offID)); !errors.Is(err, os.ErrNotExist) { |
| 1343 | t.Fatalf("the re-assert ran the seal (err %v)", err) |
| 1344 | } |
| 1345 | |
| 1346 | // From open on, no door reads a report by age: nothing is rewritten. |
| 1347 | wd.sealed() |
| 1348 | wd.flip() |
| 1349 | wd.landed() |
| 1350 | wd.open() |
| 1351 | a.pass() // the view learns the state the way it always does |
| 1352 | at := wd.report(moves.OfflineRoleSource, "ie-a").At |
| 1353 | wd.advance(time.Minute) |
| 1354 | if n := a.w.offlineReassert(ctx, cs); n != 0 { |
| 1355 | t.Fatalf("open: the source acted on %d records", n) |
| 1356 | } |
| 1357 | if r := wd.report(moves.OfflineRoleSource, "ie-a"); !r.At.Equal(at) { |
| 1358 | t.Fatalf("open: the source's report was rewritten at %v", r.At) |
| 1359 | } |
| 1360 | } |
| 1361 | |
| 1362 | // elsewhere, scheduleSkip's second half (harness Run 9, defect 8): the box |
| 1363 | // whose region the record does not name, through every state the record |
| 1364 | // moves through. |
| 1365 | func TestOfflineElsewhereFollowsTheRecordsRegion(t *testing.T) { |
| 1366 | wd := newOffWorld(t) |
| 1367 | a := wd.box("ie-a", "", nil) |
| 1368 | uk := wd.box("uk-box", "uk", nil) |
| 1369 | a.tick() |
| 1370 | uk.tick() |
| 1371 | check := func(when string, ieElsewhere, ukElsewhere bool) { |
| 1372 | t.Helper() |
| 1373 | a.pass() |
| 1374 | uk.pass() |
| 1375 | if got := a.w.cfg.Offline.elsewhere(offHost); got != ieElsewhere { |
| 1376 | t.Fatalf("%s: ie-a elsewhere = %v", when, got) |
| 1377 | } |
| 1378 | if got := uk.w.cfg.Offline.elsewhere(offHost); got != ukElsewhere { |
| 1379 | t.Fatalf("%s: uk-box elsewhere = %v", when, got) |
| 1380 | } |
| 1381 | } |
| 1382 | check("no move", false, false) |
| 1383 | wd.start() |
| 1384 | check("stopping", false, true) |
| 1385 | wd.seal() |
| 1386 | wd.sealed() |
| 1387 | check("sealed", false, true) |
| 1388 | wd.flip() |
| 1389 | check("landing", true, false) |
| 1390 | wd.abort() |
| 1391 | check("reverting", false, true) |
| 1392 | } |
| 1393 | |