| 1 | package hostops |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/ed25519" |
| 6 | "crypto/rand" |
| 7 | "encoding/json" |
| 8 | "errors" |
| 9 | "reflect" |
| 10 | "strings" |
| 11 | "sync" |
| 12 | "testing" |
| 13 | "time" |
| 14 | |
| 15 | "github.com/carlosframework/platform/internal/hostactors" |
| 16 | "github.com/carlosframework/platform/internal/hostclaim" |
| 17 | "github.com/carlosframework/platform/internal/hostfence" |
| 18 | "github.com/carlosframework/platform/internal/hostresolve" |
| 19 | "github.com/carlosframework/platform/internal/moves" |
| 20 | "github.com/carlosframework/platform/internal/provision" |
| 21 | "github.com/carlosframework/platform/internal/release" |
| 22 | "github.com/superfly/ltx" |
| 23 | ) |
| 24 | |
| 25 | // J4's moves owner and the J17 declare-lost grant: a console-initiated |
| 26 | // declaration that a move's target box is lost (moves spec §7.10), through |
| 27 | // the generic hostresolve service with only the move kinds' owner |
| 28 | // registered, writing only through the typed ControlWriter's private |
| 29 | // scoped store. |
| 30 | |
| 31 | const ( |
| 32 | lossOp = "mv_abcdefghijklmnopqrstuvwxyz" |
| 33 | lossHost = "shop.example" |
| 34 | lossBucket = "bucket" |
| 35 | ) |
| 36 | |
| 37 | // lossFaultStore fails the first Put of one key, before or after it lands. |
| 38 | type lossFaultStore struct { |
| 39 | release.Store |
| 40 | mu sync.Mutex |
| 41 | key string |
| 42 | failBefore bool |
| 43 | failAfter bool |
| 44 | puts []string |
| 45 | } |
| 46 | |
| 47 | func (s *lossFaultStore) Put(ctx context.Context, key string, body []byte, match string) (string, error) { |
| 48 | s.mu.Lock() |
| 49 | s.puts = append(s.puts, key) |
| 50 | before, after := s.key == key && s.failBefore, s.key == key && s.failAfter |
| 51 | if s.key == key { |
| 52 | s.failBefore, s.failAfter = false, false |
| 53 | } |
| 54 | s.mu.Unlock() |
| 55 | if before { |
| 56 | return "", errors.New("injected put failure") |
| 57 | } |
| 58 | tag, err := s.Store.Put(ctx, key, body, match) |
| 59 | if err == nil && after { |
| 60 | return "", errors.New("injected lost answer") |
| 61 | } |
| 62 | return tag, err |
| 63 | } |
| 64 | |
| 65 | type lossWorld struct { |
| 66 | raw *lossFaultStore |
| 67 | pub ed25519.PublicKey |
| 68 | signer release.Signer |
| 69 | control *ControlWriter |
| 70 | clock time.Time |
| 71 | inc string |
| 72 | key string |
| 73 | claimK string |
| 74 | } |
| 75 | |
| 76 | // newLossWorld is a host mid-move from ie-box (ie) to uk-box (uk): state |
| 77 | // "landing" (the target probing, executing), "releasing" (a rollback the |
| 78 | // target has not yet released), "released-dirty" (the target released |
| 79 | // dirty and has not returned) or "returning". The target admitted two |
| 80 | // sessions since the cut (20, 25); the source's cut session is 15 and the |
| 81 | // seal did not cover the source's own replica. |
| 82 | func newLossWorld(t *testing.T, state string, mutate func(*hostclaim.Claim, *provision.Record, *AppAdmission)) *lossWorld { |
| 83 | t.Helper() |
| 84 | ctx := context.Background() |
| 85 | pub, priv, err := ed25519.GenerateKey(rand.Reader) |
| 86 | if err != nil { |
| 87 | t.Fatal(err) |
| 88 | } |
| 89 | w := &lossWorld{raw: &lossFaultStore{Store: release.NewDirStore(t.TempDir())}, pub: pub, signer: release.RootSigner(priv), clock: time.Now().UTC().Truncate(time.Second)} |
| 90 | if w.control, err = NewControlWriter(w.raw, pub, func() time.Time { return w.clock }); err != nil { |
| 91 | t.Fatal(err) |
| 92 | } |
| 93 | d := hostactors.Directory{Store: w.raw, PublicKey: pub, Bucket: lossBucket} |
| 94 | if err := d.Initialize(ctx, w.signer); err != nil { |
| 95 | t.Fatal(err) |
| 96 | } |
| 97 | o, err := d.Read(ctx) |
| 98 | if err != nil { |
| 99 | t.Fatal(err) |
| 100 | } |
| 101 | r := hostactors.Clone(o.Roster) |
| 102 | r.Boxes["ie-box"] = hostactors.Box{Enrollment: strings.Repeat("a", 32), Namespace: "platform", BoxID: "ie-box", Label: "ie-label", Region: "ie", Pool: "default", Cloud: hostactors.Cloud{Provider: "aws", Account: "123", Region: "eu-west-1", InstanceID: "i-ie"}, EvidenceSHA256: strings.Repeat("c", 64), State: "active"} |
| 103 | r.Boxes["uk-box"] = hostactors.Box{Enrollment: strings.Repeat("b", 32), Namespace: "platform", BoxID: "uk-box", Label: "uk-label", Region: "uk", Pool: "default", Cloud: hostactors.Cloud{Provider: "aws", Account: "123", Region: "eu-west-2", InstanceID: "i-uk"}, EvidenceSHA256: strings.Repeat("d", 64), State: "active"} |
| 104 | if _, err := d.Replace(ctx, w.signer, o, r); err != nil { |
| 105 | t.Fatal(err) |
| 106 | } |
| 107 | created := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) |
| 108 | if w.inc, err = hostclaim.Incarnation("bab", "app", lossHost, created); err != nil { |
| 109 | t.Fatal(err) |
| 110 | } |
| 111 | w.key = provision.RecordKey("bab", "app", lossHost) |
| 112 | at := w.clock.Add(-time.Hour) |
| 113 | seal := moves.Seal{V: moves.SealVersion, N: 42, FromDB: true, SrcCovered: false, SealedAt: at, LeaseUntil: at.Add(time.Minute), |
| 114 | Replica: []moves.SealedObject{{Key: "moves/" + lossHost + "/" + lossOp + "/replica/0009/" + ltx.FormatFilename(1, 42), VersionID: "v1", Size: int64(ltx.HeaderSize)}}, |
| 115 | Files: []moves.SealedObject{{Key: "moves/" + lossHost + "/" + lossOp + "/files/archive", VersionID: "v2", Size: 10}}} |
| 116 | c := hostclaim.Claim{V: 2, Host: lossHost, Gen: 30, |
| 117 | Holder: &hostclaim.Holder{Family: "instance", Key: w.key, Account: "bab", App: "app", Acquisition: "acq", CreatedAt: created, Incarnation: w.inc, |
| 118 | Location: "instances/" + lossHost, Generation: "mv2", Stamp: 21, Session: 25, WriterBox: "uk-box", |
| 119 | Admissions: []hostclaim.AdmissionRecord{ |
| 120 | {Session: 15, Box: "ie-box", Generation: "src", Predecessor: "older", Region: "ie", PredecessorRegion: "ie", Mode: "cold"}, |
| 121 | {Session: 20, Box: "uk-box", Generation: "mv", Predecessor: "src", Region: "uk", PredecessorRegion: "ie", Mode: "cold", Coverage: "sealed"}, |
| 122 | {Session: 25, Box: "uk-box", Generation: "mv2", Predecessor: "mv", Region: "uk", PredecessorRegion: "uk", Mode: "cold"}, |
| 123 | }}, |
| 124 | Op: hostclaim.Op{Kind: hostclaim.KindLanding, ID: lossOp, Phase: "probing", Exec: "uk-box", Participants: []string{"ie-box", "uk-box"}, |
| 125 | Intent: map[string]string{"to": "uk", "source": "ie-box", "target": "uk-box", "app_id": "canonical", "admission_id": lossOp, |
| 126 | "source_enrollment": strings.Repeat("a", 32), "target_enrollment": strings.Repeat("b", 32)}, |
| 127 | Journal: map[string]string{hostclaim.JournalSeal: seal.Encode(), hostclaim.JournalCutSession: "15", hostclaim.JournalSourceStamp: "15", hostclaim.JournalSrcGen: "src", |
| 128 | hostclaim.JournalEvidenceSource: "moves/bab/app/" + lossHost + "/" + lossOp + "/stopped-source.json"}}} |
| 129 | flipped, deadline := at, at.Add(2*time.Hour) |
| 130 | rec := provision.Record{Schema: 1, Host: lossHost, Slug: "app", Account: "bab", App: "app", Acquisition: "acq", Channel: "edge", Region: "uk", CreatedAt: created, |
| 131 | Move: &provision.MoveState{ID: lossOp, Incarnation: w.inc, From: "ie", To: "uk", Cut: "auto", State: provision.MoveLanding, CutPhase: provision.CutFlipped, |
| 132 | FlippedAt: &flipped, LandingDeadline: &deadline, LandingPhase: provision.LandProbing}} |
| 133 | rb := "rb_bbbbbbbbbbbbbbbbbbbbbbbbbb" |
| 134 | requested, release15 := at.Add(time.Minute), at.Add(16*time.Minute) |
| 135 | switch state { |
| 136 | case "landing": |
| 137 | case "releasing", "released-dirty", "returning": |
| 138 | c.Op.Kind, c.Op.Phase = hostclaim.KindRestoring, hostclaim.PhaseReleasing |
| 139 | c.Op.Intent["rollback"] = rb |
| 140 | c.Op.Journal[hostclaim.JournalTargetStamp] = "21" |
| 141 | rec.Region = "ie" |
| 142 | rec.Move.State = provision.MoveRollingBack |
| 143 | rec.Move.Rollback = &provision.MoveRollback{ID: rb, Reason: provision.RollbackProbeAttempts, TargetPhase: provision.RbTargetReleasing, SourcePhase: provision.RbSourceWaiting, RequestedAt: &requested, ReleaseDeadline: &release15} |
| 144 | if state != "releasing" { |
| 145 | c.Op.Phase, c.Op.Exec = hostclaim.PhaseReleased, "" |
| 146 | c.Op.Journal[hostclaim.JournalEvidenceTarget] = "moves/bab/app/" + lossHost + "/" + rb + "/stopped-target.json" |
| 147 | c.Obligations = []hostclaim.Obligation{{ID: "loc", Op: lossOp, Kind: "park-replica", Region: "uk", Retirement: "r-loc", Owner: "region:uk", |
| 148 | Park: &hostclaim.ParkScope{Location: "instances/" + lossHost, Incarnation: w.inc, EvidenceAt: at, RequestLimit: time.Minute}}} |
| 149 | rec.Move.Rollback.TargetPhase, rec.Move.Rollback.Dirty = provision.RbTargetReleased, true |
| 150 | if state == "returning" { |
| 151 | rec.Move.Rollback.TargetPhase = provision.RbTargetReturning |
| 152 | } |
| 153 | } |
| 154 | default: |
| 155 | t.Fatalf("state %q", state) |
| 156 | } |
| 157 | adm := AppAdmission{V: 1, AppID: "canonical", Epoch: 1, Revision: 1, State: "open", Effects: map[string]Effect{}, Registrations: map[string]Registration{lossOp: {Kind: RegistrationMove, Host: lossHost, Owner: c7Owner, AppID: "canonical", Epoch: 1, Claim: SavedClaim{ETag: "serving", Gen: 1}, Expires: w.clock.Add(RegistrationTTL), CreatedAt: w.clock, Incarnation: w.inc}}} |
| 158 | if mutate != nil { |
| 159 | mutate(&c, &rec, &adm) |
| 160 | } |
| 161 | if err := c.Validate(); err != nil { |
| 162 | t.Fatal(err) |
| 163 | } |
| 164 | cb, _ := json.Marshal(c) |
| 165 | w.claimK, _ = hostclaim.Key(lossHost) |
| 166 | if _, err := w.raw.Store.Put(ctx, w.claimK, cb, ""); err != nil { |
| 167 | t.Fatal(err) |
| 168 | } |
| 169 | rbody, _ := json.Marshal(rec) |
| 170 | if _, err := w.raw.Store.Put(ctx, w.key, rbody, ""); err != nil { |
| 171 | t.Fatal(err) |
| 172 | } |
| 173 | ab, _ := json.Marshal(adm) |
| 174 | if _, err := w.raw.Store.Put(ctx, "apps/bab/app/admission.json", ab, ""); err != nil { |
| 175 | t.Fatal(err) |
| 176 | } |
| 177 | return w |
| 178 | } |
| 179 | |
| 180 | func (w *lossWorld) claim(t *testing.T) hostclaim.Observed { |
| 181 | t.Helper() |
| 182 | o, err := hostclaim.Read(context.Background(), w.raw, lossHost) |
| 183 | if err != nil { |
| 184 | t.Fatal(err) |
| 185 | } |
| 186 | return o |
| 187 | } |
| 188 | |
| 189 | func (w *lossWorld) record(t *testing.T) (provision.Record, string) { |
| 190 | t.Helper() |
| 191 | rec, tag, err := provision.GetRecordETag(context.Background(), w.raw, "bab", "app", lossHost) |
| 192 | if err != nil { |
| 193 | t.Fatal(err) |
| 194 | } |
| 195 | return *rec, tag |
| 196 | } |
| 197 | |
| 198 | func (w *lossWorld) roster(t *testing.T) hostactors.Observed { |
| 199 | t.Helper() |
| 200 | o, err := hostactors.Directory{Store: w.raw, PublicKey: w.pub, Bucket: lossBucket}.Read(context.Background()) |
| 201 | if err != nil { |
| 202 | t.Fatal(err) |
| 203 | } |
| 204 | return o |
| 205 | } |
| 206 | |
| 207 | func (w *lossWorld) inspect(t *testing.T, box string) hostresolve.Inspection { |
| 208 | t.Helper() |
| 209 | in, err := w.control.InspectMoveLoss(context.Background(), w.signer, lossBucket, "operator", "bab", "app", lossHost, box) |
| 210 | if err != nil { |
| 211 | t.Fatal(err) |
| 212 | } |
| 213 | return in |
| 214 | } |
| 215 | |
| 216 | func (w *lossWorld) submission(in hostresolve.Inspection) hostfence.Submission { |
| 217 | w.clock = w.clock.Add(2 * time.Second) |
| 218 | return hostfence.Submission{Challenge: in.Challenge, Confirmation: in.Details.ID, |
| 219 | Observation: hostfence.CloudObservation{ObservedAt: in.Details.IssuedAt.Add(time.Second), Account: "123", Region: "eu-west-2", InstanceID: "i-uk", State: "stopped", RequestID: "request"}, |
| 220 | Attestation: hostfence.OperatorAttestation{MaintainComputeFence: true, ColdQuarantineStartOnly: true, Disks: in.Details.Consequences.DestroyedStorageAssertion}} |
| 221 | } |
| 222 | |
| 223 | func (w *lossWorld) declare(sub hostfence.Submission) (hostclaim.Observed, error) { |
| 224 | return w.control.DeclareMoveLoss(context.Background(), w.signer, lossBucket, "operator", "bab", "app", lossHost, "uk-box", sub) |
| 225 | } |
| 226 | |
| 227 | func (w *lossWorld) acceptances(t *testing.T) []string { |
| 228 | t.Helper() |
| 229 | keys, err := w.raw.List(context.Background(), "control/host-loss-acceptances/") |
| 230 | if err != nil { |
| 231 | t.Fatal(err) |
| 232 | } |
| 233 | return keys |
| 234 | } |
| 235 | |
| 236 | func lossSnapshot(t *testing.T, w *lossWorld) string { |
| 237 | t.Helper() |
| 238 | c := w.claim(t) |
| 239 | _, tag := w.record(t) |
| 240 | return c.ETag + "|" + tag + "|" + w.roster(t).ETag + "|" + strings.Join(w.acceptances(t), ",") |
| 241 | } |
| 242 | |
| 243 | // The landing's target is lost: the declaration rolls the record back clean |
| 244 | // with the loss as its evidence, takes the target out of the claim, and |
| 245 | // releases it in the claim with the target region's parks, in a crash-safe |
| 246 | // sequence whose signed record froze every lost write. |
| 247 | func TestJ4DeclareLostLandingTarget(t *testing.T) { |
| 248 | ctx := context.Background() |
| 249 | w := newLossWorld(t, "landing", nil) |
| 250 | in := w.inspect(t, "uk-label") |
| 251 | if in.Details.Box != "uk-box" || in.Details.Op.Kind != hostclaim.KindLanding || in.Details.Consequences.Transition != moveLossTransition || |
| 252 | !in.Details.Consequences.PossibleWriteLoss || !in.Details.Consequences.PossibleTimerLoss || len(in.Details.Consequences.DestroyedStorageAssertion) != 0 { |
| 253 | t.Fatalf("inspection = %+v", in.Details) |
| 254 | } |
| 255 | _, tag := w.record(t) |
| 256 | if in.Details.Record.Key != w.key || in.Details.Record.ETag != tag { |
| 257 | t.Fatalf("record observation = %+v", in.Details.Record) |
| 258 | } |
| 259 | before := w.claim(t) |
| 260 | sub := w.submission(in) |
| 261 | after, err := w.declare(sub) |
| 262 | if err != nil { |
| 263 | t.Fatal(err) |
| 264 | } |
| 265 | id := in.Details.ID |
| 266 | lossRef, _ := hostclaim.LossRecordKey(id) |
| 267 | loss, err := hostclaim.ReadLossRecord(ctx, w.raw, w.pub, id) |
| 268 | if err != nil || loss.Record.State != "committed" || loss.Record.OpKind != hostclaim.KindLanding || loss.Record.ExpectedClaimETag != before.ETag { |
| 269 | t.Fatalf("loss record = %+v %v", loss.Record, err) |
| 270 | } |
| 271 | wantSources := []hostclaim.WriteSource{ |
| 272 | {Reason: "target", Box: "uk-box", Generation: "mv", Session: 20, Region: "uk", Baseline: "replica:uk/mv"}, |
| 273 | {Reason: "target", Box: "uk-box", Generation: "mv2", Session: 25, Region: "uk", Baseline: "replica:uk/mv2"}, |
| 274 | {Reason: "source-cut", Box: "ie-box", Generation: "src", Session: 15, Region: "ie", Baseline: "seal:" + lossOp}, |
| 275 | } |
| 276 | if !reflect.DeepEqual(loss.Record.WriteSources, wantSources) { |
| 277 | t.Fatalf("write sources = %+v", loss.Record.WriteSources) |
| 278 | } |
| 279 | cons := loss.Record.ConfirmedConsequences |
| 280 | if !cons.RevokeExecution || !cons.RemoveParticipation || !cons.WritesPotentiallyLost || !cons.TimersPotentiallyLost || len(cons.DischargeLocal) != 0 || len(cons.DestroyedStorage) != 0 { |
| 281 | t.Fatalf("consequences = %+v", cons) |
| 282 | } |
| 283 | if b := w.roster(t).Roster.Boxes["uk-box"]; b.State != "quarantined" || !reflect.DeepEqual(b.LossIDs, []string{id}) { |
| 284 | t.Fatalf("roster = %+v", b) |
| 285 | } |
| 286 | c := w.claim(t) |
| 287 | if c.ETag != after.ETag { |
| 288 | t.Fatal("the answer is not the claim's last write") |
| 289 | } |
| 290 | op := c.Claim.Op |
| 291 | rb := op.Intent["rollback"] |
| 292 | if op.Kind != hostclaim.KindRestoring || op.Phase != hostclaim.PhaseReleased || op.Exec != "none" || !reflect.DeepEqual(op.Participants, []string{"ie-box"}) || |
| 293 | !provision.ValidRollbackID(rb) || op.Journal["loss."+id] == "" || !hostclaim.LostTargetReleased(c.Claim, id) { |
| 294 | t.Fatalf("claim op = %+v", op) |
| 295 | } |
| 296 | if !reflect.DeepEqual(c.Claim.Lost, []string{"uk-box"}) || !reflect.DeepEqual(c.Claim.LossRefs, []string{id}) { |
| 297 | t.Fatalf("claim lost = %v refs = %v", c.Claim.Lost, c.Claim.LossRefs) |
| 298 | } |
| 299 | parks := map[string]string{} |
| 300 | for _, o := range c.Claim.Obligations { |
| 301 | if o.Kind == "park-local" { |
| 302 | t.Fatalf("a lost box was given a park-local: %+v", o) |
| 303 | } |
| 304 | parks[o.Region+"|"+o.Park.Location] = o.Owner |
| 305 | } |
| 306 | if !reflect.DeepEqual(parks, map[string]string{"uk|instances/" + lossHost: "region:uk", "uk|moves/" + lossHost + "/" + lossOp: "region:uk"}) { |
| 307 | t.Fatalf("parks = %v", parks) |
| 308 | } |
| 309 | rec, _ := w.record(t) |
| 310 | r := rec.Move.Rollback |
| 311 | if rec.Region != "ie" || rec.Move.State != provision.MoveRollingBack || r == nil || r.ID != rb || r.Reason != provision.RollbackDeclaredLost || |
| 312 | r.TargetPhase != provision.RbTargetReleased || r.Dirty || r.LossRef != lossRef || r.Evidence != lossRef || r.SourcePhase != provision.RbSourceWaiting { |
| 313 | t.Fatalf("record move = %+v rollback = %+v", rec.Move, r) |
| 314 | } |
| 315 | // The source's waiting now takes the clean release, and its claimed CAS |
| 316 | // takes execution back on the declaration's authority. |
| 317 | if err := provision.MoveRollbackSourcePhase(provision.MoveGuard{ID: lossOp, Incarnation: w.inc}, provision.RbSourceWaiting, provision.RbSourceClaimed, nil)(&rec); err != nil { |
| 318 | t.Fatalf("source waiting: %v", err) |
| 319 | } |
| 320 | if err := hostclaim.ReturnToSource(lossOp, "ie-box", true, hostclaim.ReturnFence{})(c.Claim); err != nil { |
| 321 | t.Fatalf("source claimed: %v", err) |
| 322 | } |
| 323 | // A replay writes nothing more. |
| 324 | snap := lossSnapshot(t, w) |
| 325 | if _, err := w.declare(sub); err != nil { |
| 326 | t.Fatal(err) |
| 327 | } |
| 328 | if lossSnapshot(t, w) != snap { |
| 329 | t.Fatal("a replay wrote") |
| 330 | } |
| 331 | } |
| 332 | |
| 333 | // The target released dirty and died before its return: the record goes |
| 334 | // clean with the loss as evidence (no fake clean-return coverage: the |
| 335 | // signed record names the target's sessions and the uncovered source cut), |
| 336 | // and the possibly partial return lane is parked in the source region. |
| 337 | func TestJ4DeclareLostDirtyReleasedTarget(t *testing.T) { |
| 338 | for _, state := range []string{"released-dirty", "returning"} { |
| 339 | t.Run(state, func(t *testing.T) { |
| 340 | w := newLossWorld(t, state, nil) |
| 341 | in := w.inspect(t, "uk-box") |
| 342 | if _, err := w.declare(w.submission(in)); err != nil { |
| 343 | t.Fatal(err) |
| 344 | } |
| 345 | loss, err := hostclaim.ReadLossRecord(context.Background(), w.raw, w.pub, in.Details.ID) |
| 346 | if err != nil || len(loss.Record.WriteSources) != 3 || loss.Record.ConfirmedConsequences.RevokeExecution { |
| 347 | t.Fatalf("loss = %+v %v", loss.Record, err) |
| 348 | } |
| 349 | c := w.claim(t) |
| 350 | parks := map[string]string{} |
| 351 | for _, o := range c.Claim.Obligations { |
| 352 | parks[o.Region+"|"+o.Park.Location] = o.ID |
| 353 | } |
| 354 | rb := "rb_bbbbbbbbbbbbbbbbbbbbbbbbbb" |
| 355 | if parks["uk|instances/"+lossHost] != "loc" || parks["ie|moves/"+lossHost+"/"+rb] == "" || parks["uk|moves/"+lossHost+"/"+lossOp] == "" || len(parks) != 3 { |
| 356 | t.Fatalf("parks = %v", parks) |
| 357 | } |
| 358 | rec, _ := w.record(t) |
| 359 | if r := rec.Move.Rollback; r.TargetPhase != provision.RbTargetReleased || r.Dirty || r.Evidence == "" || r.Evidence != r.LossRef { |
| 360 | t.Fatalf("rollback = %+v", r) |
| 361 | } |
| 362 | }) |
| 363 | } |
| 364 | } |
| 365 | |
| 366 | func TestJ4DeclareLostReleasingTarget(t *testing.T) { |
| 367 | w := newLossWorld(t, "releasing", nil) |
| 368 | in := w.inspect(t, "uk-box") |
| 369 | if _, err := w.declare(w.submission(in)); err != nil { |
| 370 | t.Fatal(err) |
| 371 | } |
| 372 | c := w.claim(t) |
| 373 | if c.Claim.Op.Phase != hostclaim.PhaseReleased || c.Claim.Op.Intent["rollback"] != "rb_bbbbbbbbbbbbbbbbbbbbbbbbbb" || c.Claim.Op.Exec != "none" { |
| 374 | t.Fatalf("op = %+v", c.Claim.Op) |
| 375 | } |
| 376 | rec, _ := w.record(t) |
| 377 | if r := rec.Move.Rollback; r.ID != "rb_bbbbbbbbbbbbbbbbbbbbbbbbbb" || r.Reason != provision.RollbackProbeAttempts || r.TargetPhase != provision.RbTargetReleased || r.Dirty { |
| 378 | t.Fatalf("rollback = %+v", r) |
| 379 | } |
| 380 | } |
| 381 | |
| 382 | // Every refusal comes before acceptance and quarantine. |
| 383 | func TestJ4RefusalsBeforeAcceptance(t *testing.T) { |
| 384 | ctx := context.Background() |
| 385 | cases := map[string]struct { |
| 386 | state string |
| 387 | mutate func(*hostclaim.Claim, *provision.Record, *AppAdmission) |
| 388 | box string |
| 389 | }{ |
| 390 | "source box": {"landing", nil, "ie-box"}, |
| 391 | "no registration": {"landing", func(_ *hostclaim.Claim, _ *provision.Record, a *AppAdmission) { delete(a.Registrations, lossOp) }, "uk-box"}, |
| 392 | "other registration": {"landing", func(_ *hostclaim.Claim, _ *provision.Record, a *AppAdmission) { |
| 393 | r := a.Registrations[lossOp] |
| 394 | r.Host = "other.example" |
| 395 | a.Registrations[lossOp] = r |
| 396 | }, "uk-box"}, |
| 397 | "committed record": {"landing", func(_ *hostclaim.Claim, r *provision.Record, _ *AppAdmission) { |
| 398 | r.Move.State = provision.MoveCommitted |
| 399 | at := time.Now() |
| 400 | r.Move.CommittedAt = &at |
| 401 | }, "uk-box"}, |
| 402 | "committed claim": {"landing", func(c *hostclaim.Claim, _ *provision.Record, _ *AppAdmission) { c.Op.Phase = hostclaim.PhaseCommitted }, "uk-box"}, |
| 403 | "moving claim (pre-flip)": {"landing", func(c *hostclaim.Claim, r *provision.Record, _ *AppAdmission) { |
| 404 | c.Op.Kind, c.Op.Phase, c.Op.Exec = hostclaim.KindMoving, hostclaim.PhaseSealed, "" |
| 405 | r.Move.State, r.Move.CutPhase, r.Move.FlippedAt, r.Region = provision.MoveCutting, provision.CutArchived, nil, "ie" |
| 406 | }, "uk-box"}, |
| 407 | "stuck rollback": {"releasing", func(_ *hostclaim.Claim, r *provision.Record, _ *AppAdmission) { |
| 408 | r.Move.State = provision.MoveStuck |
| 409 | }, "uk-box"}, |
| 410 | "released clean": {"released-dirty", func(_ *hostclaim.Claim, r *provision.Record, _ *AppAdmission) { |
| 411 | r.Move.Rollback.Dirty = false |
| 412 | }, "uk-box"}, |
| 413 | "no seal": {"landing", func(c *hostclaim.Claim, _ *provision.Record, _ *AppAdmission) { |
| 414 | delete(c.Op.Journal, hostclaim.JournalSeal) |
| 415 | }, "uk-box"}, |
| 416 | "other record move": {"landing", func(_ *hostclaim.Claim, r *provision.Record, _ *AppAdmission) { |
| 417 | r.Move.ID = "mv_cccccccccccccccccccccccccc" |
| 418 | }, "uk-box"}, |
| 419 | // G1: the claim still moving (sealed) while the record already landing. |
| 420 | "moving claim, landing record": {"landing", func(c *hostclaim.Claim, _ *provision.Record, _ *AppAdmission) { |
| 421 | c.Op.Kind, c.Op.Phase, c.Op.Exec = hostclaim.KindMoving, hostclaim.PhaseSealed, "" |
| 422 | }, "uk-box"}, |
| 423 | } |
| 424 | for name, tc := range cases { |
| 425 | t.Run(name, func(t *testing.T) { |
| 426 | mutate := tc.mutate |
| 427 | w := newLossWorld(t, tc.state, mutate) |
| 428 | snap := lossSnapshot(t, w) |
| 429 | _, err := w.control.InspectMoveLoss(ctx, w.signer, lossBucket, "operator", "bab", "app", lossHost, tc.box) |
| 430 | if err == nil { |
| 431 | t.Fatal("inspection accepted") |
| 432 | } |
| 433 | if lossSnapshot(t, w) != snap { |
| 434 | t.Fatal("a refused inspection wrote") |
| 435 | } |
| 436 | }) |
| 437 | } |
| 438 | // Another app's door cannot inspect this host. |
| 439 | w0 := newLossWorld(t, "landing", nil) |
| 440 | if _, err := w0.control.InspectMoveLoss(ctx, w0.signer, lossBucket, "operator", "bab", "other", lossHost, "uk-box"); !errors.Is(err, ErrIdentity) { |
| 441 | t.Fatalf("other app = %v", err) |
| 442 | } |
| 443 | // The recorded refusal for a committed move reaches the door as its |
| 444 | // sentence. |
| 445 | w := newLossWorld(t, "landing", func(_ *hostclaim.Claim, r *provision.Record, _ *AppAdmission) { |
| 446 | r.Move.State = provision.MoveCommitted |
| 447 | at := time.Now() |
| 448 | r.Move.CommittedAt = &at |
| 449 | }) |
| 450 | _, err := w.control.InspectMoveLoss(ctx, w.signer, lossBucket, "operator", "bab", "app", lossHost, "uk-box") |
| 451 | if !errors.Is(err, provision.ErrMoveConflict) || !strings.Contains(err.Error(), provision.RefusedCommitted) { |
| 452 | t.Fatalf("committed = %v", err) |
| 453 | } |
| 454 | } |
| 455 | |
| 456 | // A declaration whose world changed after inspection is refused before |
| 457 | // acceptance: a stale record, a stale claim, a removed registration. |
| 458 | func TestJ4StaleObservationsRefusedBeforeAcceptance(t *testing.T) { |
| 459 | ctx := context.Background() |
| 460 | for name, change := range map[string]func(*testing.T, *lossWorld){ |
| 461 | "record": func(t *testing.T, w *lossWorld) { |
| 462 | if _, err := provision.SetRecordMove(provision.WithRecordSequence(ctx), w.raw.Store, "bab", "app", lossHost, func(r *provision.Record) error { |
| 463 | r.Move.LandingPhase = provision.LandProbed |
| 464 | return nil |
| 465 | }); err != nil { |
| 466 | t.Fatal(err) |
| 467 | } |
| 468 | }, |
| 469 | "claim": func(t *testing.T, w *lossWorld) { |
| 470 | if _, err := hostclaim.Update(ctx, w.raw.Store, w.claim(t), func(*hostclaim.Claim) error { return nil }); err != nil { |
| 471 | t.Fatal(err) |
| 472 | } |
| 473 | }, |
| 474 | "registration": func(t *testing.T, w *lossWorld) { |
| 475 | a, err := ReadAdmission(ctx, w.raw, "bab", "app", "canonical") |
| 476 | if err != nil { |
| 477 | t.Fatal(err) |
| 478 | } |
| 479 | delete(a.Value.Registrations, lossOp) |
| 480 | b, _ := json.Marshal(a.Value) |
| 481 | if _, err := w.raw.Store.Put(ctx, a.Key, b, a.ETag); err != nil { |
| 482 | t.Fatal(err) |
| 483 | } |
| 484 | }, |
| 485 | } { |
| 486 | t.Run(name, func(t *testing.T) { |
| 487 | w := newLossWorld(t, "landing", nil) |
| 488 | sub := w.submission(w.inspect(t, "uk-box")) |
| 489 | change(t, w) |
| 490 | snap := lossSnapshot(t, w) |
| 491 | if _, err := w.declare(sub); err == nil { |
| 492 | t.Fatal("stale declaration accepted") |
| 493 | } |
| 494 | if lossSnapshot(t, w) != snap { |
| 495 | t.Fatal("a refused declaration wrote") |
| 496 | } |
| 497 | }) |
| 498 | } |
| 499 | } |
| 500 | |
| 501 | // The submission is the signed challenge: another host, another box, |
| 502 | // another account or app, or changed bytes are refused before acceptance. |
| 503 | func TestJ4ChangedRequestRefused(t *testing.T) { |
| 504 | ctx := context.Background() |
| 505 | w := newLossWorld(t, "landing", nil) |
| 506 | sub := w.submission(w.inspect(t, "uk-box")) |
| 507 | snap := lossSnapshot(t, w) |
| 508 | for name, call := range map[string]func() error{ |
| 509 | "other host": func() error { |
| 510 | _, err := w.control.DeclareMoveLoss(ctx, w.signer, lossBucket, "operator", "bab", "app", "other.example", "uk-box", sub) |
| 511 | return err |
| 512 | }, |
| 513 | "other box": func() error { |
| 514 | _, err := w.control.DeclareMoveLoss(ctx, w.signer, lossBucket, "operator", "bab", "app", lossHost, "ie-box", sub) |
| 515 | return err |
| 516 | }, |
| 517 | "other app": func() error { |
| 518 | _, err := w.control.DeclareMoveLoss(ctx, w.signer, lossBucket, "operator", "bab", "other", lossHost, "uk-box", sub) |
| 519 | return err |
| 520 | }, |
| 521 | "other actor": func() error { |
| 522 | _, err := w.control.DeclareMoveLoss(ctx, w.signer, lossBucket, "someone", "bab", "app", lossHost, "uk-box", sub) |
| 523 | return err |
| 524 | }, |
| 525 | "changed body": func() error { |
| 526 | bad := sub |
| 527 | bad.Challenge = append([]byte(nil), sub.Challenge...) |
| 528 | bad.Challenge[len(bad.Challenge)/2] ^= 1 |
| 529 | _, err := w.control.DeclareMoveLoss(ctx, w.signer, lossBucket, "operator", "bab", "app", lossHost, "uk-box", bad) |
| 530 | return err |
| 531 | }, |
| 532 | "other signer": func() error { |
| 533 | _, priv, _ := ed25519.GenerateKey(rand.Reader) |
| 534 | _, err := w.control.DeclareMoveLoss(ctx, release.RootSigner(priv), lossBucket, "operator", "bab", "app", lossHost, "uk-box", sub) |
| 535 | return err |
| 536 | }, |
| 537 | "other bucket": func() error { |
| 538 | _, err := w.control.DeclareMoveLoss(ctx, w.signer, "other-bucket", "operator", "bab", "app", lossHost, "uk-box", sub) |
| 539 | return err |
| 540 | }, |
| 541 | } { |
| 542 | if err := call(); err == nil { |
| 543 | t.Fatalf("%s accepted", name) |
| 544 | } |
| 545 | if lossSnapshot(t, w) != snap { |
| 546 | t.Fatalf("%s wrote", name) |
| 547 | } |
| 548 | } |
| 549 | } |
| 550 | |
| 551 | // Expiry and closed admission do not revoke an accepted move. |
| 552 | func TestJ4ExpiredRegistrationAndClosedAdmissionStillDeclare(t *testing.T) { |
| 553 | w := newLossWorld(t, "landing", func(_ *hostclaim.Claim, _ *provision.Record, a *AppAdmission) { |
| 554 | r := a.Registrations[lossOp] |
| 555 | r.Expires = time.Now().Add(-48 * time.Hour) |
| 556 | a.Registrations[lossOp] = r |
| 557 | a.State = "closed" |
| 558 | }) |
| 559 | if _, err := w.declare(w.submission(w.inspect(t, "uk-box"))); err != nil { |
| 560 | t.Fatal(err) |
| 561 | } |
| 562 | } |
| 563 | |
| 564 | // Crash and lost-answer points across the durable sequence: each fault |
| 565 | // leaves a state the same submission completes, with nothing written twice. |
| 566 | func TestJ4FaultsResumeFromDurableState(t *testing.T) { |
| 567 | for _, fault := range []struct { |
| 568 | name, key string |
| 569 | before, lost bool |
| 570 | nth int |
| 571 | }{ |
| 572 | {"record prepare fails", "record", true, false, 0}, |
| 573 | {"record prepare answer lost", "record", false, true, 0}, |
| 574 | {"declare CAS fails", "claim", true, false, 0}, |
| 575 | {"declare CAS answer lost", "claim", false, true, 0}, |
| 576 | {"loss commit fails", "loss", false, false, 2}, |
| 577 | {"release CAS fails", "claim", false, false, 2}, |
| 578 | {"loss-committed record write fails", "record", false, false, 2}, |
| 579 | } { |
| 580 | t.Run(fault.name, func(t *testing.T) { |
| 581 | w := newLossWorld(t, "landing", nil) |
| 582 | sub := w.submission(w.inspect(t, "uk-box")) |
| 583 | key := map[string]string{"record": w.key, "claim": w.claimK}[fault.key] |
| 584 | if fault.key == "loss" { |
| 585 | key, _ = hostclaim.LossRecordKey(sub.Confirmation) |
| 586 | } |
| 587 | var err error |
| 588 | if fault.nth > 0 { |
| 589 | hook := &lossNthPut{lossFaultStore: w.raw, key: key, n: fault.nth} |
| 590 | cw, cerr := NewControlWriter(hook, w.pub, func() time.Time { return w.clock }) |
| 591 | if cerr != nil { |
| 592 | t.Fatal(cerr) |
| 593 | } |
| 594 | _, err = cw.DeclareMoveLoss(context.Background(), w.signer, lossBucket, "operator", "bab", "app", lossHost, "uk-box", sub) |
| 595 | } else { |
| 596 | w.raw.key, w.raw.failBefore, w.raw.failAfter = key, fault.before, fault.lost |
| 597 | _, err = w.declare(sub) |
| 598 | } |
| 599 | if err == nil { |
| 600 | t.Fatal("fault not reached") |
| 601 | } |
| 602 | if _, err := w.declare(sub); err != nil { |
| 603 | t.Fatalf("resume: %v", err) |
| 604 | } |
| 605 | c := w.claim(t) |
| 606 | rec, _ := w.record(t) |
| 607 | if c.Claim.Op.Phase != hostclaim.PhaseReleased || rec.Move.Rollback.TargetPhase != provision.RbTargetReleased || len(c.Claim.Obligations) != 2 { |
| 608 | t.Fatalf("after resume: op %+v rollback %+v obligations %d", c.Claim.Op, rec.Move.Rollback, len(c.Claim.Obligations)) |
| 609 | } |
| 610 | snap := lossSnapshot(t, w) |
| 611 | if _, err := w.declare(sub); err != nil || lossSnapshot(t, w) != snap { |
| 612 | t.Fatalf("second replay: %v (wrote %v)", err, lossSnapshot(t, w) != snap) |
| 613 | } |
| 614 | }) |
| 615 | } |
| 616 | } |
| 617 | |
| 618 | // lossNthPut fails the nth Put of key (before it lands). |
| 619 | type lossNthPut struct { |
| 620 | *lossFaultStore |
| 621 | key string |
| 622 | n int |
| 623 | } |
| 624 | |
| 625 | func (s *lossNthPut) Put(ctx context.Context, key string, body []byte, match string) (string, error) { |
| 626 | if key == s.key { |
| 627 | s.n-- |
| 628 | if s.n == 0 { |
| 629 | return "", errors.New("injected put failure") |
| 630 | } |
| 631 | } |
| 632 | return s.lossFaultStore.Store.Put(ctx, key, body, match) |
| 633 | } |
| 634 | |
| 635 | // The owner's hooks are idempotent and never regress: a late PrepareLoss |
| 636 | // after FinishLoss writes nothing; a FinishLoss replay writes nothing; a |
| 637 | // PrepareLoss for a conflicted declaration writes nothing; a FinishLoss |
| 638 | // facing a successor holder with no durable completion refuses. |
| 639 | func TestJ4OwnerHooksLateAndConflicted(t *testing.T) { |
| 640 | ctx := context.Background() |
| 641 | w := newLossWorld(t, "landing", nil) |
| 642 | sub := w.submission(w.inspect(t, "uk-box")) |
| 643 | if _, err := w.declare(sub); err != nil { |
| 644 | t.Fatal(err) |
| 645 | } |
| 646 | loss, err := hostclaim.ReadLossRecord(ctx, w.raw, w.pub, sub.Confirmation) |
| 647 | if err != nil { |
| 648 | t.Fatal(err) |
| 649 | } |
| 650 | owner := &moveLossOwner{store: w.raw, pub: w.pub} |
| 651 | snap := lossSnapshot(t, w) |
| 652 | if err := owner.PrepareLoss(ctx, w.claim(t), loss.Record); err != nil { |
| 653 | t.Fatalf("late prepare: %v", err) |
| 654 | } |
| 655 | if err := owner.FinishLoss(ctx, w.claim(t), loss.Record); err != nil { |
| 656 | t.Fatalf("finish replay: %v", err) |
| 657 | } |
| 658 | if lossSnapshot(t, w) != snap { |
| 659 | t.Fatal("a late hook wrote") |
| 660 | } |
| 661 | |
| 662 | // A conflicted declaration: its prepare writes nothing. |
| 663 | w2 := newLossWorld(t, "landing", nil) |
| 664 | sub2 := w2.submission(w2.inspect(t, "uk-box")) |
| 665 | // Accept and prepare by failing the record write once, then mark the |
| 666 | // declaration conflicted by moving the claim. |
| 667 | w2.raw.key, w2.raw.failBefore = w2.key, true |
| 668 | if _, err := w2.declare(sub2); err == nil { |
| 669 | t.Fatal("fault not reached") |
| 670 | } |
| 671 | if _, err := hostclaim.Update(ctx, w2.raw.Store, w2.claim(t), func(*hostclaim.Claim) error { return nil }); err != nil { |
| 672 | t.Fatal(err) |
| 673 | } |
| 674 | if _, err := w2.declare(sub2); !errors.Is(err, hostclaim.ErrConflict) { |
| 675 | t.Fatalf("conflicted retry = %v", err) |
| 676 | } |
| 677 | conflicted, err := hostclaim.ReadLossRecord(ctx, w2.raw, w2.pub, sub2.Confirmation) |
| 678 | if err != nil || conflicted.Record.State != "conflicted" { |
| 679 | t.Fatalf("loss = %+v %v", conflicted.Record, err) |
| 680 | } |
| 681 | snap2 := lossSnapshot(t, w2) |
| 682 | prepared := conflicted.Record |
| 683 | prepared.State = "prepared" |
| 684 | if err := (&moveLossOwner{store: w2.raw, pub: w2.pub}).PrepareLoss(ctx, w2.claim(t), prepared); err == nil { |
| 685 | t.Fatal("prepare of a conflicted declaration accepted") |
| 686 | } |
| 687 | if lossSnapshot(t, w2) != snap2 { |
| 688 | t.Fatal("prepare of a conflicted declaration wrote") |
| 689 | } |
| 690 | if rec, _ := w2.record(t); rec.Move.State != provision.MoveLanding { |
| 691 | t.Fatalf("record = %+v", rec.Move) |
| 692 | } |
| 693 | |
| 694 | // A successor holder before the release: FinishLoss refuses and writes |
| 695 | // nothing. |
| 696 | w3 := newLossWorld(t, "landing", nil) |
| 697 | sub3 := w3.submission(w3.inspect(t, "uk-box")) |
| 698 | hook := &lossNthPut{lossFaultStore: w3.raw, key: w3.claimK, n: 2} |
| 699 | cw, err := NewControlWriter(hook, w3.pub, func() time.Time { return w3.clock }) |
| 700 | if err != nil { |
| 701 | t.Fatal(err) |
| 702 | } |
| 703 | if _, err := cw.DeclareMoveLoss(ctx, w3.signer, lossBucket, "operator", "bab", "app", lossHost, "uk-box", sub3); err == nil { |
| 704 | t.Fatal("release fault not reached") |
| 705 | } |
| 706 | loss3, err := hostclaim.ReadLossRecord(ctx, w3.raw, w3.pub, sub3.Confirmation) |
| 707 | if err != nil || loss3.Record.State != "committed" { |
| 708 | t.Fatalf("loss = %+v %v", loss3.Record, err) |
| 709 | } |
| 710 | if _, err := hostclaim.Update(ctx, w3.raw.Store, w3.claim(t), func(c *hostclaim.Claim) error { |
| 711 | c.Holder.CreatedAt = c.Holder.CreatedAt.Add(time.Second) |
| 712 | inc, err := hostclaim.Incarnation("bab", "app", lossHost, c.Holder.CreatedAt) |
| 713 | c.Holder.Incarnation = inc |
| 714 | return err |
| 715 | }); err != nil { |
| 716 | t.Fatal(err) |
| 717 | } |
| 718 | snap3 := lossSnapshot(t, w3) |
| 719 | if err := (&moveLossOwner{store: w3.raw, pub: w3.pub}).FinishLoss(ctx, w3.claim(t), loss3.Record); err == nil { |
| 720 | t.Fatal("finish against a successor holder accepted") |
| 721 | } |
| 722 | if lossSnapshot(t, w3) != snap3 { |
| 723 | t.Fatal("finish against a successor wrote") |
| 724 | } |
| 725 | } |
| 726 | |
| 727 | // ValidateDeclareLost is pure and exact: changed signed effects are refused |
| 728 | // inside the claim CAS. |
| 729 | func TestJ4ValidateDeclareLostRefusesChangedEffects(t *testing.T) { |
| 730 | ctx := context.Background() |
| 731 | w := newLossWorld(t, "landing", nil) |
| 732 | o := w.claim(t) |
| 733 | owner := &moveLossOwner{store: w.raw, pub: w.pub} |
| 734 | _, tag := w.record(t) |
| 735 | _, actual, err := owner.InspectLoss(ctx, o, hostfence.RecordObservation{Key: w.key, ETag: tag, CommitState: "present"}, "uk-box") |
| 736 | if err != nil { |
| 737 | t.Fatal(err) |
| 738 | } |
| 739 | d := hostclaim.LostDeclaration{LossID: "loss_1", ExpectedClaimGen: o.Claim.Gen, ExpectedClaimETag: o.ETag, HolderIncarnation: w.inc, HolderStamp: o.Claim.Holder.Stamp, |
| 740 | HolderSession: o.Claim.Holder.Session, OpID: lossOp, OpKind: hostclaim.KindLanding, OpPhase: "probing", Box: "uk-box", QuarantineRevision: 1, Enrollment: strings.Repeat("b", 32), FencedAt: w.clock, Consequences: actual} |
| 741 | if err := owner.ValidateDeclareLost(*o.Claim, d); err != nil { |
| 742 | t.Fatal(err) |
| 743 | } |
| 744 | for name, change := range map[string]func(*hostclaim.LostDeclaration){ |
| 745 | "keep execution": func(d *hostclaim.LostDeclaration) { d.Consequences.RevokeExecution = false }, |
| 746 | "no writes lost": func(d *hostclaim.LostDeclaration) { d.Consequences.WritesPotentiallyLost = false }, |
| 747 | "destroyed storage": func(d *hostclaim.LostDeclaration) { d.Consequences.DestroyedStorage = []string{"x"} }, |
| 748 | "other box": func(d *hostclaim.LostDeclaration) { d.Box = "ie-box" }, |
| 749 | "other etag": func(d *hostclaim.LostDeclaration) { d.ExpectedClaimETag = "\"other\"" }, |
| 750 | "intent": func(d *hostclaim.LostDeclaration) { |
| 751 | d.Consequences.RecoveryIntent = json.RawMessage(`{"kind":"move-target-lost"}`) |
| 752 | }, |
| 753 | "intent move": func(d *hostclaim.LostDeclaration) { |
| 754 | var m map[string]any |
| 755 | _ = json.Unmarshal(d.Consequences.RecoveryIntent, &m) |
| 756 | m["move"] = "mv_cccccccccccccccccccccccccc" |
| 757 | d.Consequences.RecoveryIntent, _ = json.Marshal(m) |
| 758 | }, |
| 759 | "intent target sessions": func(d *hostclaim.LostDeclaration) { |
| 760 | var m map[string]any |
| 761 | _ = json.Unmarshal(d.Consequences.RecoveryIntent, &m) |
| 762 | m["src_covered"] = true |
| 763 | d.Consequences.RecoveryIntent, _ = json.Marshal(m) |
| 764 | }, |
| 765 | } { |
| 766 | changed := d |
| 767 | changed.Consequences.RecoveryIntent = append(json.RawMessage(nil), d.Consequences.RecoveryIntent...) |
| 768 | change(&changed) |
| 769 | if err := owner.ValidateDeclareLost(*o.Claim, changed); err == nil { |
| 770 | t.Errorf("%s accepted", name) |
| 771 | } |
| 772 | } |
| 773 | } |
| 774 | |
| 775 | // The scoped store behind the declaration admits only the bound |
| 776 | // declaration's objects and exact transitions; a raw write, a delete, an |
| 777 | // unrelated key or a different transition through it is refused. |
| 778 | func TestJ4ScopedStoreRefusesEverythingElse(t *testing.T) { |
| 779 | ctx := context.Background() |
| 780 | w := newLossWorld(t, "landing", nil) |
| 781 | o := w.claim(t) |
| 782 | s := &lossScopedStore{raw: w.raw, pub: w.pub, now: func() time.Time { return w.clock }, |
| 783 | b: lossBinding{lossID: "loss_1", host: lossHost, opID: lossOp, kind: hostclaim.KindLanding, box: "uk-box", claimETag: o.ETag, incarnation: w.inc, account: "bab", app: "app"}} |
| 784 | snap := lossSnapshot(t, w) |
| 785 | if _, err := s.Put(ctx, "apps/bab/app/other.json", []byte(`{}`), ""); !errors.Is(err, ErrContract) { |
| 786 | t.Fatalf("unrelated key = %v", err) |
| 787 | } |
| 788 | if err := s.Delete(ctx, w.key); !errors.Is(err, ErrContract) { |
| 789 | t.Fatalf("delete = %v", err) |
| 790 | } |
| 791 | // A loss record or acceptance for another declaration id. |
| 792 | if _, err := s.Put(ctx, "control/host-loss/loss_2.json", []byte(`{}`), ""); !errors.Is(err, ErrContract) { |
| 793 | t.Fatalf("other loss = %v", err) |
| 794 | } |
| 795 | if _, err := s.Put(ctx, "control/host-loss-acceptances/loss_1.json", []byte(`{}`), ""); err == nil { |
| 796 | t.Fatal("unsigned acceptance accepted") |
| 797 | } |
| 798 | // The roster, unsigned or with another change. |
| 799 | if _, err := s.Put(ctx, hostactors.Key, []byte(`{}`), w.roster(t).ETag); err == nil { |
| 800 | t.Fatal("unsigned roster accepted") |
| 801 | } |
| 802 | ro := w.roster(t) |
| 803 | r := hostactors.Clone(ro.Roster) |
| 804 | b := r.Boxes["ie-box"] |
| 805 | b.State, b.QuarantineRevision, b.LossIDs = "quarantined", 1, []string{"loss_1"} |
| 806 | r.Boxes["ie-box"] = b |
| 807 | if _, err := (hostactors.Directory{Store: s, PublicKey: w.pub, Bucket: lossBucket}).Replace(ctx, w.signer, ro, r); err == nil { |
| 808 | t.Fatal("quarantine of another box accepted") |
| 809 | } |
| 810 | if lossSnapshot(t, w) != snap { |
| 811 | t.Fatal("a refused scoped write landed") |
| 812 | } |
| 813 | } |
| 814 | |
| 815 | // Only the move kinds are registered, and only on the typed methods: a |
| 816 | // serving claim is not resolvable through them. |
| 817 | func TestJ4OnlyMoveKindsAreServed(t *testing.T) { |
| 818 | w := newLossWorld(t, "landing", func(c *hostclaim.Claim, r *provision.Record, _ *AppAdmission) { |
| 819 | c.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: "ie-box"} |
| 820 | r.Move = nil |
| 821 | }) |
| 822 | if _, err := w.control.InspectMoveLoss(context.Background(), w.signer, lossBucket, "operator", "bab", "app", lossHost, "ie-box"); !errors.Is(err, hostresolve.ErrResolve) { |
| 823 | t.Fatalf("serving = %v", err) |
| 824 | } |
| 825 | } |
| 826 | |
| 827 | // lossAfterPut runs after once, right after the first Put of key lands. |
| 828 | type lossAfterPut struct { |
| 829 | *lossFaultStore |
| 830 | key string |
| 831 | after func() |
| 832 | } |
| 833 | |
| 834 | func (s *lossAfterPut) Put(ctx context.Context, key string, body []byte, match string) (string, error) { |
| 835 | tag, err := s.lossFaultStore.Store.Put(ctx, key, body, match) |
| 836 | if err == nil && key == s.key && s.after != nil { |
| 837 | f := s.after |
| 838 | s.after = nil |
| 839 | f() |
| 840 | } |
| 841 | return tag, err |
| 842 | } |
| 843 | |
| 844 | // The registration is reread at every scoped write, not only at entry: one |
| 845 | // removed after the acceptance stops the quarantine and everything after it. |
| 846 | func TestJ4RegistrationRecheckedAtEachWrite(t *testing.T) { |
| 847 | ctx := context.Background() |
| 848 | w := newLossWorld(t, "landing", nil) |
| 849 | sub := w.submission(w.inspect(t, "uk-box")) |
| 850 | roster := w.roster(t).ETag |
| 851 | hook := &lossAfterPut{lossFaultStore: w.raw, key: "control/host-loss-acceptances/" + sub.Confirmation + ".json", after: func() { |
| 852 | a, err := ReadAdmission(ctx, w.raw, "bab", "app", "canonical") |
| 853 | if err != nil { |
| 854 | t.Fatal(err) |
| 855 | } |
| 856 | delete(a.Value.Registrations, lossOp) |
| 857 | b, _ := json.Marshal(a.Value) |
| 858 | if _, err := w.raw.Store.Put(ctx, a.Key, b, a.ETag); err != nil { |
| 859 | t.Fatal(err) |
| 860 | } |
| 861 | }} |
| 862 | cw, err := NewControlWriter(hook, w.pub, func() time.Time { return w.clock }) |
| 863 | if err != nil { |
| 864 | t.Fatal(err) |
| 865 | } |
| 866 | before := w.claim(t) |
| 867 | rec, tag := w.record(t) |
| 868 | if _, err := cw.DeclareMoveLoss(ctx, w.signer, lossBucket, "operator", "bab", "app", lossHost, "uk-box", sub); !errors.Is(err, ErrPending) { |
| 869 | t.Fatalf("declaration after the registration went = %v", err) |
| 870 | } |
| 871 | if w.roster(t).ETag != roster { |
| 872 | t.Fatal("quarantined without the registration") |
| 873 | } |
| 874 | if c := w.claim(t); c.ETag != before.ETag { |
| 875 | t.Fatal("claim written without the registration") |
| 876 | } |
| 877 | if after, tag2 := w.record(t); tag2 != tag || after.Move.State != rec.Move.State { |
| 878 | t.Fatal("record written without the registration") |
| 879 | } |
| 880 | } |
| 881 | |
| 882 | // A record changed after acceptance (a late box write) is not overwritten |
| 883 | // by the declaration's decision: the prepare refuses and the declaration |
| 884 | // stays prepared, with the claim untouched. |
| 885 | func TestJ4RecordChangedAfterAcceptanceRefused(t *testing.T) { |
| 886 | ctx := context.Background() |
| 887 | w := newLossWorld(t, "landing", nil) |
| 888 | sub := w.submission(w.inspect(t, "uk-box")) |
| 889 | lossKey, _ := hostclaim.LossRecordKey(sub.Confirmation) |
| 890 | hook := &lossAfterPut{lossFaultStore: w.raw, key: lossKey, after: func() { |
| 891 | if _, err := provision.SetRecordMove(provision.WithRecordSequence(ctx), w.raw.Store, "bab", "app", lossHost, func(r *provision.Record) error { |
| 892 | r.Move.LandingPhase = provision.LandProbed |
| 893 | return nil |
| 894 | }); err != nil { |
| 895 | t.Fatal(err) |
| 896 | } |
| 897 | }} |
| 898 | cw, err := NewControlWriter(hook, w.pub, func() time.Time { return w.clock }) |
| 899 | if err != nil { |
| 900 | t.Fatal(err) |
| 901 | } |
| 902 | before := w.claim(t) |
| 903 | if _, err := cw.DeclareMoveLoss(ctx, w.signer, lossBucket, "operator", "bab", "app", lossHost, "uk-box", sub); !errors.Is(err, hostclaim.ErrConflict) { |
| 904 | t.Fatalf("declaration over a changed record = %v", err) |
| 905 | } |
| 906 | rec, _ := w.record(t) |
| 907 | if rec.Move.State != provision.MoveLanding || rec.Move.LandingPhase != provision.LandProbed || rec.Move.Rollback != nil { |
| 908 | t.Fatalf("record = %+v", rec.Move) |
| 909 | } |
| 910 | if c := w.claim(t); c.ETag != before.ETag { |
| 911 | t.Fatal("claim written over a changed record") |
| 912 | } |
| 913 | loss, err := hostclaim.ReadLossRecord(ctx, w.raw, w.pub, sub.Confirmation) |
| 914 | if err != nil || loss.Record.State != "prepared" { |
| 915 | t.Fatalf("loss = %+v %v", loss.Record, err) |
| 916 | } |
| 917 | } |
| 918 | |
| 919 | // conflictAfterPrepare drives a declaration to B1's stranded state: the |
| 920 | // record prepare lands (lost with this loss as its reference), the claim |
| 921 | // CAS fails, an unrelated claim write moves the ETag, and the retry marks |
| 922 | // the declaration conflicted. |
| 923 | func conflictAfterPrepare(t *testing.T, w *lossWorld, sub hostfence.Submission) { |
| 924 | t.Helper() |
| 925 | ctx := context.Background() |
| 926 | w.raw.key, w.raw.failBefore = w.claimK, true |
| 927 | if _, err := w.declare(sub); err == nil { |
| 928 | t.Fatal("claim fault not reached") |
| 929 | } |
| 930 | ref, _ := hostclaim.LossRecordKey(sub.Confirmation) |
| 931 | if rec, _ := w.record(t); rec.Move.Rollback == nil || rec.Move.Rollback.TargetPhase != provision.RbTargetLost || rec.Move.Rollback.LossRef != ref { |
| 932 | t.Fatalf("record after prepare = %+v", rec.Move) |
| 933 | } |
| 934 | if _, err := hostclaim.Update(ctx, w.raw.Store, w.claim(t), func(*hostclaim.Claim) error { return nil }); err != nil { |
| 935 | t.Fatal(err) |
| 936 | } |
| 937 | if _, err := w.declare(sub); !errors.Is(err, hostclaim.ErrConflict) { |
| 938 | t.Fatalf("conflicted retry = %v", err) |
| 939 | } |
| 940 | if loss, err := hostclaim.ReadLossRecord(ctx, w.raw, w.pub, sub.Confirmation); err != nil || loss.Record.State != "conflicted" { |
| 941 | t.Fatalf("loss = %+v %v", loss.Record, err) |
| 942 | } |
| 943 | } |
| 944 | |
| 945 | // B1: a declaration conflicted after its record prepare leaves the record |
| 946 | // lost with a dead reference. A fresh declaration rebinds it and completes, |
| 947 | // keeping the rollback the first one decided, and the source takes over. |
| 948 | func TestJ4ConflictedPreparedLossRebinds(t *testing.T) { |
| 949 | ctx := context.Background() |
| 950 | w := newLossWorld(t, "landing", nil) |
| 951 | sub1 := w.submission(w.inspect(t, "uk-box")) |
| 952 | conflictAfterPrepare(t, w, sub1) |
| 953 | rec1, _ := w.record(t) |
| 954 | rb1 := rec1.Move.Rollback.ID |
| 955 | |
| 956 | in2, err := w.control.InspectMoveLoss(ctx, w.signer, lossBucket, "operator", "bab", "app", lossHost, "uk-box") |
| 957 | if err != nil { |
| 958 | t.Fatalf("fresh inspection: %v", err) |
| 959 | } |
| 960 | sub2 := w.submission(in2) |
| 961 | if _, err := w.declare(sub2); err != nil { |
| 962 | t.Fatalf("fresh declaration: %v", err) |
| 963 | } |
| 964 | ref2, _ := hostclaim.LossRecordKey(sub2.Confirmation) |
| 965 | c := w.claim(t) |
| 966 | if c.Claim.Op.Kind != hostclaim.KindRestoring || c.Claim.Op.Phase != hostclaim.PhaseReleased || c.Claim.Op.Intent["rollback"] != rb1 || |
| 967 | !hostclaim.LostTargetReleased(c.Claim, sub2.Confirmation) || !reflect.DeepEqual(c.Claim.LossRefs, []string{sub2.Confirmation}) { |
| 968 | t.Fatalf("claim = %+v refs %v", c.Claim.Op, c.Claim.LossRefs) |
| 969 | } |
| 970 | rec, _ := w.record(t) |
| 971 | if r := rec.Move.Rollback; r.ID != rb1 || r.TargetPhase != provision.RbTargetReleased || r.Dirty || r.LossRef != ref2 || r.Evidence != ref2 || r.Reason != provision.RollbackDeclaredLost { |
| 972 | t.Fatalf("rollback = %+v", r) |
| 973 | } |
| 974 | if err := provision.MoveRollbackSourcePhase(provision.MoveGuard{ID: lossOp, Incarnation: w.inc}, provision.RbSourceWaiting, provision.RbSourceClaimed, nil)(&rec); err != nil { |
| 975 | t.Fatalf("source waiting: %v", err) |
| 976 | } |
| 977 | if err := hostclaim.ReturnToSource(lossOp, "ie-box", true, hostclaim.ReturnFence{})(c.Claim); err != nil { |
| 978 | t.Fatalf("source claimed: %v", err) |
| 979 | } |
| 980 | } |
| 981 | |
| 982 | // lossIntentFor reads a declaration's signed intent. |
| 983 | func lossIntentFor(t *testing.T, w *lossWorld, id string) moveLossIntent { |
| 984 | t.Helper() |
| 985 | loss, err := hostclaim.ReadLossRecord(context.Background(), w.raw, w.pub, id) |
| 986 | if err != nil { |
| 987 | t.Fatal(err) |
| 988 | } |
| 989 | in, err := decodeMoveLossIntent(loss.Record.ConfirmedConsequences.RecoveryIntent) |
| 990 | if err != nil { |
| 991 | t.Fatal(err) |
| 992 | } |
| 993 | return in |
| 994 | } |
| 995 | |
| 996 | // forgeLoss rewrites a signed loss record with the root signer (standing in |
| 997 | // for any signed state the test needs) and returns the bytes it replaced. |
| 998 | func forgeLoss(t *testing.T, w *lossWorld, id string, change func(*hostclaim.LossRecord)) []byte { |
| 999 | t.Helper() |
| 1000 | ctx := context.Background() |
| 1001 | key, _ := hostclaim.LossRecordKey(id) |
| 1002 | old, tag, err := w.raw.Store.Get(ctx, key) |
| 1003 | if err != nil { |
| 1004 | t.Fatal(err) |
| 1005 | } |
| 1006 | loss, err := hostclaim.ReadLossRecord(ctx, w.raw, w.pub, id) |
| 1007 | if err != nil { |
| 1008 | t.Fatal(err) |
| 1009 | } |
| 1010 | r := loss.Record |
| 1011 | change(&r) |
| 1012 | wire, err := release.SignRootEnvelope(w.signer, r) |
| 1013 | if err != nil { |
| 1014 | t.Fatal(err) |
| 1015 | } |
| 1016 | if _, err := w.raw.Store.Put(ctx, key, wire, tag); err != nil { |
| 1017 | t.Fatal(err) |
| 1018 | } |
| 1019 | return old |
| 1020 | } |
| 1021 | |
| 1022 | // A dirty target's rebind keeps the signed dirty phase and its possibly |
| 1023 | // partial return lane, across a chain of conflicted declarations; the |
| 1024 | // rollback the first declaration found stands throughout, and a late |
| 1025 | // prepare of a superseded declaration writes nothing. |
| 1026 | func TestJ4ConflictedLossChainKeepsSignedPhase(t *testing.T) { |
| 1027 | ctx := context.Background() |
| 1028 | for _, tc := range []struct { |
| 1029 | state, phase string |
| 1030 | dirty, lane bool |
| 1031 | }{ |
| 1032 | {"landing", provision.RbTargetReleasing, false, false}, |
| 1033 | {"released-dirty", provision.RbTargetReleased, true, true}, |
| 1034 | {"returning", provision.RbTargetReturning, true, true}, |
| 1035 | } { |
| 1036 | t.Run(tc.state, func(t *testing.T) { |
| 1037 | w := newLossWorld(t, tc.state, nil) |
| 1038 | sub1 := w.submission(w.inspect(t, "uk-box")) |
| 1039 | conflictAfterPrepare(t, w, sub1) |
| 1040 | rec1, _ := w.record(t) |
| 1041 | rb1 := rec1.Move.Rollback.ID |
| 1042 | ref1, _ := hostclaim.LossRecordKey(sub1.Confirmation) |
| 1043 | sub2 := w.submission(w.inspect(t, "uk-box")) |
| 1044 | conflictAfterPrepare(t, w, sub2) |
| 1045 | ref2, _ := hostclaim.LossRecordKey(sub2.Confirmation) |
| 1046 | sub3 := w.submission(w.inspect(t, "uk-box")) |
| 1047 | if _, err := w.declare(sub3); err != nil { |
| 1048 | t.Fatalf("third declaration: %v", err) |
| 1049 | } |
| 1050 | for id, supersedes := range map[string]string{sub2.Confirmation: ref1, sub3.Confirmation: ref2} { |
| 1051 | in := lossIntentFor(t, w, id) |
| 1052 | if in.Supersedes != supersedes || in.Rollback != rb1 || in.RecordState != moveLossRecordRollingBack || in.TargetPhase != tc.phase || in.Dirty != tc.dirty || in.ReturnLane != tc.lane { |
| 1053 | t.Fatalf("intent %s = %+v", id, in) |
| 1054 | } |
| 1055 | } |
| 1056 | ref3, _ := hostclaim.LossRecordKey(sub3.Confirmation) |
| 1057 | rec, _ := w.record(t) |
| 1058 | if r := rec.Move.Rollback; r.ID != rb1 || r.TargetPhase != provision.RbTargetReleased || r.Dirty || r.Evidence != ref3 || r.LossRef != ref3 { |
| 1059 | t.Fatalf("rollback = %+v", r) |
| 1060 | } |
| 1061 | c := w.claim(t) |
| 1062 | if c.Claim.Op.Intent["rollback"] != rb1 || !reflect.DeepEqual(c.Claim.LossRefs, []string{sub3.Confirmation}) { |
| 1063 | t.Fatalf("claim = %+v %v", c.Claim.Op, c.Claim.LossRefs) |
| 1064 | } |
| 1065 | lane := false |
| 1066 | for _, o := range c.Claim.Obligations { |
| 1067 | lane = lane || (o.Region == "ie" && o.Park.Location == "moves/"+lossHost+"/"+rb1) |
| 1068 | } |
| 1069 | if lane != tc.lane { |
| 1070 | t.Fatalf("return lane parked = %v, obligations %+v", lane, c.Claim.Obligations) |
| 1071 | } |
| 1072 | // Late hooks of the superseded declarations write nothing. |
| 1073 | snap := lossSnapshot(t, w) |
| 1074 | owner := &moveLossOwner{store: w.raw, pub: w.pub} |
| 1075 | for _, id := range []string{sub1.Confirmation, sub2.Confirmation} { |
| 1076 | loss, err := hostclaim.ReadLossRecord(ctx, w.raw, w.pub, id) |
| 1077 | if err != nil { |
| 1078 | t.Fatal(err) |
| 1079 | } |
| 1080 | prepared := loss.Record |
| 1081 | prepared.State = "prepared" |
| 1082 | _ = owner.PrepareLoss(ctx, c, prepared) |
| 1083 | if _, err := w.declare(map[string]hostfence.Submission{sub1.Confirmation: sub1, sub2.Confirmation: sub2}[id]); !errors.Is(err, hostclaim.ErrConflict) { |
| 1084 | t.Fatalf("superseded replay = %v", err) |
| 1085 | } |
| 1086 | } |
| 1087 | if lossSnapshot(t, w) != snap { |
| 1088 | t.Fatal("a superseded declaration wrote") |
| 1089 | } |
| 1090 | }) |
| 1091 | } |
| 1092 | } |
| 1093 | |
| 1094 | // Only a signed, conflicted declaration of this move's target whose rollback |
| 1095 | // and dirty bit the record still carries may be superseded; everything |
| 1096 | // else stays refused before acceptance. |
| 1097 | func TestJ4ConflictedLossRebindRefusals(t *testing.T) { |
| 1098 | ctx := context.Background() |
| 1099 | for name, setup := range map[string]func(*testing.T, *lossWorld){ |
| 1100 | "old prepared": func(t *testing.T, w *lossWorld) { |
| 1101 | sub := w.submission(w.inspect(t, "uk-box")) |
| 1102 | w.raw.key, w.raw.failBefore = w.claimK, true |
| 1103 | if _, err := w.declare(sub); err == nil { |
| 1104 | t.Fatal("claim fault not reached") |
| 1105 | } |
| 1106 | }, |
| 1107 | "old committed": func(t *testing.T, w *lossWorld) { |
| 1108 | sub := w.submission(w.inspect(t, "uk-box")) |
| 1109 | conflictAfterPrepare(t, w, sub) |
| 1110 | forgeLoss(t, w, sub.Confirmation, func(r *hostclaim.LossRecord) { r.State, r.ResultClaimGen = "committed", r.ExpectedClaimGen+1 }) |
| 1111 | }, |
| 1112 | "old other host": func(t *testing.T, w *lossWorld) { |
| 1113 | sub := w.submission(w.inspect(t, "uk-box")) |
| 1114 | conflictAfterPrepare(t, w, sub) |
| 1115 | forgeLoss(t, w, sub.Confirmation, func(r *hostclaim.LossRecord) { r.Host = "other.example" }) |
| 1116 | }, |
| 1117 | "old other rollback": func(t *testing.T, w *lossWorld) { |
| 1118 | sub := w.submission(w.inspect(t, "uk-box")) |
| 1119 | conflictAfterPrepare(t, w, sub) |
| 1120 | forgeLoss(t, w, sub.Confirmation, func(r *hostclaim.LossRecord) { |
| 1121 | var m map[string]any |
| 1122 | _ = json.Unmarshal(r.ConfirmedConsequences.RecoveryIntent, &m) |
| 1123 | m["rollback"] = "rb_cccccccccccccccccccccccccc" |
| 1124 | r.ConfirmedConsequences.RecoveryIntent, _ = json.Marshal(m) |
| 1125 | }) |
| 1126 | }, |
| 1127 | "old other incarnation": func(t *testing.T, w *lossWorld) { |
| 1128 | sub := w.submission(w.inspect(t, "uk-box")) |
| 1129 | conflictAfterPrepare(t, w, sub) |
| 1130 | forgeLoss(t, w, sub.Confirmation, func(r *hostclaim.LossRecord) { |
| 1131 | var m map[string]any |
| 1132 | _ = json.Unmarshal(r.ConfirmedConsequences.RecoveryIntent, &m) |
| 1133 | m["incarnation"] = "other" |
| 1134 | r.ConfirmedConsequences.RecoveryIntent, _ = json.Marshal(m) |
| 1135 | r.HolderIncarnation = "other" |
| 1136 | }) |
| 1137 | }, |
| 1138 | "old unsigned": func(t *testing.T, w *lossWorld) { |
| 1139 | sub := w.submission(w.inspect(t, "uk-box")) |
| 1140 | conflictAfterPrepare(t, w, sub) |
| 1141 | key, _ := hostclaim.LossRecordKey(sub.Confirmation) |
| 1142 | _, tag, _ := w.raw.Store.Get(ctx, key) |
| 1143 | _, priv, _ := ed25519.GenerateKey(rand.Reader) |
| 1144 | loss, _ := hostclaim.ReadLossRecord(ctx, w.raw, w.pub, sub.Confirmation) |
| 1145 | wire, _ := release.SignRootEnvelope(release.RootSigner(priv), loss.Record) |
| 1146 | if _, err := w.raw.Store.Put(ctx, key, wire, tag); err != nil { |
| 1147 | t.Fatal(err) |
| 1148 | } |
| 1149 | }, |
| 1150 | "record names a fence": func(t *testing.T, w *lossWorld) { |
| 1151 | sub := w.submission(w.inspect(t, "uk-box")) |
| 1152 | conflictAfterPrepare(t, w, sub) |
| 1153 | if _, err := provision.SetRecordMove(provision.WithRecordSequence(ctx), w.raw.Store, "bab", "app", lossHost, func(r *provision.Record) error { |
| 1154 | r.Move.Rollback.LossRef = "control/host-fences/f.json" |
| 1155 | return nil |
| 1156 | }); err != nil { |
| 1157 | t.Fatal(err) |
| 1158 | } |
| 1159 | }, |
| 1160 | "record dirty changed": func(t *testing.T, w *lossWorld) { |
| 1161 | sub := w.submission(w.inspect(t, "uk-box")) |
| 1162 | conflictAfterPrepare(t, w, sub) |
| 1163 | if _, err := provision.SetRecordMove(provision.WithRecordSequence(ctx), w.raw.Store, "bab", "app", lossHost, func(r *provision.Record) error { |
| 1164 | r.Move.Rollback.Dirty = true |
| 1165 | return nil |
| 1166 | }); err != nil { |
| 1167 | t.Fatal(err) |
| 1168 | } |
| 1169 | }, |
| 1170 | } { |
| 1171 | t.Run(name, func(t *testing.T) { |
| 1172 | w := newLossWorld(t, "landing", nil) |
| 1173 | setup(t, w) |
| 1174 | snap := lossSnapshot(t, w) |
| 1175 | if _, err := w.control.InspectMoveLoss(ctx, w.signer, lossBucket, "operator", "bab", "app", lossHost, "uk-box"); err == nil { |
| 1176 | t.Fatal("inspection accepted") |
| 1177 | } |
| 1178 | if lossSnapshot(t, w) != snap { |
| 1179 | t.Fatal("a refused inspection wrote") |
| 1180 | } |
| 1181 | }) |
| 1182 | } |
| 1183 | } |
| 1184 | |
| 1185 | // The rebind's record write rereads the superseded declaration at the |
| 1186 | // final write: a superseded declaration no longer signed conflicted stops |
| 1187 | // both the owner and the scoped store; restored, the same declaration |
| 1188 | // completes. |
| 1189 | func TestJ4ConflictedLossRebindRecheckedAtWrite(t *testing.T) { |
| 1190 | ctx := context.Background() |
| 1191 | w := newLossWorld(t, "landing", nil) |
| 1192 | sub1 := w.submission(w.inspect(t, "uk-box")) |
| 1193 | conflictAfterPrepare(t, w, sub1) |
| 1194 | sub2 := w.submission(w.inspect(t, "uk-box")) |
| 1195 | w.raw.key, w.raw.failBefore = w.key, true |
| 1196 | if _, err := w.declare(sub2); err == nil { |
| 1197 | t.Fatal("record fault not reached") |
| 1198 | } |
| 1199 | loss2, err := hostclaim.ReadLossRecord(ctx, w.raw, w.pub, sub2.Confirmation) |
| 1200 | if err != nil || loss2.Record.State != "prepared" { |
| 1201 | t.Fatalf("loss = %+v %v", loss2.Record, err) |
| 1202 | } |
| 1203 | conflicted := forgeLoss(t, w, sub1.Confirmation, func(r *hostclaim.LossRecord) { r.State = "prepared" }) |
| 1204 | snap := lossSnapshot(t, w) |
| 1205 | if err := (&moveLossOwner{store: w.raw, pub: w.pub}).PrepareLoss(ctx, w.claim(t), loss2.Record); err == nil { |
| 1206 | t.Fatal("owner rebind over a prepared declaration accepted") |
| 1207 | } |
| 1208 | in2, ref2, err := lossIntentOf(loss2.Record) |
| 1209 | if err != nil { |
| 1210 | t.Fatal(err) |
| 1211 | } |
| 1212 | s := &lossScopedStore{raw: w.raw, pub: w.pub, now: func() time.Time { return w.clock }, |
| 1213 | b: lossBinding{lossID: sub2.Confirmation, host: lossHost, opID: lossOp, kind: hostclaim.KindLanding, box: "uk-box", claimETag: loss2.Record.ExpectedClaimETag, incarnation: w.inc, account: "bab", app: "app"}} |
| 1214 | if _, err := provision.SetRecordMove(provision.WithRecordSequence(ctx), s, "bab", "app", lossHost, func(r *provision.Record) error { |
| 1215 | return prepareMoveLoss(r, in2, ref2, loss2.Record.FencedAt) |
| 1216 | }); err == nil { |
| 1217 | t.Fatal("scoped rebind over a prepared declaration accepted") |
| 1218 | } |
| 1219 | if lossSnapshot(t, w) != snap { |
| 1220 | t.Fatal("a refused rebind wrote") |
| 1221 | } |
| 1222 | key1, _ := hostclaim.LossRecordKey(sub1.Confirmation) |
| 1223 | _, tag, _ := w.raw.Store.Get(ctx, key1) |
| 1224 | if _, err := w.raw.Store.Put(ctx, key1, conflicted, tag); err != nil { |
| 1225 | t.Fatal(err) |
| 1226 | } |
| 1227 | if _, err := w.declare(sub2); err != nil { |
| 1228 | t.Fatalf("resume: %v", err) |
| 1229 | } |
| 1230 | if rec, _ := w.record(t); rec.Move.Rollback.TargetPhase != provision.RbTargetReleased || rec.Move.Rollback.Evidence != ref2 { |
| 1231 | t.Fatalf("rollback = %+v", rec.Move.Rollback) |
| 1232 | } |
| 1233 | } |
| 1234 | |