package host import ( "context" "errors" "net" "os" "path/filepath" "strconv" "strings" "testing" "time" "github.com/carlosframework/platform/internal/cgroup" "github.com/carlosframework/platform/internal/hostbox" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/hostguard" "github.com/carlosframework/platform/internal/hostrelocate" "github.com/carlosframework/platform/internal/provision" ) // Finding 14: every journal field has its own JSON name and survives a // round trip; a journal for another host or op is refused. func TestRelocationAttemptJournalRoundTrips(t *testing.T) { dd := t.TempDir() j, err := readRelocationAttempts(dd, "shop.example", "op1") if err != nil { t.Fatal(err) } j.Opened["1"] = time.Date(2026, 10, 10, 9, 0, 0, 0, time.UTC) j.Sessions["7"] = 1 j.Starts["1"] = relocationStart{Session: 7, Generation: "P1", Stamp: 9, State: probeReserved, Attempt: hostclaim.WakeRetryAttempt{ID: "a", InvocationID: "b", Session: 7, Box: "dst"}} if err := j.write(dd); err != nil { t.Fatal(err) } raw, _ := os.ReadFile(filepath.Join(dd, "relocate", "ops", "op1", "attempts.json")) for _, key := range []string{`"host":"shop.example"`, `"op":"op1"`} { if !strings.Contains(string(raw), key) { t.Fatalf("%s missing from %s", key, raw) } } got, err := readRelocationAttempts(dd, "shop.example", "op1") if err != nil || got.Starts["1"] != j.Starts["1"] || got.Sessions["7"] != 1 || !got.Opened["1"].Equal(j.Opened["1"]) { t.Fatalf("round trip: %+v %v", got, err) } if _, err := readRelocationAttempts(dd, "other.example", "op1"); !errors.Is(err, hostbox.ErrChanged) { t.Fatalf("another host's journal was read: %v", err) } } // Finding 7: root's reader returns the reserved start only for the exact // op at probing, the executor's attempt k, session, generation and stamp. func TestRootReadsTheRelocationAttemptJournal(t *testing.T) { dd := t.TempDir() j, _ := readRelocationAttempts(dd, "shop.example", "op1") j.Sessions["7"] = 1 attempt := hostclaim.WakeRetryAttempt{ID: "a", InvocationID: "b", Session: 7, Box: "dst"} j.Starts["1"] = relocationStart{Session: 7, Generation: "P1", Stamp: 9, State: probeReserved, Attempt: attempt} if err := j.write(dd); err != nil { t.Fatal(err) } claim := func() *hostclaim.Claim { return &hostclaim.Claim{Host: "shop.example", Holder: &hostclaim.Holder{Incarnation: "inc", Stamp: 9, Session: 7, Generation: "P1", WriterBox: "dst"}, Op: hostclaim.Op{Kind: hostclaim.KindRelocating, ID: "op1", Phase: hostclaim.PhaseRelocatingProbing, Exec: "dst", Journal: map[string]string{"install_stamp": "9", "prepared": "P1", "probe_attempts.dst": "1"}}} } r := relocationAttemptReader{DataDir: dd} got, err := r.ReadAttempt(context.Background(), hostclaim.Observed{Host: "shop.example", Claim: claim()}) if err != nil || got != attempt { t.Fatalf("read %+v %v", got, err) } // Astra round 2 finding 4: a later cold attempt runs at its own // admission's stamp; the reader takes the journaled start at that // stamp (root's claimLaunchAuthority pin is claim ask C11). j2, _ := readRelocationAttempts(dd, "shop.example", "op1") j2.Sessions["14"] = 2 later := hostclaim.WakeRetryAttempt{ID: "c", InvocationID: "d", Session: 14, Box: "dst"} j2.Starts["2"] = relocationStart{Session: 14, Generation: "g2", Stamp: 14, State: probeReserved, Attempt: later} if err := j2.write(dd); err != nil { t.Fatal(err) } second := claim() second.Holder.Stamp, second.Holder.Session, second.Holder.Generation = 14, 14, "g2" second.Op.Journal["probe_attempts.dst"] = "2" second.Op.Journal["prepared_opened"] = "P1" if got, err := r.ReadAttempt(context.Background(), hostclaim.Observed{Host: "shop.example", Claim: second}); err != nil || got != later { t.Fatalf("second attempt: %+v %v", got, err) } for name, change := range map[string]func(*hostclaim.Claim){ "another session": func(c *hostclaim.Claim) { c.Holder.Session = 8 }, "another generation": func(c *hostclaim.Claim) { c.Holder.Generation = "g2" }, "another stamp": func(c *hostclaim.Claim) { c.Holder.Stamp = 10; c.Op.Journal["install_stamp"] = "10" }, "P at a stamp not the install stamp": func(c *hostclaim.Claim) { c.Op.Journal["install_stamp"] = "8" }, "a later attempt": func(c *hostclaim.Claim) { c.Op.Journal["probe_attempts.dst"] = "2" }, "installed": func(c *hostclaim.Claim) { c.Op.Phase = hostclaim.PhaseInstalled }, "stuck": func(c *hostclaim.Claim) { c.Op.Journal["stuck"] = "x" }, } { c := claim() change(c) if _, err := r.ReadAttempt(context.Background(), hostclaim.Observed{Host: "shop.example", Claim: c}); err == nil { t.Fatalf("%s: read", name) } } } // Finding 4: a relocation target's route is the nested layout, and the // files and env paths are the instance directory's. func TestRelocationRouteIsNestedAndPathsAgree(t *testing.T) { dd := "/data" rt := provision.RouteFor(provision.Record{Host: "shop.example", App: "shop", Account: "acct", Channel: "stable"}, dd, "", true, nil) if rt.DBPath != provision.NestedDBPath(dd, "shop.example") || rt.Socket != provision.NestedSocket("shop.example") { t.Fatalf("not nested: %+v", rt) } p := relocationPathsFor(dd, rt) dir := provision.InstanceDir(dd, "shop.example") if p.FilesDir != filepath.Join(dir, "shop.example") || p.EnvPath != filepath.Join(dir, "shop.example.env") { t.Fatalf("paths %+v", p) } if _, err := claimIsolatedPaths(map[string]string{"data": dd}, "shop.example", ClaimLaunchInputs{DBPath: rt.DBPath, Limit: cgroup.Limit{MemoryMax: cgroup.TierDefault.MemoryMax}}); err != nil { t.Fatalf("the isolated launch refuses the minted route: %v", err) } } // Finding 7, ruling 48: root installs the relocation attempt reader only // with region HA on; its launch capability answers for kind relocating // only then. func TestRootInstallsTheRelocationReaderOnlyWithRegionHA(t *testing.T) { journal, err := hostbox.OpenLaunchJournal(filepath.Join(t.TempDir(), "launch.db")) if err != nil { t.Fatal(err) } defer journal.Close() inner := stopKernelFunc(func(context.Context, hostguard.FenceTarget, StopInventory, DrainAcknowledgement) (StopKernelEvidence, error) { return StopKernelEvidence{}, nil }) root := ClaimRootHTTP{Launches: journal, Stop: ClaimStopHTTP{Authenticate: func(context.Context, net.Conn) (StopPeer, error) { return StopPeer{}, nil }, Coordinator: ClaimStopCoordinator{Kernel: inner, Guard: hostguard.Guard{DataDir: "/data", Claims: &launchClaims{}}}}} actor := func(context.Context) (hostbox.Actor, error) { return hostbox.Actor{}, nil } saved := claimLaunchEnv t.Cleanup(func() { claimLaunchEnv = saved }) q := hostbox.RoleLaunchRequest{V: hostbox.RoleLaunchV, Op: hostbox.RoleLaunchCapability, Kind: hostclaim.KindRelocating} for _, on := range []bool{true, false} { value := "" if on { value = "1" } claimLaunchEnv = func(k string) string { if k == "CARLOS_REGION_HA" { return value } return "" } wrapped, err := claimLaunchFor(root, actor, map[string]string{"opt": "/opt/carlos", "data": "/data"}) if err != nil { t.Fatal(err) } reader, installed := wrapped.Launch.Attempts[hostclaim.KindRelocating].(relocationAttemptReader) if installed != on || (on && reader.DataDir != "/data") || (!on && wrapped.Launch.Attempts != nil) { t.Fatalf("region HA %v: attempts %+v", on, wrapped.Launch.Attempts) } f := newLaunchFixture(t, servingOp()) f.root.Attempts = wrapped.Launch.Attempts _, err = f.root.Serve(t.Context(), f.peer, q) if on && err != nil { t.Fatalf("the relocating capability with region HA on: %v", err) } if !on && !errors.Is(err, hostbox.ErrUnsupported) { t.Fatalf("the relocating capability with region HA off: %v", err) } } } // Absence is never evidence: root's reader answers nothing for an // operation whose journal is missing, unreadable or malformed, and the // journal's own settles and restarts refuse once it is gone. (A fresh // journal for a missing file is the writer's first state; the reader // refuses it because no session is bound, and this pins that direction.) func TestRootReaderRefusesAMissingOrUnreadableJournal(t *testing.T) { ctx := context.Background() claim := func() *hostclaim.Claim { return &hostclaim.Claim{Host: "shop.example", Holder: &hostclaim.Holder{Incarnation: "inc", Stamp: 9, Session: 7, Generation: "P1", WriterBox: "dst"}, Op: hostclaim.Op{Kind: hostclaim.KindRelocating, ID: "op1", Phase: hostclaim.PhaseRelocatingProbing, Exec: "dst", Journal: map[string]string{"install_stamp": "9", "prepared": "P1", "probe_attempts.dst": "1"}}} } dd := t.TempDir() r := relocationAttemptReader{DataDir: dd} o := hostclaim.Observed{Host: "shop.example", Claim: claim()} if _, err := r.ReadAttempt(ctx, o); !errors.Is(err, hostbox.ErrChanged) { t.Fatalf("no journal: %v", err) } path, _ := relocationAttemptsPath(dd, "op1") if err := os.MkdirAll(path, 0o700); err != nil { t.Fatal(err) } if _, err := r.ReadAttempt(ctx, o); err == nil { t.Fatal("a journal that cannot be read answered") } if err := os.Remove(path); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(`{"v":1,"host":"shop.example","op":"op1"`), 0o600); err != nil { t.Fatal(err) } if _, err := r.ReadAttempt(ctx, o); !errors.Is(err, hostbox.ErrChanged) { t.Fatalf("a malformed journal: %v", err) } // A journal with its maps missing is malformed too, not empty. if err := os.WriteFile(path, []byte(`{"v":1,"host":"shop.example","op":"op1"}`), 0o600); err != nil { t.Fatal(err) } if _, err := readRelocationAttempts(dd, "shop.example", "op1"); !errors.Is(err, hostbox.ErrChanged) { t.Fatalf("a journal without its maps: %v", err) } } // Root takes a start only when the journal vouches for it twice: the // holder's session is bound to the record attempt k, and k's current // start is still reserved. A well-formed start under an unbound session, // a session bound to another attempt, or a start already settled failed // or healthy, answers nothing. func TestRootReaderNeedsABoundReservedStart(t *testing.T) { ctx := context.Background() attempt := hostclaim.WakeRetryAttempt{ID: "a", InvocationID: "b", Session: 7, Box: "dst"} claim := &hostclaim.Claim{Host: "shop.example", Holder: &hostclaim.Holder{Incarnation: "inc", Stamp: 9, Session: 7, Generation: "P1", WriterBox: "dst"}, Op: hostclaim.Op{Kind: hostclaim.KindRelocating, ID: "op1", Phase: hostclaim.PhaseRelocatingProbing, Exec: "dst", Journal: map[string]string{"install_stamp": "9", "prepared": "P1", "probe_attempts.dst": "1"}}} o := hostclaim.Observed{Host: "shop.example", Claim: claim} for name, shape := range map[string]func(*relocationAttemptJournal){ "unbound session": func(j *relocationAttemptJournal) { delete(j.Sessions, "7") }, "session bound to another attempt": func(j *relocationAttemptJournal) { j.Sessions["7"] = 2 }, "a failed start": func(j *relocationAttemptJournal) { s := j.Starts["1"]; s.State = probeFailed; j.Starts["1"] = s }, "a healthy start": func(j *relocationAttemptJournal) { s := j.Starts["1"]; s.State = probeHealthy; j.Starts["1"] = s }, "a start under another session": func(j *relocationAttemptJournal) { s := j.Starts["1"]; s.Session = 8; j.Starts["1"] = s }, "a start at another stamp": func(j *relocationAttemptJournal) { s := j.Starts["1"]; s.Stamp = 10; j.Starts["1"] = s }, "an attempt for another session": func(j *relocationAttemptJournal) { s := j.Starts["1"]; s.Attempt.Session = 8; j.Starts["1"] = s }, "an attempt for another box": func(j *relocationAttemptJournal) { s := j.Starts["1"]; s.Attempt.Box = "src"; j.Starts["1"] = s }, "a journal for another op": func(j *relocationAttemptJournal) { j.Op = "op2" }, } { dd := t.TempDir() j, _ := readRelocationAttempts(dd, "shop.example", "op1") j.Sessions["7"] = 1 j.Starts["1"] = relocationStart{Session: 7, Generation: "P1", Stamp: 9, State: probeReserved, Attempt: attempt} shape(&j) if err := j.write(dd); err != nil { t.Fatal(err) } if got, err := (relocationAttemptReader{DataDir: dd}).ReadAttempt(ctx, o); !errors.Is(err, hostbox.ErrChanged) { t.Fatalf("%s: read %+v %v", name, got, err) } } // The record attempt is root's claim read, bounded by MaxProbeAttempts. dd := t.TempDir() j, _ := readRelocationAttempts(dd, "shop.example", "op1") j.Sessions["7"] = hostclaim.MaxProbeAttempts + 1 j.Starts[strconv.Itoa(hostclaim.MaxProbeAttempts+1)] = relocationStart{Session: 7, Generation: "P1", Stamp: 9, State: probeReserved, Attempt: attempt} if err := j.write(dd); err != nil { t.Fatal(err) } claim.Op.Journal["probe_attempts.dst"] = strconv.Itoa(hostclaim.MaxProbeAttempts + 1) if _, err := (relocationAttemptReader{DataDir: dd}).ReadAttempt(ctx, o); !errors.Is(err, hostbox.ErrChanged) { t.Fatalf("an attempt past the bound: %v", err) } } // Final review A2: the passed probe is recovered from the journal only as // the healthy start of the executor's record attempt bound to the holder's // session, generation and stamp; any other start is no probe; a missing or // unreadable journal after P's opening is a refusal, never "no probe". func TestRelocationPassedProbeReadsTheHealthyStartForTheAdmittedSession(t *testing.T) { ctx := context.Background() dd := t.TempDir() claim := func() *hostclaim.Claim { return &hostclaim.Claim{Host: "shop.example", Holder: &hostclaim.Holder{Incarnation: "inc", Stamp: 9, Session: 7, Generation: "P1", WriterBox: "dst"}, Op: hostclaim.Op{Kind: hostclaim.KindRelocating, ID: "op1", Phase: hostclaim.PhaseRelocatingProbing, Exec: "dst", Journal: map[string]string{"install_stamp": "9", "prepared": "P1", "prepared_opened": "P1", "probe_attempts.dst": "1"}}} } passed := relocationPassedProbe(dd) if _, _, err := passed(ctx, hostclaim.Observed{Host: "shop.example", Claim: claim()}); !errors.Is(err, hostrelocate.ErrRefused) { t.Fatalf("a missing journal after P's opening: %v", err) } write := func(state string, change func(*relocationStart)) { t.Helper() j, _ := readRelocationAttempts(dd, "shop.example", "op1") j.Sessions["7"] = 1 st := relocationStart{Session: 7, Generation: "P1", Stamp: 9, State: state, Attempt: hostclaim.WakeRetryAttempt{ID: "a", InvocationID: "b", Session: 7, Box: "dst"}} if change != nil { change(&st) } j.Starts["1"] = st if err := j.write(dd); err != nil { t.Fatal(err) } } write(probeHealthy, nil) if s, ok, err := passed(ctx, hostclaim.Observed{Host: "shop.example", Claim: claim()}); err != nil || !ok || s != 7 { t.Fatalf("the healthy start: %d %v %v", s, ok, err) } for name, tc := range map[string]struct { state string change func(*relocationStart) claim func(*hostclaim.Claim) }{ "reserved": {probeReserved, nil, nil}, "failed": {probeFailed, nil, nil}, "another session": {probeHealthy, func(s *relocationStart) { s.Session = 8; s.Attempt.Session = 8 }, nil}, "another generation": {probeHealthy, func(s *relocationStart) { s.Generation = "g2" }, nil}, "another stamp": {probeHealthy, func(s *relocationStart) { s.Stamp = 10 }, nil}, "another box's attempt": {probeHealthy, func(s *relocationStart) { s.Attempt.Box = "src" }, nil}, "a later record attempt": {probeHealthy, nil, func(c *hostclaim.Claim) { c.Op.Journal["probe_attempts.dst"] = "2" }}, } { write(tc.state, tc.change) c := claim() if tc.claim != nil { tc.claim(c) } if s, ok, err := passed(ctx, hostclaim.Observed{Host: "shop.example", Claim: c}); err != nil || ok || s != 0 { t.Fatalf("%s: %d %v %v", name, s, ok, err) } } for name, change := range map[string]func(*hostclaim.Claim){ "P not opened": func(c *hostclaim.Claim) { delete(c.Op.Journal, "prepared_opened") }, "installed": func(c *hostclaim.Claim) { c.Op.Phase = hostclaim.PhaseInstalled }, "another writer": func(c *hostclaim.Claim) { c.Holder.WriterBox = "src" }, "no attempt count": func(c *hostclaim.Claim) { delete(c.Op.Journal, "probe_attempts.dst") }, } { write(probeHealthy, nil) c := claim() change(c) if _, ok, err := passed(ctx, hostclaim.Observed{Host: "shop.example", Claim: c}); err == nil || ok { t.Fatalf("%s: %v %v", name, ok, err) } } path, _ := relocationAttemptsPath(dd, "op1") if err := os.WriteFile(path, []byte("{"), 0o600); err != nil { t.Fatal(err) } if _, ok, err := passed(ctx, hostclaim.Observed{Host: "shop.example", Claim: claim()}); err == nil || ok { t.Fatalf("an unreadable journal: %v %v", ok, err) } }