| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "crypto/ed25519" |
| 7 | "crypto/rand" |
| 8 | "errors" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "slices" |
| 12 | "strings" |
| 13 | "testing" |
| 14 | "time" |
| 15 | |
| 16 | "github.com/carlosframework/platform/internal/hostactors" |
| 17 | "github.com/carlosframework/platform/internal/hostbox" |
| 18 | "github.com/carlosframework/platform/internal/hostclaim" |
| 19 | "github.com/carlosframework/platform/internal/hostfloor" |
| 20 | "github.com/carlosframework/platform/internal/hostguard" |
| 21 | "github.com/carlosframework/platform/internal/hostops" |
| 22 | "github.com/carlosframework/platform/internal/hostrelocate" |
| 23 | "github.com/carlosframework/platform/internal/hostwrite" |
| 24 | "github.com/carlosframework/platform/internal/lease" |
| 25 | "github.com/carlosframework/platform/internal/lease/leasetest" |
| 26 | "github.com/carlosframework/platform/internal/registry" |
| 27 | "github.com/carlosframework/platform/internal/release" |
| 28 | ) |
| 29 | |
| 30 | // finishRecorder is a Finish that records each host it is handed and |
| 31 | // answers err. |
| 32 | type finishRecorder struct { |
| 33 | calls []string |
| 34 | err error |
| 35 | } |
| 36 | |
| 37 | func (f *finishRecorder) finish(_ context.Context, host, op string) error { |
| 38 | f.calls = append(f.calls, host+":"+op) |
| 39 | return f.err |
| 40 | } |
| 41 | |
| 42 | // Carry 8: the completion pass hands Finish only a claim serving on this |
| 43 | // box whose completion names this box. The completion-only runtime has no |
| 44 | // Box; it asks Self each pass, and a pass that cannot answer finishes |
| 45 | // nothing. |
| 46 | func TestCompletionPassFinishesOnlyWhatServesOnThisBox(t *testing.T) { |
| 47 | ctx := t.Context() |
| 48 | store := release.NewDirStore(t.TempDir()) |
| 49 | passClaim(t, store, "mine.example", hostclaim.Op{}) |
| 50 | completeOn(t, store, "mine.example", "rel-m", "dst", false) |
| 51 | // Serving on spa, its completion naming spa: spa's to finish. |
| 52 | passClaim(t, store, "theirs.example", hostclaim.Op{}) |
| 53 | completeOn(t, store, "theirs.example", "rel-t", "spa", false) |
| 54 | // Serving on spa while its record names dst: not serving here, so not |
| 55 | // this box's to finish, whatever the record says. |
| 56 | passClaim(t, store, "moved.example", hostclaim.Op{}) |
| 57 | o := completeOn(t, store, "moved.example", "rel-x", "dst", false) |
| 58 | if _, err := hostclaim.Update(ctx, store, o, func(c *hostclaim.Claim) error { c.Op.Exec = "spa"; return nil }); err != nil { |
| 59 | t.Fatal(err) |
| 60 | } |
| 61 | // Serving on dst while its record names spa: the completion is spa's. |
| 62 | passClaim(t, store, "foreign.example", hostclaim.Op{}) |
| 63 | o = completeOn(t, store, "foreign.example", "rel-f", "spa", false) |
| 64 | if _, err := hostclaim.Update(ctx, store, o, func(c *hostclaim.Claim) error { c.Op.Exec = "dst"; return nil }); err != nil { |
| 65 | t.Fatal(err) |
| 66 | } |
| 67 | f := &finishRecorder{} |
| 68 | hosts := func() ([]string, error) { |
| 69 | return []string{"mine.example", "theirs.example", "moved.example", "foreign.example"}, nil |
| 70 | } |
| 71 | r := &RelocationRuntime{Finish: f.finish, Hosts: hosts, Self: func(context.Context) (string, error) { return "dst", nil }} |
| 72 | r.once(ctx, store) |
| 73 | if !slices.Equal(f.calls, []string{"mine.example:rel-m"}) { |
| 74 | t.Fatalf("Finish was handed %v, want only mine.example", f.calls) |
| 75 | } |
| 76 | // No answer to "which box is this": nothing is finished. |
| 77 | f.calls = nil |
| 78 | buf := captureRacyLog(t) |
| 79 | r = &RelocationRuntime{Finish: f.finish, Hosts: hosts, Self: func(context.Context) (string, error) { return "", errors.New("no verified identity") }} |
| 80 | r.once(ctx, store) |
| 81 | if len(f.calls) != 0 || !strings.Contains(buf.String(), "no verified identity") { |
| 82 | t.Fatalf("finished %v without knowing this box:\n%s", f.calls, buf.String()) |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | // Carry 8 and review M4: ErrChanged from the hold release, after the |
| 87 | // pass's own serving-here check passed, is never taken as finished: it is |
| 88 | // said once (a completion stuck that way keeps the barrier closed) and |
| 89 | // asked again next pass. Every other refusal is alerted once and retried |
| 90 | // too (carry 6: a probe-fence owner Finish cannot find is ErrRefused). |
| 91 | func TestCompletionPassRetriesARefusedFinishAndAChangedClaim(t *testing.T) { |
| 92 | ctx := t.Context() |
| 93 | store := release.NewDirStore(t.TempDir()) |
| 94 | passClaim(t, store, "a.example", hostclaim.Op{}) |
| 95 | completeOn(t, store, "a.example", "rel-a", "dst", false) |
| 96 | buf := captureRacyLog(t) |
| 97 | f := &finishRecorder{err: hostbox.ErrChanged} |
| 98 | r := &RelocationRuntime{Finish: f.finish, Box: "dst"} |
| 99 | r.once(ctx, store) |
| 100 | r.once(ctx, store) |
| 101 | if len(f.calls) != 2 || strings.Count(buf.String(), "relocate a.example: finish: "+hostbox.ErrChanged.Error()) != 1 { |
| 102 | t.Fatalf("a changed claim: calls %v, log:\n%s", f.calls, buf.String()) |
| 103 | } |
| 104 | f.calls, f.err = nil, errors.Join(hostrelocate.ErrRefused, errors.New("a.example has no probe-fence owner")) |
| 105 | r.once(ctx, store) |
| 106 | r.once(ctx, store) |
| 107 | if len(f.calls) != 2 || strings.Count(buf.String(), "no probe-fence owner") != 1 { |
| 108 | t.Fatalf("a refused Finish: calls %v (want a retry each pass), log:\n%s", f.calls, buf.String()) |
| 109 | } |
| 110 | if obs, err := hostclaim.Read(ctx, store, "a.example"); err != nil { |
| 111 | t.Fatal(err) |
| 112 | } else if done, ok, err := hostclaim.ReadCompletedRelocation(obs.Claim); err != nil || !ok || done.Discharged { |
| 113 | t.Fatalf("the record was marked over a refusal: %+v %v %v", done, ok, err) |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | // Carry 1: a completion record that cannot be read is alerted and never |
| 118 | // read as "no completion": Finish is not called over it, and the next pass |
| 119 | // reads it again. |
| 120 | func TestCompletionPassAlertsAnUnreadableRecord(t *testing.T) { |
| 121 | ctx := t.Context() |
| 122 | store := release.NewDirStore(t.TempDir()) |
| 123 | passClaim(t, store, "a.example", hostclaim.Op{}) |
| 124 | o := completeOn(t, store, "a.example", "rel-a", "dst", false) |
| 125 | if _, err := hostclaim.Update(ctx, store, o, func(c *hostclaim.Claim) error { |
| 126 | c.Op.Journal[hostclaim.JournalCompleted] = "not a record" |
| 127 | return nil |
| 128 | }); err != nil { |
| 129 | t.Fatal(err) |
| 130 | } |
| 131 | buf := captureRacyLog(t) |
| 132 | f := &finishRecorder{} |
| 133 | r := &RelocationRuntime{Finish: f.finish, Box: "dst"} |
| 134 | r.once(ctx, store) |
| 135 | if len(f.calls) != 0 || !strings.Contains(buf.String(), "relocate a.example: finish:") { |
| 136 | t.Fatalf("an unreadable record: calls %v, log:\n%s", f.calls, buf.String()) |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | // Carry 3: the sweep never runs beside a relocation journal write. A |
| 141 | // write in flight (its directory made, its temporary not yet renamed) |
| 142 | // holds relocationJournals shared; the pass's sweep waits for it, then |
| 143 | // keeps the journal it committed. Without the lock the sweep removes the |
| 144 | // directory as a write's leftover under the write. |
| 145 | func TestRelocatePassSweepWaitsForAJournalWrite(t *testing.T) { |
| 146 | t.Parallel() |
| 147 | ctx := t.Context() |
| 148 | store := release.NewDirStore(t.TempDir()) |
| 149 | passClaim(t, store, "a.example", relocatingOp("rel-a")) |
| 150 | data := t.TempDir() |
| 151 | dir := filepath.Join(data, "relocate", "ops", "rel-a") |
| 152 | relocationJournals.RLock() |
| 153 | locked := true |
| 154 | defer func() { |
| 155 | if locked { |
| 156 | relocationJournals.RUnlock() |
| 157 | } |
| 158 | }() |
| 159 | if err := os.MkdirAll(dir, 0o700); err != nil { |
| 160 | t.Fatal(err) |
| 161 | } |
| 162 | temp := filepath.Join(dir, ".attempts.json.123") |
| 163 | if err := os.WriteFile(temp, []byte("{"), 0o600); err != nil { |
| 164 | t.Fatal(err) |
| 165 | } |
| 166 | r := &RelocationRuntime{Box: "dst", DataDir: data} |
| 167 | done := make(chan struct{}) |
| 168 | go func() { r.once(ctx, store); close(done) }() |
| 169 | select { |
| 170 | case <-done: |
| 171 | t.Fatal("the sweep ran beside a journal write") |
| 172 | case <-time.After(300 * time.Millisecond): |
| 173 | } |
| 174 | // The write lands: its journal committed, its temporary gone. |
| 175 | if err := os.WriteFile(filepath.Join(dir, "attempts.json"), []byte(`{"v":1,"host":"a.example","op":"rel-a"}`), 0o600); err != nil { |
| 176 | t.Fatal(err) |
| 177 | } |
| 178 | if err := os.Remove(temp); err != nil { |
| 179 | t.Fatal(err) |
| 180 | } |
| 181 | relocationJournals.RUnlock() |
| 182 | locked = false |
| 183 | <-done |
| 184 | if _, err := os.Stat(filepath.Join(dir, "attempts.json")); err != nil { |
| 185 | t.Fatalf("the running op's journal was swept: %v", err) |
| 186 | } |
| 187 | } |
| 188 | |
| 189 | // Carry 3, the writers' half: every relocation journal write this process |
| 190 | // makes outside the pass's own steps holds relocationJournals shared, so |
| 191 | // it waits while a sweep holds it. |
| 192 | func TestRelocationJournalWritesWaitForTheSweep(t *testing.T) { |
| 193 | t.Parallel() |
| 194 | data := t.TempDir() |
| 195 | fence, err := relocationProbeFencePath(data, "rel-a") |
| 196 | if err != nil { |
| 197 | t.Fatal(err) |
| 198 | } |
| 199 | positions := ReleasePositionsFile{Dir: filepath.Join(data, "relocate", "release")} |
| 200 | for name, write := range map[string]func() error{ |
| 201 | "attempts": func() error { |
| 202 | return relocationAttemptJournal{V: 1, Host: "a.example", Op: "rel-a", Opened: map[string]time.Time{}, Sessions: map[string]int{}, Starts: map[string]relocationStart{}}.write(data) |
| 203 | }, |
| 204 | "probe fence": func() error { _, err := relocationProbeFenceOwner(fence, "a.example", 7); return err }, |
| 205 | "positions": func() error { |
| 206 | return positions.Record(t.Context(), ReleasePosition{Request: StopRequest{Host: "a.example", OperationID: "rel-a"}, PID: 1, ProcessStart: "1"}) |
| 207 | }, |
| 208 | } { |
| 209 | t.Run(name, func(t *testing.T) { |
| 210 | relocationJournals.Lock() |
| 211 | done := make(chan error, 1) |
| 212 | go func() { done <- write() }() |
| 213 | select { |
| 214 | case err := <-done: |
| 215 | relocationJournals.Unlock() |
| 216 | t.Fatalf("wrote beside the sweep: %v", err) |
| 217 | case <-time.After(300 * time.Millisecond): |
| 218 | } |
| 219 | relocationJournals.Unlock() |
| 220 | if err := <-done; err != nil { |
| 221 | t.Fatal(err) |
| 222 | } |
| 223 | }) |
| 224 | } |
| 225 | } |
| 226 | |
| 227 | // Carry 4: the full runtime's config carries the agent's held-stage |
| 228 | // evidence: the release key it was given and the edge's pointer digest |
| 229 | // ratchet (pointerRatchet(false, ...), never root's), with the box's |
| 230 | // secrets key when it has one. No release key, or a secrets key that does |
| 231 | // not parse, refuses. |
| 232 | func TestRelocationRuntimeConfigBindsTheAgentsHeldStageEvidence(t *testing.T) { |
| 233 | pub, _, err := ed25519.GenerateKey(rand.Reader) |
| 234 | if err != nil { |
| 235 | t.Fatal(err) |
| 236 | } |
| 237 | a := &ClaimAssembly{} |
| 238 | a.Guard.DataDir = t.TempDir() |
| 239 | opt := t.TempDir() |
| 240 | env := map[string]string{} |
| 241 | getenv := func(k string) string { return env[k] } |
| 242 | s := relocationStores{Files: release.NewDirStore(t.TempDir()), Versions: leasetest.NewVersionedBucket(), Bucket: "data-ie", Region: "ie", LeaseRegion: "eu-west-1"} |
| 243 | discharge := func(context.Context, hostclaim.Observed) error { return nil } |
| 244 | build := func(in relocationRuntimeInputs) (RelocationAssemblyConfig, error) { |
| 245 | return relocationRuntimeConfig(getenv, a, ClaimProviders{Selector: hostbox.WorkSource{Box: "dst", Region: "ie"}}, s, ®istry.Registry{}, &relocationProbeActivator{}, hostactors.Directory{}, in, discharge) |
| 246 | } |
| 247 | cfg, err := build(relocationRuntimeInputs{Pub: pub, OptDir: opt}) |
| 248 | if err != nil { |
| 249 | t.Fatal(err) |
| 250 | } |
| 251 | if !bytes.Equal(cfg.ReleasePub, pub) || cfg.Ratchet == nil || cfg.Ratchet != pointerRatchet(false, opt, a.Guard.DataDir, getenv) || cfg.Ratchet == pointerRatchet(true, opt, a.Guard.DataDir, getenv) || cfg.SecretsKey != nil { |
| 252 | t.Fatalf("held-stage evidence: pub %v ratchet %p secrets %v", cfg.ReleasePub, cfg.Ratchet, cfg.SecretsKey) |
| 253 | } |
| 254 | if _, err := build(relocationRuntimeInputs{OptDir: opt}); !errors.Is(err, hostwrite.ErrUnavailable) { |
| 255 | t.Fatalf("no release key: %v", err) |
| 256 | } |
| 257 | env["CARLOS_SECRETS_KEY"] = "not a key" |
| 258 | if _, err := build(relocationRuntimeInputs{Pub: pub, OptDir: opt}); err == nil { |
| 259 | t.Fatal("a secrets key that does not parse was accepted") |
| 260 | } |
| 261 | } |
| 262 | |
| 263 | // With claim mode and region HA on and the relocation owners bound into |
| 264 | // the wake's dispatcher, NewRelocationRuntime builds the full pass: this |
| 265 | // box's worker in its roster region, which starts nothing (its Admission |
| 266 | // and Census refuse), the participant, the J3 conversion and the hold |
| 267 | // release. A roster entry that is not this box's enrollment, no release |
| 268 | // key, or no data bucket refuses rather than build a pass over a guess. |
| 269 | func TestRelocationRuntimeBuildsTheFullPassWithTheOwnersBound(t *testing.T) { |
| 270 | ctx := t.Context() |
| 271 | w := newLandingWorld(t, true, nil) |
| 272 | reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| 273 | if err != nil { |
| 274 | t.Fatal(err) |
| 275 | } |
| 276 | defer reg.Close() |
| 277 | data := t.TempDir() |
| 278 | a := &ClaimAssembly{Claims: w.store, Guard: hostguard.Guard{DataDir: data}, RetirementAuthority: &hostops.ProductionAuthority{Store: w.store, Directory: w.directory}, |
| 279 | Wake: &ClaimWakeAssembly{Inputs: ClaimOwnerDispatch{Relocation: &ClaimKindOwners{}}}} |
| 280 | local := &productionClaimLocal{legacyParticipation: true, assembly: a} |
| 281 | env := map[string]string{"CARLOS_HOST_CLAIM": "1", "CARLOS_REGION_HA": "1", "CARLOS_HIBERNATE_BUCKET": "data-ie"} |
| 282 | getenv := func(k string) string { return env[k] } |
| 283 | pub, _, err := ed25519.GenerateKey(rand.Reader) |
| 284 | if err != nil { |
| 285 | t.Fatal(err) |
| 286 | } |
| 287 | in := relocationRuntimeInputs{Enrollment: hostfloor.Enrollment{Box: "wrk", Enrollment: w.enrollment["wrk"]}, Pub: pub, OptDir: t.TempDir(), |
| 288 | Files: func(context.Context, string) (release.Store, error) { return release.NewDirStore(t.TempDir()), nil }, |
| 289 | Versioned: func(context.Context, string, string) (lease.VersionedStore, error) { |
| 290 | return leasetest.NewVersionedBucket(), nil |
| 291 | }} |
| 292 | providers := ClaimProviders{Selector: hostbox.WorkSource{Box: "wrk", Region: "ie"}} |
| 293 | build := func(in relocationRuntimeInputs) (*RelocationRuntime, error) { |
| 294 | return NewRelocationRuntime(ctx, getenv, w.store, local, providers, reg, &relocationProbeActivator{}, in) |
| 295 | } |
| 296 | r, err := build(in) |
| 297 | if err != nil { |
| 298 | t.Fatal(err) |
| 299 | } |
| 300 | worker, ok := r.Worker.(hostrelocate.Worker) |
| 301 | if !ok || r.Participant == nil || r.Convert == nil || r.Finish == nil || r.Box != "wrk" || r.DataDir != data || r.Hosts != nil { |
| 302 | t.Fatalf("not the full pass: %+v", r) |
| 303 | } |
| 304 | // Review M7: the held-stage evidence reaches the full pass's config. |
| 305 | if !bytes.Equal(r.config.ReleasePub, pub) || r.config.Ratchet == nil || r.config.Ratchet != pointerRatchet(false, in.OptDir, data, getenv) { |
| 306 | t.Fatalf("the full pass's held-stage evidence: pub %v ratchet %p", r.config.ReleasePub, r.config.Ratchet) |
| 307 | } |
| 308 | if worker.Self.Box != "wrk" || worker.Self.Enrollment != w.enrollment["wrk"] || worker.Region != "ie" { |
| 309 | t.Fatalf("the worker is not this box in its roster region: %+v %q", worker.Self, worker.Region) |
| 310 | } |
| 311 | if _, err := worker.Admission.Register(ctx, hostclaim.Observed{}, hostrelocate.Owner{}, ""); !errors.Is(err, errRelocationNotStarted) { |
| 312 | t.Fatalf("the pass's admission registered a start: %v", err) |
| 313 | } |
| 314 | if err := worker.Census(ctx, "wrk", nil); !errors.Is(err, errRelocationNotStarted) { |
| 315 | t.Fatalf("the pass's census passed a start: %v", err) |
| 316 | } |
| 317 | for name, change := range map[string]func(*relocationRuntimeInputs){ |
| 318 | "another enrollment": func(in *relocationRuntimeInputs) { in.Enrollment.Enrollment = strings.Repeat("f", 32) }, |
| 319 | "not in the roster": func(in *relocationRuntimeInputs) { in.Enrollment.Box = "gone" }, |
| 320 | "no release key": func(in *relocationRuntimeInputs) { in.Pub = nil }, |
| 321 | } { |
| 322 | bad := in |
| 323 | change(&bad) |
| 324 | if r, err := build(bad); err == nil || r != nil { |
| 325 | t.Fatalf("%s: built %+v", name, r) |
| 326 | } |
| 327 | } |
| 328 | // Region HA off: the completion-only runtime, though the owners are |
| 329 | // bound (ruling 82; the dormant constraint). |
| 330 | env["CARLOS_REGION_HA"] = "" |
| 331 | if r, err := build(in); err != nil || r == nil || r.Worker != nil || r.Participant != nil || r.Convert != nil || r.Finish == nil { |
| 332 | t.Fatalf("region HA off: %+v %v", r, err) |
| 333 | } |
| 334 | env["CARLOS_REGION_HA"] = "1" |
| 335 | delete(env, "CARLOS_HIBERNATE_BUCKET") |
| 336 | if r, err := build(in); !errors.Is(err, hostwrite.ErrUnavailable) || r != nil { |
| 337 | t.Fatalf("no data bucket: %+v %v", r, err) |
| 338 | } |
| 339 | } |
| 340 | |