package host import ( "encoding/json" "errors" "log" "os" "path/filepath" "strings" "testing" "time" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/release" ) // Ruling 61: a relocation journal lives while its op runs. Once the host's // claim runs another op, or the claim is gone, the sweep removes the op's // install and attempt journals, its release positions and its build // scratch. The live op's files stay, and a journal whose host cannot be // read stays and is reported: absence is never evidence. func TestRelocationJournalsAreSweptWhenTheirOpEnds(t *testing.T) { ctx := t.Context() store := release.NewDirStore(t.TempDir()) created := time.Date(2026, 10, 7, 0, 0, 0, 0, time.UTC) record := provision.Record{Schema: 1, Host: "shop.example", Slug: "shop", App: "app", Account: "acct", Region: "ie", Channel: "stable", CreatedAt: created} if err := provision.CreateRecord(ctx, store, record); err != nil { t.Fatal(err) } obs, err := hostclaim.Read(ctx, store, record.Host) if err != nil { t.Fatal(err) } obs, err = hostclaim.Update(ctx, store, obs, hostclaim.Adopt(hostclaim.NewHolder{Family: "instance", Key: provision.RecordKey(record.Account, record.App, record.Host), Account: record.Account, App: record.App, Host: record.Host, Region: record.Region, CreatedAt: created}, "src", nil, nil, "adopt", func() string { return "id" })) if err != nil { t.Fatal(err) } if _, err = hostclaim.Update(ctx, store, obs, func(c *hostclaim.Claim) error { c.Op = hostclaim.Op{Kind: hostclaim.KindRelocating, ID: "rel-live", Phase: hostclaim.PhaseInstalling, Exec: "dst", Participants: []string{"src", "dst"}, Intent: map[string]string{"region": "ie", "source_box": "src", "target_box": "dst"}} return nil }); err != nil { t.Fatal(err) } data := t.TempDir() files := map[string]string{ "relocate/ops/rel-live/install.json": `{"v":1,"host":"shop.example","op":"rel-live"}`, "relocate/ops/rel-old/install.json": `{"v":1,"host":"shop.example","op":"rel-old"}`, "relocate/ops/rel-old/attempts.json": `{"v":1,"host":"shop.example","op":"rel-old"}`, "relocate/ops/rel-gone/attempts.json": `{"v":1,"host":"gone.example","op":"rel-gone"}`, "relocate/ops/rel-torn/install.json": `{"v":1,"host":`, "relocate/release/rel-live.json": `{"request":{"Host":"shop.example"}}`, "relocate/release/rel-old.json": `{"request":{"Host":"shop.example"}}`, "relocate/build/rel-live/shop.example.db": "scratch", "relocate/build/rel-old/shop.example.db": "scratch", } for path, body := range files { full := filepath.Join(data, filepath.FromSlash(path)) if err := os.MkdirAll(filepath.Dir(full), 0o700); err != nil { t.Fatal(err) } if err := os.WriteFile(full, []byte(body), 0o600); err != nil { t.Fatal(err) } } if err := sweepRelocationJournals(ctx, data, store); err == nil { t.Fatal("a torn journal was not reported") } for path := range files { _, err := os.Stat(filepath.Join(data, filepath.FromSlash(path))) live := filepath.Base(filepath.Dir(path)) == "rel-live" || filepath.Base(path) == "rel-live.json" || filepath.Base(filepath.Dir(path)) == "rel-torn" switch { case live && err != nil: t.Errorf("%s was swept while its op runs or its host is unread: %v", path, err) case !live && !errors.Is(err, os.ErrNotExist): t.Errorf("%s survived the end of its op: %v", path, err) } } for _, dir := range []string{"relocate/ops/rel-old", "relocate/ops/rel-gone", "relocate/build/rel-old"} { if _, err := os.Stat(filepath.Join(data, filepath.FromSlash(dir))); !errors.Is(err, os.ErrNotExist) { t.Errorf("%s was left behind: %v", dir, err) } } // A second pass over what is left removes nothing more and still // reports the torn journal. if err := sweepRelocationJournals(ctx, data, store); err == nil { t.Fatal("the torn journal stopped being reported") } if _, err := os.Stat(filepath.Join(data, "relocate/ops/rel-live/install.json")); err != nil { t.Fatalf("the second pass swept the live op: %v", err) } } // Ruling 76: the op a serving claim records as completed keeps its // journals, the probe stop's fence owner among them, until the completed // relocation is marked discharged, so the hold release always finds the // owner it lifts under. Then the next sweep removes them. func TestRelocationJournalsOfACompletedOpStayUntilItsDischarge(t *testing.T) { ctx := t.Context() store := release.NewDirStore(t.TempDir()) created := time.Date(2026, 10, 7, 0, 0, 0, 0, time.UTC) record := provision.Record{Schema: 1, Host: "shop.example", Slug: "shop", App: "app", Account: "acct", Region: "ie", Channel: "stable", CreatedAt: created} if err := provision.CreateRecord(ctx, store, record); err != nil { t.Fatal(err) } obs, err := hostclaim.Read(ctx, store, record.Host) if err != nil { t.Fatal(err) } obs, err = hostclaim.Update(ctx, store, obs, hostclaim.Adopt(hostclaim.NewHolder{Family: "instance", Key: provision.RecordKey(record.Account, record.App, record.Host), Account: record.Account, App: record.App, Host: record.Host, Region: record.Region, CreatedAt: created}, "dst", nil, nil, "adopt", func() string { return "id" })) if err != nil { t.Fatal(err) } done, _ := json.Marshal(hostclaim.CompletedRelocation{Op: "rel-done", Stop: hostclaim.ProbeStop{Box: "dst", Session: 12, Receipt: "fence-1", ReceiptSHA256: strings.Repeat("a", 64)}, Phase: hostclaim.PhaseRelocatingProbing}) if obs, err = hostclaim.Update(ctx, store, obs, func(c *hostclaim.Claim) error { c.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: "dst", Participants: []string{"dst"}, Journal: map[string]string{hostclaim.JournalCompleted: string(done)}} return nil }); err != nil { t.Fatal(err) } data := t.TempDir() files := []string{"relocate/ops/rel-done/install.json", "relocate/ops/rel-done/probe-fence.json", "relocate/ops/rel-fence-only/probe-fence.json"} bodies := []string{`{"v":1,"host":"shop.example","op":"rel-done"}`, `{"host":"shop.example","session":12,"owner":"0123"}`, `{"host":"shop.example","session":3,"owner":"4567"}`} for i, path := range files { full := filepath.Join(data, filepath.FromSlash(path)) if err := os.MkdirAll(filepath.Dir(full), 0o700); err != nil { t.Fatal(err) } if err := os.WriteFile(full, []byte(bodies[i]), 0o600); err != nil { t.Fatal(err) } } if err := sweepRelocationJournals(ctx, data, store); err != nil { t.Fatal(err) } for _, path := range files[:2] { if _, err := os.Stat(filepath.Join(data, filepath.FromSlash(path))); err != nil { t.Fatalf("%s was swept before the discharge: %v", path, err) } } if _, err := os.Stat(filepath.Join(data, "relocate/ops/rel-fence-only")); !errors.Is(err, os.ErrNotExist) { t.Fatalf("an ended op known only by its fence owner survived: %v", err) } if _, err := hostclaim.Update(ctx, store, obs, hostclaim.MarkCompletedDischarged("rel-done")); err != nil { t.Fatal(err) } if err := sweepRelocationJournals(ctx, data, store); err != nil { t.Fatal(err) } if _, err := os.Stat(filepath.Join(data, "relocate/ops/rel-done")); !errors.Is(err, os.ErrNotExist) { t.Fatalf("the discharged op's journals survived: %v", err) } } // sweepClaim is shop.example's claim, adopted on "dst", with its op // replaced by op. func sweepClaim(t *testing.T, op hostclaim.Op) *release.DirStore { t.Helper() ctx := t.Context() store := release.NewDirStore(t.TempDir()) created := time.Date(2026, 10, 7, 0, 0, 0, 0, time.UTC) record := provision.Record{Schema: 1, Host: "shop.example", Slug: "shop", App: "app", Account: "acct", Region: "ie", Channel: "stable", CreatedAt: created} if err := provision.CreateRecord(ctx, store, record); err != nil { t.Fatal(err) } obs, err := hostclaim.Read(ctx, store, record.Host) if err != nil { t.Fatal(err) } obs, err = hostclaim.Update(ctx, store, obs, hostclaim.Adopt(hostclaim.NewHolder{Family: "instance", Key: provision.RecordKey(record.Account, record.App, record.Host), Account: record.Account, App: record.App, Host: record.Host, Region: record.Region, CreatedAt: created}, "dst", nil, nil, "adopt", func() string { return "id" })) if err != nil { t.Fatal(err) } if _, err := hostclaim.Update(ctx, store, obs, func(c *hostclaim.Claim) error { c.Op = op; return nil }); err != nil { t.Fatal(err) } return store } func writeSweepFiles(t *testing.T, data string, files map[string]string) { t.Helper() for path, body := range files { full := filepath.Join(data, filepath.FromSlash(path)) if err := os.MkdirAll(filepath.Dir(full), 0o700); err != nil { t.Fatal(err) } if err := os.WriteFile(full, []byte(body), 0o600); err != nil { t.Fatal(err) } } } // Fix round 1, I1: a serving claim whose completed relocation cannot be // read keeps every journal of its host, the probe stop's fence owner // included, and the sweep reports it: it may be that op's, and absence is // never evidence. func TestRelocationJournalsStayWhileTheCompletionCannotBeRead(t *testing.T) { for name, raw := range map[string]string{"unparseable": `{"op":"rel-done",`, "no box": `{"op":"rel-done","stop":{"session":12}}`} { t.Run(name, func(t *testing.T) { store := sweepClaim(t, hostclaim.Op{Kind: hostclaim.KindServing, Exec: "dst", Participants: []string{"dst"}, Journal: map[string]string{hostclaim.JournalCompleted: raw}}) data := t.TempDir() files := map[string]string{ "relocate/ops/rel-done/install.json": `{"v":1,"host":"shop.example","op":"rel-done"}`, "relocate/ops/rel-done/probe-fence.json": `{"host":"shop.example","session":12,"owner":"0123"}`, "relocate/release/rel-done.json": `{"request":{"Host":"shop.example"}}`, "relocate/build/rel-done/shop.example.db": "scratch", } writeSweepFiles(t, data, files) if err := sweepRelocationJournals(t.Context(), data, store); !errors.Is(err, hostclaim.ErrRelocation) { t.Fatalf("an unreadable completion was not reported: %v", err) } for path := range files { if _, err := os.Stat(filepath.Join(data, filepath.FromSlash(path))); err != nil { t.Errorf("%s was swept over an unreadable completion: %v", path, err) } } }) } } // Fix round 1, M5: what no committed journal can be is removed (an ops // directory with no host-bearing journal, an atomic write's leftover // temporaries under release/, a build directory holding only SQLite // sidecars of an ended op, or nothing); the live op's sidecar stays; an // entry the sweep does not know stays and is logged once, not every pass, // and is no error. func TestRelocationSweepRemovesLeftoversAndLogsAStrayOnce(t *testing.T) { store := sweepClaim(t, hostclaim.Op{Kind: hostclaim.KindRelocating, ID: "rel-live", Phase: hostclaim.PhaseInstalling, Exec: "dst", Participants: []string{"src", "dst"}, Intent: map[string]string{"region": "ie", "source_box": "src", "target_box": "dst"}}) data := t.TempDir() writeSweepFiles(t, data, map[string]string{ "relocate/ops/rel-tmp/.install.json.123456": `{"v":1,"host":`, "relocate/release/rel-x.json.tmp": `{"request":`, "relocate/release/.rel-y.json.987654": `{"req`, "relocate/build/rel-wal/shop.example.db-wal": "wal", "relocate/build/rel-jrn/shop.example.db-journal": "journal", "relocate/build/rel-live/shop.example.db-wal": "wal", "relocate/build/stray.txt": "?", "relocate/release/notes.txt": "?", }) for _, dir := range []string{"relocate/ops/rel-empty", "relocate/build/rel-empty"} { if err := os.MkdirAll(filepath.Join(data, filepath.FromSlash(dir)), 0o700); err != nil { t.Fatal(err) } } var logged strings.Builder log.SetOutput(&logged) t.Cleanup(func() { log.SetOutput(os.Stderr) }) for pass := 0; pass < 2; pass++ { if err := sweepRelocationJournals(t.Context(), data, store); err != nil { t.Fatalf("pass %d: %v", pass, err) } } for _, gone := range []string{"relocate/ops/rel-tmp", "relocate/ops/rel-empty", "relocate/release/rel-x.json.tmp", "relocate/release/.rel-y.json.987654", "relocate/build/rel-wal", "relocate/build/rel-jrn", "relocate/build/rel-empty"} { if _, err := os.Stat(filepath.Join(data, filepath.FromSlash(gone))); !errors.Is(err, os.ErrNotExist) { t.Errorf("%s survived the sweep: %v", gone, err) } } for _, kept := range []string{"relocate/build/rel-live/shop.example.db-wal", "relocate/build/stray.txt", "relocate/release/notes.txt"} { if _, err := os.Stat(filepath.Join(data, filepath.FromSlash(kept))); err != nil { t.Errorf("%s was removed: %v", kept, err) } } for _, stray := range []string{"stray.txt", "notes.txt"} { if n := strings.Count(logged.String(), stray); n != 1 { t.Errorf("%s logged %d times over two passes, want once: %q", stray, n, logged.String()) } } }