| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "net" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "strconv" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/cgroup" |
| 15 | "github.com/carlosframework/platform/internal/hostbox" |
| 16 | "github.com/carlosframework/platform/internal/hostclaim" |
| 17 | "github.com/carlosframework/platform/internal/hostguard" |
| 18 | "github.com/carlosframework/platform/internal/hostrelocate" |
| 19 | "github.com/carlosframework/platform/internal/provision" |
| 20 | ) |
| 21 | |
| 22 | // Finding 14: every journal field has its own JSON name and survives a |
| 23 | // round trip; a journal for another host or op is refused. |
| 24 | func TestRelocationAttemptJournalRoundTrips(t *testing.T) { |
| 25 | dd := t.TempDir() |
| 26 | j, err := readRelocationAttempts(dd, "shop.example", "op1") |
| 27 | if err != nil { |
| 28 | t.Fatal(err) |
| 29 | } |
| 30 | j.Opened["1"] = time.Date(2026, 10, 10, 9, 0, 0, 0, time.UTC) |
| 31 | j.Sessions["7"] = 1 |
| 32 | j.Starts["1"] = relocationStart{Session: 7, Generation: "P1", Stamp: 9, State: probeReserved, Attempt: hostclaim.WakeRetryAttempt{ID: "a", InvocationID: "b", Session: 7, Box: "dst"}} |
| 33 | if err := j.write(dd); err != nil { |
| 34 | t.Fatal(err) |
| 35 | } |
| 36 | raw, _ := os.ReadFile(filepath.Join(dd, "relocate", "ops", "op1", "attempts.json")) |
| 37 | for _, key := range []string{`"host":"shop.example"`, `"op":"op1"`} { |
| 38 | if !strings.Contains(string(raw), key) { |
| 39 | t.Fatalf("%s missing from %s", key, raw) |
| 40 | } |
| 41 | } |
| 42 | got, err := readRelocationAttempts(dd, "shop.example", "op1") |
| 43 | if err != nil || got.Starts["1"] != j.Starts["1"] || got.Sessions["7"] != 1 || !got.Opened["1"].Equal(j.Opened["1"]) { |
| 44 | t.Fatalf("round trip: %+v %v", got, err) |
| 45 | } |
| 46 | if _, err := readRelocationAttempts(dd, "other.example", "op1"); !errors.Is(err, hostbox.ErrChanged) { |
| 47 | t.Fatalf("another host's journal was read: %v", err) |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | // Finding 7: root's reader returns the reserved start only for the exact |
| 52 | // op at probing, the executor's attempt k, session, generation and stamp. |
| 53 | func TestRootReadsTheRelocationAttemptJournal(t *testing.T) { |
| 54 | dd := t.TempDir() |
| 55 | j, _ := readRelocationAttempts(dd, "shop.example", "op1") |
| 56 | j.Sessions["7"] = 1 |
| 57 | attempt := hostclaim.WakeRetryAttempt{ID: "a", InvocationID: "b", Session: 7, Box: "dst"} |
| 58 | j.Starts["1"] = relocationStart{Session: 7, Generation: "P1", Stamp: 9, State: probeReserved, Attempt: attempt} |
| 59 | if err := j.write(dd); err != nil { |
| 60 | t.Fatal(err) |
| 61 | } |
| 62 | claim := func() *hostclaim.Claim { |
| 63 | return &hostclaim.Claim{Host: "shop.example", Holder: &hostclaim.Holder{Incarnation: "inc", Stamp: 9, Session: 7, Generation: "P1", WriterBox: "dst"}, |
| 64 | 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"}}} |
| 65 | } |
| 66 | r := relocationAttemptReader{DataDir: dd} |
| 67 | got, err := r.ReadAttempt(context.Background(), hostclaim.Observed{Host: "shop.example", Claim: claim()}) |
| 68 | if err != nil || got != attempt { |
| 69 | t.Fatalf("read %+v %v", got, err) |
| 70 | } |
| 71 | // Astra round 2 finding 4: a later cold attempt runs at its own |
| 72 | // admission's stamp; the reader takes the journaled start at that |
| 73 | // stamp (root's claimLaunchAuthority pin is claim ask C11). |
| 74 | j2, _ := readRelocationAttempts(dd, "shop.example", "op1") |
| 75 | j2.Sessions["14"] = 2 |
| 76 | later := hostclaim.WakeRetryAttempt{ID: "c", InvocationID: "d", Session: 14, Box: "dst"} |
| 77 | j2.Starts["2"] = relocationStart{Session: 14, Generation: "g2", Stamp: 14, State: probeReserved, Attempt: later} |
| 78 | if err := j2.write(dd); err != nil { |
| 79 | t.Fatal(err) |
| 80 | } |
| 81 | second := claim() |
| 82 | second.Holder.Stamp, second.Holder.Session, second.Holder.Generation = 14, 14, "g2" |
| 83 | second.Op.Journal["probe_attempts.dst"] = "2" |
| 84 | second.Op.Journal["prepared_opened"] = "P1" |
| 85 | if got, err := r.ReadAttempt(context.Background(), hostclaim.Observed{Host: "shop.example", Claim: second}); err != nil || got != later { |
| 86 | t.Fatalf("second attempt: %+v %v", got, err) |
| 87 | } |
| 88 | for name, change := range map[string]func(*hostclaim.Claim){ |
| 89 | "another session": func(c *hostclaim.Claim) { c.Holder.Session = 8 }, |
| 90 | "another generation": func(c *hostclaim.Claim) { c.Holder.Generation = "g2" }, |
| 91 | "another stamp": func(c *hostclaim.Claim) { c.Holder.Stamp = 10; c.Op.Journal["install_stamp"] = "10" }, |
| 92 | "P at a stamp not the install stamp": func(c *hostclaim.Claim) { c.Op.Journal["install_stamp"] = "8" }, |
| 93 | "a later attempt": func(c *hostclaim.Claim) { c.Op.Journal["probe_attempts.dst"] = "2" }, |
| 94 | "installed": func(c *hostclaim.Claim) { c.Op.Phase = hostclaim.PhaseInstalled }, |
| 95 | "stuck": func(c *hostclaim.Claim) { c.Op.Journal["stuck"] = "x" }, |
| 96 | } { |
| 97 | c := claim() |
| 98 | change(c) |
| 99 | if _, err := r.ReadAttempt(context.Background(), hostclaim.Observed{Host: "shop.example", Claim: c}); err == nil { |
| 100 | t.Fatalf("%s: read", name) |
| 101 | } |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | // Finding 4: a relocation target's route is the nested layout, and the |
| 106 | // files and env paths are the instance directory's. |
| 107 | func TestRelocationRouteIsNestedAndPathsAgree(t *testing.T) { |
| 108 | dd := "/data" |
| 109 | rt := provision.RouteFor(provision.Record{Host: "shop.example", App: "shop", Account: "acct", Channel: "stable"}, dd, "", true, nil) |
| 110 | if rt.DBPath != provision.NestedDBPath(dd, "shop.example") || rt.Socket != provision.NestedSocket("shop.example") { |
| 111 | t.Fatalf("not nested: %+v", rt) |
| 112 | } |
| 113 | p := relocationPathsFor(dd, rt) |
| 114 | dir := provision.InstanceDir(dd, "shop.example") |
| 115 | if p.FilesDir != filepath.Join(dir, "shop.example") || p.EnvPath != filepath.Join(dir, "shop.example.env") { |
| 116 | t.Fatalf("paths %+v", p) |
| 117 | } |
| 118 | if _, err := claimIsolatedPaths(map[string]string{"data": dd}, "shop.example", ClaimLaunchInputs{DBPath: rt.DBPath, Limit: cgroup.Limit{MemoryMax: cgroup.TierDefault.MemoryMax}}); err != nil { |
| 119 | t.Fatalf("the isolated launch refuses the minted route: %v", err) |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | // Finding 7, ruling 48: root installs the relocation attempt reader only |
| 124 | // with region HA on; its launch capability answers for kind relocating |
| 125 | // only then. |
| 126 | func TestRootInstallsTheRelocationReaderOnlyWithRegionHA(t *testing.T) { |
| 127 | journal, err := hostbox.OpenLaunchJournal(filepath.Join(t.TempDir(), "launch.db")) |
| 128 | if err != nil { |
| 129 | t.Fatal(err) |
| 130 | } |
| 131 | defer journal.Close() |
| 132 | inner := stopKernelFunc(func(context.Context, hostguard.FenceTarget, StopInventory, DrainAcknowledgement) (StopKernelEvidence, error) { |
| 133 | return StopKernelEvidence{}, nil |
| 134 | }) |
| 135 | root := ClaimRootHTTP{Launches: journal, Stop: ClaimStopHTTP{Authenticate: func(context.Context, net.Conn) (StopPeer, error) { return StopPeer{}, nil }, |
| 136 | Coordinator: ClaimStopCoordinator{Kernel: inner, Guard: hostguard.Guard{DataDir: "/data", Claims: &launchClaims{}}}}} |
| 137 | actor := func(context.Context) (hostbox.Actor, error) { return hostbox.Actor{}, nil } |
| 138 | saved := claimLaunchEnv |
| 139 | t.Cleanup(func() { claimLaunchEnv = saved }) |
| 140 | q := hostbox.RoleLaunchRequest{V: hostbox.RoleLaunchV, Op: hostbox.RoleLaunchCapability, Kind: hostclaim.KindRelocating} |
| 141 | for _, on := range []bool{true, false} { |
| 142 | value := "" |
| 143 | if on { |
| 144 | value = "1" |
| 145 | } |
| 146 | claimLaunchEnv = func(k string) string { |
| 147 | if k == "CARLOS_REGION_HA" { |
| 148 | return value |
| 149 | } |
| 150 | return "" |
| 151 | } |
| 152 | wrapped, err := claimLaunchFor(root, actor, map[string]string{"opt": "/opt/carlos", "data": "/data"}) |
| 153 | if err != nil { |
| 154 | t.Fatal(err) |
| 155 | } |
| 156 | reader, installed := wrapped.Launch.Attempts[hostclaim.KindRelocating].(relocationAttemptReader) |
| 157 | if installed != on || (on && reader.DataDir != "/data") || (!on && wrapped.Launch.Attempts != nil) { |
| 158 | t.Fatalf("region HA %v: attempts %+v", on, wrapped.Launch.Attempts) |
| 159 | } |
| 160 | f := newLaunchFixture(t, servingOp()) |
| 161 | f.root.Attempts = wrapped.Launch.Attempts |
| 162 | _, err = f.root.Serve(t.Context(), f.peer, q) |
| 163 | if on && err != nil { |
| 164 | t.Fatalf("the relocating capability with region HA on: %v", err) |
| 165 | } |
| 166 | if !on && !errors.Is(err, hostbox.ErrUnsupported) { |
| 167 | t.Fatalf("the relocating capability with region HA off: %v", err) |
| 168 | } |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | // Absence is never evidence: root's reader answers nothing for an |
| 173 | // operation whose journal is missing, unreadable or malformed, and the |
| 174 | // journal's own settles and restarts refuse once it is gone. (A fresh |
| 175 | // journal for a missing file is the writer's first state; the reader |
| 176 | // refuses it because no session is bound, and this pins that direction.) |
| 177 | func TestRootReaderRefusesAMissingOrUnreadableJournal(t *testing.T) { |
| 178 | ctx := context.Background() |
| 179 | claim := func() *hostclaim.Claim { |
| 180 | return &hostclaim.Claim{Host: "shop.example", Holder: &hostclaim.Holder{Incarnation: "inc", Stamp: 9, Session: 7, Generation: "P1", WriterBox: "dst"}, |
| 181 | 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"}}} |
| 182 | } |
| 183 | dd := t.TempDir() |
| 184 | r := relocationAttemptReader{DataDir: dd} |
| 185 | o := hostclaim.Observed{Host: "shop.example", Claim: claim()} |
| 186 | if _, err := r.ReadAttempt(ctx, o); !errors.Is(err, hostbox.ErrChanged) { |
| 187 | t.Fatalf("no journal: %v", err) |
| 188 | } |
| 189 | path, _ := relocationAttemptsPath(dd, "op1") |
| 190 | if err := os.MkdirAll(path, 0o700); err != nil { |
| 191 | t.Fatal(err) |
| 192 | } |
| 193 | if _, err := r.ReadAttempt(ctx, o); err == nil { |
| 194 | t.Fatal("a journal that cannot be read answered") |
| 195 | } |
| 196 | if err := os.Remove(path); err != nil { |
| 197 | t.Fatal(err) |
| 198 | } |
| 199 | if err := os.WriteFile(path, []byte(`{"v":1,"host":"shop.example","op":"op1"`), 0o600); err != nil { |
| 200 | t.Fatal(err) |
| 201 | } |
| 202 | if _, err := r.ReadAttempt(ctx, o); !errors.Is(err, hostbox.ErrChanged) { |
| 203 | t.Fatalf("a malformed journal: %v", err) |
| 204 | } |
| 205 | // A journal with its maps missing is malformed too, not empty. |
| 206 | if err := os.WriteFile(path, []byte(`{"v":1,"host":"shop.example","op":"op1"}`), 0o600); err != nil { |
| 207 | t.Fatal(err) |
| 208 | } |
| 209 | if _, err := readRelocationAttempts(dd, "shop.example", "op1"); !errors.Is(err, hostbox.ErrChanged) { |
| 210 | t.Fatalf("a journal without its maps: %v", err) |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | // Root takes a start only when the journal vouches for it twice: the |
| 215 | // holder's session is bound to the record attempt k, and k's current |
| 216 | // start is still reserved. A well-formed start under an unbound session, |
| 217 | // a session bound to another attempt, or a start already settled failed |
| 218 | // or healthy, answers nothing. |
| 219 | func TestRootReaderNeedsABoundReservedStart(t *testing.T) { |
| 220 | ctx := context.Background() |
| 221 | attempt := hostclaim.WakeRetryAttempt{ID: "a", InvocationID: "b", Session: 7, Box: "dst"} |
| 222 | claim := &hostclaim.Claim{Host: "shop.example", Holder: &hostclaim.Holder{Incarnation: "inc", Stamp: 9, Session: 7, Generation: "P1", WriterBox: "dst"}, |
| 223 | 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"}}} |
| 224 | o := hostclaim.Observed{Host: "shop.example", Claim: claim} |
| 225 | for name, shape := range map[string]func(*relocationAttemptJournal){ |
| 226 | "unbound session": func(j *relocationAttemptJournal) { delete(j.Sessions, "7") }, |
| 227 | "session bound to another attempt": func(j *relocationAttemptJournal) { j.Sessions["7"] = 2 }, |
| 228 | "a failed start": func(j *relocationAttemptJournal) { s := j.Starts["1"]; s.State = probeFailed; j.Starts["1"] = s }, |
| 229 | "a healthy start": func(j *relocationAttemptJournal) { s := j.Starts["1"]; s.State = probeHealthy; j.Starts["1"] = s }, |
| 230 | "a start under another session": func(j *relocationAttemptJournal) { s := j.Starts["1"]; s.Session = 8; j.Starts["1"] = s }, |
| 231 | "a start at another stamp": func(j *relocationAttemptJournal) { s := j.Starts["1"]; s.Stamp = 10; j.Starts["1"] = s }, |
| 232 | "an attempt for another session": func(j *relocationAttemptJournal) { s := j.Starts["1"]; s.Attempt.Session = 8; j.Starts["1"] = s }, |
| 233 | "an attempt for another box": func(j *relocationAttemptJournal) { s := j.Starts["1"]; s.Attempt.Box = "src"; j.Starts["1"] = s }, |
| 234 | "a journal for another op": func(j *relocationAttemptJournal) { j.Op = "op2" }, |
| 235 | } { |
| 236 | dd := t.TempDir() |
| 237 | j, _ := readRelocationAttempts(dd, "shop.example", "op1") |
| 238 | j.Sessions["7"] = 1 |
| 239 | j.Starts["1"] = relocationStart{Session: 7, Generation: "P1", Stamp: 9, State: probeReserved, Attempt: attempt} |
| 240 | shape(&j) |
| 241 | if err := j.write(dd); err != nil { |
| 242 | t.Fatal(err) |
| 243 | } |
| 244 | if got, err := (relocationAttemptReader{DataDir: dd}).ReadAttempt(ctx, o); !errors.Is(err, hostbox.ErrChanged) { |
| 245 | t.Fatalf("%s: read %+v %v", name, got, err) |
| 246 | } |
| 247 | } |
| 248 | // The record attempt is root's claim read, bounded by MaxProbeAttempts. |
| 249 | dd := t.TempDir() |
| 250 | j, _ := readRelocationAttempts(dd, "shop.example", "op1") |
| 251 | j.Sessions["7"] = hostclaim.MaxProbeAttempts + 1 |
| 252 | j.Starts[strconv.Itoa(hostclaim.MaxProbeAttempts+1)] = relocationStart{Session: 7, Generation: "P1", Stamp: 9, State: probeReserved, Attempt: attempt} |
| 253 | if err := j.write(dd); err != nil { |
| 254 | t.Fatal(err) |
| 255 | } |
| 256 | claim.Op.Journal["probe_attempts.dst"] = strconv.Itoa(hostclaim.MaxProbeAttempts + 1) |
| 257 | if _, err := (relocationAttemptReader{DataDir: dd}).ReadAttempt(ctx, o); !errors.Is(err, hostbox.ErrChanged) { |
| 258 | t.Fatalf("an attempt past the bound: %v", err) |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | // Final review A2: the passed probe is recovered from the journal only as |
| 263 | // the healthy start of the executor's record attempt bound to the holder's |
| 264 | // session, generation and stamp; any other start is no probe; a missing or |
| 265 | // unreadable journal after P's opening is a refusal, never "no probe". |
| 266 | func TestRelocationPassedProbeReadsTheHealthyStartForTheAdmittedSession(t *testing.T) { |
| 267 | ctx := context.Background() |
| 268 | dd := t.TempDir() |
| 269 | claim := func() *hostclaim.Claim { |
| 270 | return &hostclaim.Claim{Host: "shop.example", Holder: &hostclaim.Holder{Incarnation: "inc", Stamp: 9, Session: 7, Generation: "P1", WriterBox: "dst"}, |
| 271 | Op: hostclaim.Op{Kind: hostclaim.KindRelocating, ID: "op1", Phase: hostclaim.PhaseRelocatingProbing, Exec: "dst", |
| 272 | Journal: map[string]string{"install_stamp": "9", "prepared": "P1", "prepared_opened": "P1", "probe_attempts.dst": "1"}}} |
| 273 | } |
| 274 | passed := relocationPassedProbe(dd) |
| 275 | if _, _, err := passed(ctx, hostclaim.Observed{Host: "shop.example", Claim: claim()}); !errors.Is(err, hostrelocate.ErrRefused) { |
| 276 | t.Fatalf("a missing journal after P's opening: %v", err) |
| 277 | } |
| 278 | write := func(state string, change func(*relocationStart)) { |
| 279 | t.Helper() |
| 280 | j, _ := readRelocationAttempts(dd, "shop.example", "op1") |
| 281 | j.Sessions["7"] = 1 |
| 282 | st := relocationStart{Session: 7, Generation: "P1", Stamp: 9, State: state, Attempt: hostclaim.WakeRetryAttempt{ID: "a", InvocationID: "b", Session: 7, Box: "dst"}} |
| 283 | if change != nil { |
| 284 | change(&st) |
| 285 | } |
| 286 | j.Starts["1"] = st |
| 287 | if err := j.write(dd); err != nil { |
| 288 | t.Fatal(err) |
| 289 | } |
| 290 | } |
| 291 | write(probeHealthy, nil) |
| 292 | if s, ok, err := passed(ctx, hostclaim.Observed{Host: "shop.example", Claim: claim()}); err != nil || !ok || s != 7 { |
| 293 | t.Fatalf("the healthy start: %d %v %v", s, ok, err) |
| 294 | } |
| 295 | for name, tc := range map[string]struct { |
| 296 | state string |
| 297 | change func(*relocationStart) |
| 298 | claim func(*hostclaim.Claim) |
| 299 | }{ |
| 300 | "reserved": {probeReserved, nil, nil}, |
| 301 | "failed": {probeFailed, nil, nil}, |
| 302 | "another session": {probeHealthy, func(s *relocationStart) { s.Session = 8; s.Attempt.Session = 8 }, nil}, |
| 303 | "another generation": {probeHealthy, func(s *relocationStart) { s.Generation = "g2" }, nil}, |
| 304 | "another stamp": {probeHealthy, func(s *relocationStart) { s.Stamp = 10 }, nil}, |
| 305 | "another box's attempt": {probeHealthy, func(s *relocationStart) { s.Attempt.Box = "src" }, nil}, |
| 306 | "a later record attempt": {probeHealthy, nil, func(c *hostclaim.Claim) { c.Op.Journal["probe_attempts.dst"] = "2" }}, |
| 307 | } { |
| 308 | write(tc.state, tc.change) |
| 309 | c := claim() |
| 310 | if tc.claim != nil { |
| 311 | tc.claim(c) |
| 312 | } |
| 313 | if s, ok, err := passed(ctx, hostclaim.Observed{Host: "shop.example", Claim: c}); err != nil || ok || s != 0 { |
| 314 | t.Fatalf("%s: %d %v %v", name, s, ok, err) |
| 315 | } |
| 316 | } |
| 317 | for name, change := range map[string]func(*hostclaim.Claim){ |
| 318 | "P not opened": func(c *hostclaim.Claim) { delete(c.Op.Journal, "prepared_opened") }, |
| 319 | "installed": func(c *hostclaim.Claim) { c.Op.Phase = hostclaim.PhaseInstalled }, |
| 320 | "another writer": func(c *hostclaim.Claim) { c.Holder.WriterBox = "src" }, |
| 321 | "no attempt count": func(c *hostclaim.Claim) { delete(c.Op.Journal, "probe_attempts.dst") }, |
| 322 | } { |
| 323 | write(probeHealthy, nil) |
| 324 | c := claim() |
| 325 | change(c) |
| 326 | if _, ok, err := passed(ctx, hostclaim.Observed{Host: "shop.example", Claim: c}); err == nil || ok { |
| 327 | t.Fatalf("%s: %v %v", name, ok, err) |
| 328 | } |
| 329 | } |
| 330 | path, _ := relocationAttemptsPath(dd, "op1") |
| 331 | if err := os.WriteFile(path, []byte("{"), 0o600); err != nil { |
| 332 | t.Fatal(err) |
| 333 | } |
| 334 | if _, ok, err := passed(ctx, hostclaim.Observed{Host: "shop.example", Claim: claim()}); err == nil || ok { |
| 335 | t.Fatalf("an unreadable journal: %v %v", ok, err) |
| 336 | } |
| 337 | } |
| 338 | |