| 1 | package hostrelocate |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "slices" |
| 8 | "testing" |
| 9 | |
| 10 | "github.com/carlosframework/platform/internal/hostclaim" |
| 11 | "github.com/carlosframework/platform/internal/release" |
| 12 | ) |
| 13 | |
| 14 | // driveToProbing runs a drain until dst's participant is in probing with |
| 15 | // its local install recorded and no attempt counted. |
| 16 | func driveToProbing(t *testing.T, f *fixture, src, dst *fakeBox) (Participant, Participant) { |
| 17 | t.Helper() |
| 18 | ctx := context.Background() |
| 19 | if _, err := f.w.Start(ctx, host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}); err != nil { |
| 20 | t.Fatal(err) |
| 21 | } |
| 22 | ps := Participant{Claims: f.s, Box: "src", Enrollment: enrollment("src"), Effects: src, Now: f.clock} |
| 23 | pd := Participant{Claims: f.s, Box: "dst", Enrollment: enrollment("dst"), Effects: dst, Now: f.clock} |
| 24 | for i := 0; i < 40; i++ { |
| 25 | if o := f.claim(); o.Claim.Op.Phase == hostclaim.PhaseRelocatingProbing { |
| 26 | return ps, pd |
| 27 | } |
| 28 | f.w.Step(ctx, host) |
| 29 | ps.Step(ctx, host) |
| 30 | if f.claim().Claim.Op.Phase == hostclaim.PhaseInstalled && f.claim().Claim.Op.Journal["local_install"] != "" { |
| 31 | pd.Step(ctx, host) // ToProbing |
| 32 | continue |
| 33 | } |
| 34 | pd.Step(ctx, host) |
| 35 | } |
| 36 | t.Fatalf("never probing: %+v", f.claim().Claim.Op) |
| 37 | return ps, pd |
| 38 | } |
| 39 | |
| 40 | // Ruling 48: while root refuses the relocating kind, the participant |
| 41 | // counts no attempt; once it answers, the next pass counts one. |
| 42 | func TestParticipantCountsNoAttemptWhileRootRefusesTheKind(t *testing.T) { |
| 43 | f := newFixture(t) |
| 44 | ctx := context.Background() |
| 45 | src, dst := &fakeBox{f: f, box: "src"}, &fakeBox{f: f, box: "dst", preflightErr: errors.New("root: relocating unsupported")} |
| 46 | _, pd := driveToProbing(t, f, src, dst) |
| 47 | for i := 0; i < 3; i++ { |
| 48 | if _, err := pd.Step(ctx, host); !errors.Is(err, ErrNotYet) { |
| 49 | t.Fatalf("pass %d: %v", i, err) |
| 50 | } |
| 51 | if n := f.claim().Claim.Op.Journal["probe_attempts.dst"]; n != "" { |
| 52 | t.Fatalf("an attempt was counted while root refused: %s", n) |
| 53 | } |
| 54 | } |
| 55 | dst.preflightErr = nil |
| 56 | if _, err := pd.Step(ctx, host); err != nil && !errors.Is(err, ErrNotYet) { |
| 57 | t.Fatal(err) |
| 58 | } |
| 59 | if n := f.claim().Claim.Op.Journal["probe_attempts.dst"]; n != "1" { |
| 60 | t.Fatalf("attempts after root answered: %q", n) |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | // Ruling 58: once P's opening failed, an attempt root cannot launch is not |
| 65 | // made; the operation goes probe-failed at once, with one attempt counted. |
| 66 | func TestParticipantFailsAProbeRootCannotRelaunch(t *testing.T) { |
| 67 | f := newFixture(t) |
| 68 | ctx := context.Background() |
| 69 | src, dst := &fakeBox{f: f, box: "src"}, &fakeBox{f: f, box: "dst", failProbes: true} |
| 70 | _, pd := driveToProbing(t, f, src, dst) |
| 71 | if _, err := pd.Step(ctx, host); !errors.Is(err, ErrNotYet) { |
| 72 | t.Fatalf("the first attempt: %v", err) |
| 73 | } |
| 74 | if !hostclaim.PreparedOpened(f.claim().Claim) { |
| 75 | t.Fatal("P was not opened by the first attempt") |
| 76 | } |
| 77 | dst.preflightErr = ErrLaterAttemptUnlaunchable |
| 78 | o, err := pd.Step(ctx, host) |
| 79 | if err != nil { |
| 80 | t.Fatal(err) |
| 81 | } |
| 82 | if o.Claim.Op.Phase != hostclaim.PhaseProbeFailed || o.Claim.Op.Journal["probe_attempts.dst"] != "1" || len(dst.certified) != 1 { |
| 83 | t.Fatalf("phase %s attempts %q admissions %d", o.Claim.Op.Phase, o.Claim.Op.Journal["probe_attempts.dst"], len(dst.certified)) |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | // flakyStore is the claim store with one failure armed: the next Get (the |
| 88 | // read after a passed probe) or Put (the CAS that records it) fails once, |
| 89 | // as a transient store error would, and the store is itself again. |
| 90 | type flakyStore struct { |
| 91 | release.Store |
| 92 | armed bool |
| 93 | puts bool // fail a Put rather than a Get |
| 94 | } |
| 95 | |
| 96 | var errFlaky = errors.New("the claim store failed once") |
| 97 | |
| 98 | func (s *flakyStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 99 | if s.armed && !s.puts { |
| 100 | s.armed = false |
| 101 | return nil, "", errFlaky |
| 102 | } |
| 103 | return s.Store.Get(ctx, key) |
| 104 | } |
| 105 | |
| 106 | func (s *flakyStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| 107 | if s.armed && s.puts { |
| 108 | s.armed = false |
| 109 | return "", errFlaky |
| 110 | } |
| 111 | return s.Store.Put(ctx, key, body, ifMatch) |
| 112 | } |
| 113 | |
| 114 | // Final review A2 (Astra 2): after Probe returned passed, the claim read |
| 115 | // or the CAS that records the result fails once. The next pass recovers |
| 116 | // the result from the box's durable attempt record, keyed by the admitted |
| 117 | // session, and journals it: no Preflight, no second attempt, no |
| 118 | // probe-failed, and the live session is the one journaled. A record that |
| 119 | // cannot be read is retried, never taken as "no probe". |
| 120 | func TestParticipantRecoversAPassedProbeTheClaimDidNotRecord(t *testing.T) { |
| 121 | for name, puts := range map[string]bool{"the claim read fails once": false, "the CAS fails once": true} { |
| 122 | t.Run(name, func(t *testing.T) { |
| 123 | f := newFixture(t) |
| 124 | ctx := context.Background() |
| 125 | flaky := &flakyStore{Store: f.s, puts: puts} |
| 126 | src, dst := &fakeBox{f: f, box: "src"}, &fakeBox{f: f, box: "dst", flaky: flaky} |
| 127 | _, pd := driveToProbing(t, f, src, dst) |
| 128 | pd.Claims = flaky |
| 129 | if _, err := pd.Step(ctx, host); !errors.Is(err, errFlaky) { |
| 130 | t.Fatalf("the pass after the probe passed: %v", err) |
| 131 | } |
| 132 | c := f.claim().Claim |
| 133 | if !hostclaim.PreparedOpened(c) || c.Op.Journal["probe"] != "" || flaky.armed { |
| 134 | t.Fatalf("after the failure: opened %v, probe %q, armed %v", hostclaim.PreparedOpened(c), c.Op.Journal["probe"], flaky.armed) |
| 135 | } |
| 136 | dst.preflightErr = ErrLaterAttemptUnlaunchable // what root would answer a new attempt |
| 137 | dst.effects = nil |
| 138 | dst.passedErr = errors.New("the attempt journal cannot be read") |
| 139 | if _, err := pd.Step(ctx, host); !errors.Is(err, dst.passedErr) { |
| 140 | t.Fatalf("an unreadable record: %v", err) |
| 141 | } |
| 142 | dst.passedErr = nil |
| 143 | o, err := pd.Step(ctx, host) |
| 144 | if err != nil { |
| 145 | t.Fatalf("the recovering pass: %v", err) |
| 146 | } |
| 147 | var p hostclaim.Probe |
| 148 | if o.Claim.Op.Phase != hostclaim.PhaseRelocatingProbing || json.Unmarshal([]byte(o.Claim.Op.Journal["probe"]), &p) != nil || p.Session != o.Claim.Holder.Session || p.Box != "dst" || p.At.IsZero() { |
| 149 | t.Fatalf("phase %s probe %q", o.Claim.Op.Phase, o.Claim.Op.Journal["probe"]) |
| 150 | } |
| 151 | if len(dst.certified) != 1 || o.Claim.Op.Journal["probe_attempts.dst"] != "1" || slices.Contains(dst.effects, "preflight") { |
| 152 | t.Fatalf("admissions %d attempts %q effects %v", len(dst.certified), o.Claim.Op.Journal["probe_attempts.dst"], dst.effects) |
| 153 | } |
| 154 | }) |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | // Ruling 62: an install that writes the claim (the typed inventory) has its |
| 159 | // receipt recorded on a fresh read, not a conflict loop on the stale one. |
| 160 | func TestParticipantRecordsItsReceiptAfterAnEffectWroteTheClaim(t *testing.T) { |
| 161 | f := newFixture(t) |
| 162 | ctx := context.Background() |
| 163 | src, dst := &fakeBox{f: f, box: "src"}, &fakeBox{f: f, box: "dst", writesOnInstall: true} |
| 164 | if _, err := f.w.Start(ctx, host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}); err != nil { |
| 165 | t.Fatal(err) |
| 166 | } |
| 167 | ps := Participant{Claims: f.s, Box: "src", Enrollment: enrollment("src"), Effects: src, Now: f.clock} |
| 168 | pd := Participant{Claims: f.s, Box: "dst", Enrollment: enrollment("dst"), Effects: dst, Now: f.clock} |
| 169 | for i := 0; i < 40 && f.claim().Claim.Op.Phase != hostclaim.PhaseInstalled; i++ { |
| 170 | f.w.Step(ctx, host) |
| 171 | ps.Step(ctx, host) |
| 172 | pd.Step(ctx, host) |
| 173 | } |
| 174 | if f.claim().Claim.Op.Phase != hostclaim.PhaseInstalled { |
| 175 | t.Fatalf("never installed: %+v", f.claim().Claim.Op) |
| 176 | } |
| 177 | if _, err := pd.Step(ctx, host); err != nil { |
| 178 | t.Fatalf("the receipt after an effect wrote the claim: %v", err) |
| 179 | } |
| 180 | c := f.claim().Claim |
| 181 | if c.Op.Journal["local_install"] == "" || c.Op.Journal["test.inventory"] != "dst" { |
| 182 | t.Fatalf("receipt %q inventory %q", c.Op.Journal["local_install"], c.Op.Journal["test.inventory"]) |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | // passProbe runs dst's first attempt to a journaled passing probe. |
| 187 | func passProbe(t *testing.T, f *fixture, pd Participant) { |
| 188 | t.Helper() |
| 189 | if _, err := pd.Step(context.Background(), host); err != nil { |
| 190 | t.Fatalf("the probe: %v", err) |
| 191 | } |
| 192 | if f.claim().Claim.Op.Journal["probe"] == "" { |
| 193 | t.Fatal("no probe journaled") |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | // Ruling 67's first scenario: the execution lease is lost right after |
| 198 | // RecordProbe. The participant reopens a controller and ends nothing, so |
| 199 | // no probe_stop is ever journaled by it (the worker's bound decides). |
| 200 | func TestParticipantEndsNoProbeWithoutItsLease(t *testing.T) { |
| 201 | f := newFixture(t) |
| 202 | ctx := context.Background() |
| 203 | src, dst := &fakeBox{f: f, box: "src"}, &fakeBox{f: f, box: "dst"} |
| 204 | _, pd := driveToProbing(t, f, src, dst) |
| 205 | passProbe(t, f, pd) |
| 206 | dst.restarted = true // the lease is gone; Ready reports no controller |
| 207 | dst.effects = nil |
| 208 | if _, err := pd.Step(ctx, host); !errors.Is(err, ErrNotYet) { |
| 209 | t.Fatalf("a step without the lease: %v", err) |
| 210 | } |
| 211 | if slices.Contains(dst.effects, "end-probe") || f.claim().Claim.Op.Journal["probe_stop"] != "" { |
| 212 | t.Fatalf("ended the probe without a ready controller: %v", dst.effects) |
| 213 | } |
| 214 | } |
| 215 | |
| 216 | // Ruling 67's second scenario: root keeps refusing the stop. The error is |
| 217 | // returned and nothing is journaled; a later pass that gets the receipt |
| 218 | // records it. |
| 219 | func TestParticipantRecordsNoProbeStopWhileRootRefuses(t *testing.T) { |
| 220 | f := newFixture(t) |
| 221 | ctx := context.Background() |
| 222 | src, dst := &fakeBox{f: f, box: "src"}, &fakeBox{f: f, box: "dst"} |
| 223 | _, pd := driveToProbing(t, f, src, dst) |
| 224 | passProbe(t, f, pd) |
| 225 | refused := errors.New("root: stop refused") |
| 226 | dst.endProbeErr = refused |
| 227 | for i := 0; i < 2; i++ { |
| 228 | if _, err := pd.Step(ctx, host); !errors.Is(err, refused) || f.claim().Claim.Op.Journal["probe_stop"] != "" { |
| 229 | t.Fatalf("pass %d: %v", i, err) |
| 230 | } |
| 231 | } |
| 232 | dst.endProbeErr = nil |
| 233 | if _, err := pd.Step(ctx, host); err != nil || f.claim().Claim.Op.Journal["probe_stop"] == "" { |
| 234 | t.Fatalf("the stop once root answered: %v", err) |
| 235 | } |
| 236 | } |
| 237 | |
| 238 | // Fix round 1, M4 (ruling 62): sameInstallation accepts a fresh read that |
| 239 | // differs only in an effect's own bookkeeping, and refuses one whose |
| 240 | // installation moved: another stamp, install stamp, prepared P, session |
| 241 | // or phase, or a stuck mark. (The claim store itself refuses a changed |
| 242 | // incarnation, or an installed op's executor moved off its target.) |
| 243 | func TestSameInstallationRefusesAMovedInstallation(t *testing.T) { |
| 244 | ctx := context.Background() |
| 245 | installed := func(t *testing.T) (*fixture, hostclaim.Observed, Participant) { |
| 246 | f := newFixture(t) |
| 247 | releasedAndInstalling(t, f) |
| 248 | f.worker(ErrNotYet) // P is not built yet |
| 249 | p := f.claim().Claim.Op.Journal["prepared"] |
| 250 | f.participant(func(c *hostclaim.Claim) error { c.Op.Journal["test.built"] = p; return nil }) |
| 251 | if o := f.worker(nil); o.Claim.Op.Phase != hostclaim.PhaseInstalled { |
| 252 | t.Fatalf("not installed: %s", o.Claim.Op.Phase) |
| 253 | } |
| 254 | return f, f.claim(), Participant{Claims: f.s, Box: "dst", Enrollment: enrollment("dst"), Effects: &fakeBox{f: f, box: "dst"}, Now: f.clock} |
| 255 | } |
| 256 | t.Run("the effect's own bookkeeping", func(t *testing.T) { |
| 257 | f, obs, p := installed(t) |
| 258 | f.participant(func(c *hostclaim.Claim) error { c.Op.Journal["test.inventory"] = "dst"; return nil }) |
| 259 | fresh, err := p.sameInstallation(ctx, obs, hostclaim.PhaseInstalled) |
| 260 | if err != nil || fresh.ETag == obs.ETag || fresh.Claim.Op.Journal["test.inventory"] != "dst" { |
| 261 | t.Fatalf("a fresh read of the same installation: %v", err) |
| 262 | } |
| 263 | }) |
| 264 | for name, change := range map[string]func(*hostclaim.Claim){ |
| 265 | "another stamp": func(c *hostclaim.Claim) { c.Holder.Stamp++ }, |
| 266 | "another install stamp": func(c *hostclaim.Claim) { c.Op.Journal["install_stamp"] += "0" }, |
| 267 | "another prepared P": func(c *hostclaim.Claim) { c.Op.Journal["prepared"] = "P9" }, |
| 268 | "another session": func(c *hostclaim.Claim) { c.Holder.Session++ }, |
| 269 | "stuck": func(c *hostclaim.Claim) { c.Op.Journal["stuck"] = "operator" }, |
| 270 | } { |
| 271 | t.Run(name, func(t *testing.T) { |
| 272 | f, obs, p := installed(t) |
| 273 | f.participant(func(c *hostclaim.Claim) error { change(c); return nil }) |
| 274 | if _, err := p.sameInstallation(ctx, obs, hostclaim.PhaseInstalled); !errors.Is(err, ErrRefused) || !errors.Is(err, ErrNotYet) { |
| 275 | t.Fatalf("a moved installation was accepted: %v", err) |
| 276 | } |
| 277 | }) |
| 278 | } |
| 279 | t.Run("another phase", func(t *testing.T) { |
| 280 | _, obs, p := installed(t) |
| 281 | if _, err := p.sameInstallation(ctx, obs, hostclaim.PhaseRelocatingProbing); !errors.Is(err, ErrRefused) { |
| 282 | t.Fatalf("another phase was accepted: %v", err) |
| 283 | } |
| 284 | }) |
| 285 | } |
| 286 | |
| 287 | // Fix round 1, I3 and M1: a journaled release that is unreadable, or that |
| 288 | // names another writer than the predecessor, is a claim defect. The |
| 289 | // participant reads it before any attempt is counted: the operation is |
| 290 | // stuck for an operator, no attempt is spent, and no probe is made. |
| 291 | func TestParticipantRefusesADamagedReleaseBeforeCountingAnAttempt(t *testing.T) { |
| 292 | ctx := context.Background() |
| 293 | for name, damage := range map[string]func(*hostclaim.Claim) error{ |
| 294 | "unparseable": func(c *hostclaim.Claim) error { c.Op.Journal["release"] = `{"released_by":`; return nil }, |
| 295 | "another session": func(c *hostclaim.Claim) error { |
| 296 | var rel hostclaim.Release |
| 297 | if err := json.Unmarshal([]byte(c.Op.Journal["release"]), &rel); err != nil { |
| 298 | return err |
| 299 | } |
| 300 | rel.Session++ |
| 301 | raw, err := json.Marshal(rel) |
| 302 | c.Op.Journal["release"] = string(raw) |
| 303 | return err |
| 304 | }, |
| 305 | } { |
| 306 | t.Run(name, func(t *testing.T) { |
| 307 | f := newFixture(t) |
| 308 | src, dst := &fakeBox{f: f, box: "src"}, &fakeBox{f: f, box: "dst"} |
| 309 | _, pd := driveToProbing(t, f, src, dst) |
| 310 | if _, err := hostclaim.Update(ctx, f.s, f.claim(), damage); err != nil { |
| 311 | t.Fatal(err) |
| 312 | } |
| 313 | if _, err := pd.Step(ctx, host); err != nil { |
| 314 | t.Fatal(err) |
| 315 | } |
| 316 | c := f.claim().Claim |
| 317 | if c.Op.Journal["stuck"] == "" || c.Op.Journal["probe_attempts.dst"] != "" || len(dst.certified) != 0 { |
| 318 | t.Fatalf("stuck %q attempts %q probes %d", c.Op.Journal["stuck"], c.Op.Journal["probe_attempts.dst"], len(dst.certified)) |
| 319 | } |
| 320 | }) |
| 321 | } |
| 322 | // A sound release certifies P's opening, and only it. |
| 323 | f := newFixture(t) |
| 324 | src, dst := &fakeBox{f: f, box: "src"}, &fakeBox{f: f, box: "dst"} |
| 325 | _, pd := driveToProbing(t, f, src, dst) |
| 326 | if certified, err := CertifiedFor(f.claim().Claim); err != nil || !certified { |
| 327 | t.Fatalf("a drain's release: %v %v", certified, err) |
| 328 | } |
| 329 | if _, err := pd.Step(ctx, host); err != nil && !errors.Is(err, ErrNotYet) { |
| 330 | t.Fatal(err) |
| 331 | } |
| 332 | if certified, err := CertifiedFor(f.claim().Claim); err != nil || certified { |
| 333 | t.Fatalf("after P's opening: %v %v", certified, err) |
| 334 | } |
| 335 | } |
| 336 | |