| 1 | package hostclaim |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/ed25519" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "reflect" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/carlosframework/platform/internal/release" |
| 14 | ) |
| 15 | |
| 16 | // Existing fixtures declare directly in the recovery phase. The end-to-end |
| 17 | // test below supplies a separate owner for landing -> restoring. |
| 18 | type testLossRecoveryOwner struct{} |
| 19 | |
| 20 | func (testLossRecoveryOwner) ValidateLossRecovery(c Claim, loss LossRecord, in Admission) error { |
| 21 | if c.Op.Kind != loss.OpKind || c.Op.Phase != loss.OpPhase || c.Op.Exec != in.Box { |
| 22 | return ErrWrongState |
| 23 | } |
| 24 | return nil |
| 25 | } |
| 26 | |
| 27 | type lossRecoveryOwnerFunc func(Claim, LossRecord, Admission) error |
| 28 | |
| 29 | func (f lossRecoveryOwnerFunc) ValidateLossRecovery(c Claim, loss LossRecord, in Admission) error { |
| 30 | return f(c, loss, in) |
| 31 | } |
| 32 | |
| 33 | func validLossRecord(t *testing.T) LossRecord { |
| 34 | t.Helper() |
| 35 | now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) |
| 36 | inc := strings.Repeat("a", 64) |
| 37 | return LossRecord{ |
| 38 | V: 1, ID: "loss_1", State: "prepared", Actor: "operator", AcceptedAt: now, |
| 39 | Host: testHost, HolderIncarnation: inc, HolderStamp: 3, HolderSession: 2, ExpectedClaimGen: 4, ExpectedClaimETag: `"claim"`, |
| 40 | OpID: "op", OpKind: KindMoving, OpPhase: "precommit", Box: "box-a", Enrollment: "enroll-a", QuarantineRevision: 9, |
| 41 | CloudObservation: LossCloudObservation{ObservedAt: now, Account: "123", Region: "eu-west-1", InstanceID: "i-1", State: "stopped", RequestID: "request-1"}, |
| 42 | OperatorAttestation: LossOperatorAttestation{MaintainComputeFence: true, ColdQuarantineStartOnly: true}, FencedAt: now, |
| 43 | ConfirmedConsequences: LossConsequences{RevokeExecution: true, RemoveParticipation: true, RecoveryIntent: json.RawMessage(`{"kind":"lost-target"}`), TimersPotentiallyLost: true}, |
| 44 | Timers: TimerLoss{Knowledge: TimersUnknown}, |
| 45 | WriteSources: []WriteSource{{Reason: "target", Box: "box-a", Generation: "g1", Session: 3, Region: "ie", Baseline: "snapshot:v1"}}, |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | func TestLossRecordCreateOnlyConditionalAndImmutable(t *testing.T) { |
| 50 | ctx := context.Background() |
| 51 | s := release.NewDirStore(t.TempDir()) |
| 52 | pub, priv, err := ed25519.GenerateKey(nil) |
| 53 | if err != nil { |
| 54 | t.Fatal(err) |
| 55 | } |
| 56 | sg := release.RootSigner(priv) |
| 57 | record := validLossRecord(t) |
| 58 | created, err := CreateLossRecord(ctx, s, sg, record) |
| 59 | if err != nil { |
| 60 | t.Fatal(err) |
| 61 | } |
| 62 | if created.ETag == "" { |
| 63 | t.Fatal("create returned no ETag") |
| 64 | } |
| 65 | retry, err := CreateLossRecord(ctx, s, sg, record) |
| 66 | if err != nil || retry.ETag != created.ETag { |
| 67 | t.Fatalf("idempotent create = %+v, %v", retry, err) |
| 68 | } |
| 69 | read, err := ReadLossRecord(ctx, s, pub, record.ID) |
| 70 | if err != nil || !reflect.DeepEqual(read.Record, record) { |
| 71 | t.Fatalf("read = %+v, %v", read, err) |
| 72 | } |
| 73 | |
| 74 | committed := read.Record |
| 75 | committed.State, committed.ResultClaimGen = "committed", 5 |
| 76 | next, err := ReplaceLossRecord(ctx, s, sg, read, committed) |
| 77 | if err != nil { |
| 78 | t.Fatal(err) |
| 79 | } |
| 80 | mutated := next.Record |
| 81 | mutated.FencedAt = mutated.FencedAt.Add(time.Second) |
| 82 | if _, err := ReplaceLossRecord(ctx, s, sg, next, mutated); !errors.Is(err, ErrLossRecord) { |
| 83 | t.Fatalf("immutable evidence mutation = %v", err) |
| 84 | } |
| 85 | if _, err := ReplaceLossRecord(ctx, s, sg, read, committed); !errors.Is(err, ErrConflict) { |
| 86 | t.Fatalf("stale conditional replace = %v", err) |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | func TestTimerKnowledgeNeverConflatesUnknownWithEmptyComplete(t *testing.T) { |
| 91 | r := validLossRecord(t) |
| 92 | if err := r.Validate(); err != nil { |
| 93 | t.Fatal(err) |
| 94 | } |
| 95 | r.Timers.Knowledge = TimersComplete |
| 96 | if err := r.Validate(); !errors.Is(err, ErrLossRecord) { |
| 97 | t.Fatalf("complete without source = %v", err) |
| 98 | } |
| 99 | r.Timers = TimerLoss{} |
| 100 | if err := r.Validate(); !errors.Is(err, ErrLossRecord) { |
| 101 | t.Fatalf("empty knowledge = %v", err) |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | func TestLossRecordCreateRetryUsesSerializedEvidence(t *testing.T) { |
| 106 | for _, name := range []string{"ordinary timestamps", "spaced recovery intent", "both"} { |
| 107 | t.Run(name, func(t *testing.T) { |
| 108 | ctx := t.Context() |
| 109 | s := release.NewDirStore(t.TempDir()) |
| 110 | _, priv, err := ed25519.GenerateKey(nil) |
| 111 | if err != nil { |
| 112 | t.Fatal(err) |
| 113 | } |
| 114 | sg := release.RootSigner(priv) |
| 115 | record := validLossRecord(t) |
| 116 | if name != "spaced recovery intent" { |
| 117 | now := time.Now() |
| 118 | record.AcceptedAt, record.FencedAt, record.CloudObservation.ObservedAt = now, now, now |
| 119 | } |
| 120 | if name != "ordinary timestamps" { |
| 121 | record.ConfirmedConsequences.RecoveryIntent = json.RawMessage(`{ "kind": "lost-target", "txid": 9007199254740993 }`) |
| 122 | } |
| 123 | created, err := CreateLossRecord(ctx, s, sg, record) |
| 124 | if err != nil { |
| 125 | t.Fatal(err) |
| 126 | } |
| 127 | retry, err := CreateLossRecord(ctx, s, sg, record) |
| 128 | if err != nil || retry.ETag != created.ETag { |
| 129 | t.Errorf("identical serialized evidence retry = %+v, %v", retry, err) |
| 130 | } |
| 131 | changed := record |
| 132 | changed.FencedAt = changed.FencedAt.Add(time.Nanosecond) |
| 133 | if _, err := CreateLossRecord(ctx, s, sg, changed); !errors.Is(err, ErrLossRecord) { |
| 134 | t.Fatalf("changed evidence accepted: %v", err) |
| 135 | } |
| 136 | changed = record |
| 137 | changed.ConfirmedConsequences.RecoveryIntent = json.RawMessage(`{"kind":"lost-target","txid":9007199254740992}`) |
| 138 | if _, err := CreateLossRecord(ctx, s, sg, changed); !errors.Is(err, ErrLossRecord) { |
| 139 | t.Fatalf("changed recovery intent accepted: %v", err) |
| 140 | } |
| 141 | fresh, err := ReadLossRecord(ctx, s, sg.RootPublicKey(), record.ID) |
| 142 | if err != nil || fresh.ETag != created.ETag { |
| 143 | t.Fatalf("retry changed stored evidence: %v", err) |
| 144 | } |
| 145 | }) |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | func TestAdmissionLossRetentionRequiresExactDurableCopy(t *testing.T) { |
| 150 | ctx := context.Background() |
| 151 | s := release.NewDirStore(t.TempDir()) |
| 152 | pub, priv, err := ed25519.GenerateKey(nil) |
| 153 | if err != nil { |
| 154 | t.Fatal(err) |
| 155 | } |
| 156 | lost := WritesLost{Box: "target", Generation: "g-target", Session: 4, RemoteMaxTXID: 12} |
| 157 | source := WritesLost{Box: "source", Generation: "g-source", Session: 2, RemoteMaxTXID: 10} |
| 158 | obs := servingClaim(t, ctx, s) |
| 159 | obs, err = Update(ctx, s, obs, func(c *Claim) error { |
| 160 | c.LossRefs = []string{"loss_1"} |
| 161 | c.Holder.Admissions = []AdmissionRecord{{Region: "ie", Session: 8, LossID: "loss_1", WritesLost: &lost, AdditionalWritesLost: []WritesLost{source}}} |
| 162 | return nil |
| 163 | }) |
| 164 | if err != nil { |
| 165 | t.Fatal(err) |
| 166 | } |
| 167 | if err := RequireLossEvidenceArchived(obs.Claim); !errors.Is(err, ErrLossEvidencePending) { |
| 168 | t.Fatalf("unarchived evidence = %v", err) |
| 169 | } |
| 170 | record := validLossRecord(t) |
| 171 | record.Host = obs.Host |
| 172 | created, err := CreateLossRecord(ctx, s, release.RootSigner(priv), record) |
| 173 | if err != nil { |
| 174 | t.Fatal(err) |
| 175 | } |
| 176 | exact := []ArchivedAdmissionLoss{{LossID: "loss_1", AdmissionClaimGen: 8, WritesLost: &lost, AdditionalWritesLost: []WritesLost{source}}} |
| 177 | committed := created.Record |
| 178 | committed.State, committed.ResultClaimGen, committed.ArchivedAdmissionLosses = "committed", obs.Claim.Gen, exact |
| 179 | if _, err := ReplaceLossRecord(ctx, s, release.RootSigner(priv), created, committed); err != nil { |
| 180 | t.Fatal(err) |
| 181 | } |
| 182 | archived, err := ArchiveAdmissionLosses(ctx, s, pub, obs, "loss_1") |
| 183 | if err != nil { |
| 184 | t.Fatal(err) |
| 185 | } |
| 186 | if err := RequireLossEvidenceArchived(archived.Claim); err != nil { |
| 187 | t.Fatal(err) |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | func TestLossBearingAdmissionRequiresEveryFrozenTargetAndSourceCut(t *testing.T) { |
| 192 | ctx := context.Background() |
| 193 | s := release.NewDirStore(t.TempDir()) |
| 194 | pub, priv, err := ed25519.GenerateKey(nil) |
| 195 | if err != nil { |
| 196 | t.Fatal(err) |
| 197 | } |
| 198 | obs := servingClaim(t, ctx, s) |
| 199 | obs, err = Update(ctx, s, obs, func(c *Claim) error { |
| 200 | c.Op = Op{Kind: KindReverting, ID: "return", Phase: "recover", Exec: "recovery", Participants: []string{"recovery"}} |
| 201 | c.Holder.WriterBox, c.Holder.Generation, c.Holder.Session = "target", "g-target", 4 |
| 202 | c.LossRefs = []string{"loss_1"} |
| 203 | return nil |
| 204 | }) |
| 205 | if err != nil { |
| 206 | t.Fatal(err) |
| 207 | } |
| 208 | earlier := WritesLost{Box: "target", Generation: "g-earlier", Session: 3, RemoteMaxTXID: 7} |
| 209 | source := WritesLost{Box: "source", Generation: "g-source", Session: 2, RemoteMaxTXID: 10} |
| 210 | in := Admission{ObservedOpID: "return", ObservedPhase: "recover", ObservedSession: 4, ObservedGeneration: "g-target", Box: "recovery", Mode: "cold", NewGeneration: "g-recovery", Region: "ie", PredecessorRegion: "ie", RemoteMaxTXID: 12, AdditionalWritesLost: []WritesLost{earlier, source}} |
| 211 | if _, err := Update(ctx, s, obs, AdmitWriter(Admission{ObservedOpID: in.ObservedOpID, ObservedPhase: in.ObservedPhase, ObservedSession: in.ObservedSession, ObservedGeneration: in.ObservedGeneration, Box: in.Box, Mode: in.Mode, NewGeneration: in.NewGeneration, Region: in.Region, PredecessorRegion: in.PredecessorRegion, LossID: "loss_1", AdditionalWritesLost: in.AdditionalWritesLost})); !errors.Is(err, ErrWrongState) { |
| 212 | t.Fatalf("direct loss admission = %v", err) |
| 213 | } |
| 214 | record := validLossRecord(t) |
| 215 | record.Host = obs.Host |
| 216 | record.HolderIncarnation, record.HolderStamp, record.HolderSession = obs.Claim.Holder.Incarnation, obs.Claim.Holder.Stamp, 4 |
| 217 | record.OpID, record.OpKind, record.OpPhase, record.Box = "return", KindReverting, "recover", "target" |
| 218 | record.WriteSources = []WriteSource{ |
| 219 | {Reason: "target", Box: "target", Generation: "g-target", Session: 4, Region: "ie", Baseline: "target-snapshot"}, |
| 220 | {Reason: "target", Box: "target", Generation: "g-earlier", Session: 3, Region: "ie", Baseline: "earlier-snapshot"}, |
| 221 | {Reason: "source-cut", Box: "source", Generation: "g-source", Session: 2, Region: "ie", Baseline: "source-seal"}, |
| 222 | } |
| 223 | created, err := CreateLossRecord(ctx, s, release.RootSigner(priv), record) |
| 224 | if err != nil { |
| 225 | t.Fatal(err) |
| 226 | } |
| 227 | committed := created.Record |
| 228 | committed.State, committed.ResultClaimGen = "committed", obs.Claim.Gen |
| 229 | if _, err := ReplaceLossRecord(ctx, s, release.RootSigner(priv), created, committed); err != nil { |
| 230 | t.Fatal(err) |
| 231 | } |
| 232 | validated := []ValidatedWriteSource{ |
| 233 | {Reason: "target", Box: "target", Generation: "g-target", Session: 4, Region: "ie", Baseline: "target-snapshot", RemoteMaxTXID: in.RemoteMaxTXID}, |
| 234 | {Reason: "target", Box: "target", Generation: "g-earlier", Session: 3, Region: "ie", Baseline: "earlier-snapshot", RemoteMaxTXID: earlier.RemoteMaxTXID}, |
| 235 | {Reason: "source-cut", Box: "source", Generation: "g-source", Session: 2, Region: "ie", Baseline: "source-seal", RemoteMaxTXID: source.RemoteMaxTXID}, |
| 236 | } |
| 237 | for name, mutate := range map[string]func(*Admission, *[]ValidatedWriteSource){ |
| 238 | "omitted earlier target": func(a *Admission, _ *[]ValidatedWriteSource) { a.AdditionalWritesLost = a.AdditionalWritesLost[1:] }, |
| 239 | "omitted earlier target with same txid": func(a *Admission, v *[]ValidatedWriteSource) { |
| 240 | (*v)[1].RemoteMaxTXID = a.RemoteMaxTXID |
| 241 | a.AdditionalWritesLost = a.AdditionalWritesLost[1:] |
| 242 | }, |
| 243 | "omitted source cut": func(a *Admission, _ *[]ValidatedWriteSource) { a.AdditionalWritesLost = a.AdditionalWritesLost[:1] }, |
| 244 | "omitted all additional": func(a *Admission, _ *[]ValidatedWriteSource) { a.AdditionalWritesLost = nil }, |
| 245 | "earlier target uses predecessor txid": func(a *Admission, _ *[]ValidatedWriteSource) { |
| 246 | a.AdditionalWritesLost[0].RemoteMaxTXID = a.RemoteMaxTXID |
| 247 | }, |
| 248 | "source cut uses predecessor txid": func(a *Admission, _ *[]ValidatedWriteSource) { |
| 249 | a.AdditionalWritesLost[1].RemoteMaxTXID = a.RemoteMaxTXID |
| 250 | }, |
| 251 | "predecessor uses earlier txid": func(a *Admission, _ *[]ValidatedWriteSource) { a.RemoteMaxTXID = earlier.RemoteMaxTXID }, |
| 252 | "duplicate box session with another generation": func(a *Admission, _ *[]ValidatedWriteSource) { |
| 253 | duplicate := earlier |
| 254 | duplicate.Generation = "another-generation" |
| 255 | a.AdditionalWritesLost = append(a.AdditionalWritesLost, duplicate) |
| 256 | }, |
| 257 | "unfrozen tuple": func(a *Admission, _ *[]ValidatedWriteSource) { a.AdditionalWritesLost[0].Session++ }, |
| 258 | "omitted baseline": func(_ *Admission, v *[]ValidatedWriteSource) { *v = (*v)[:2] }, |
| 259 | "changed baseline": func(_ *Admission, v *[]ValidatedWriteSource) { (*v)[1].Baseline = "another-snapshot" }, |
| 260 | } { |
| 261 | t.Run(name, func(t *testing.T) { |
| 262 | candidate := in |
| 263 | candidate.AdditionalWritesLost = append([]WritesLost(nil), in.AdditionalWritesLost...) |
| 264 | evidence := append([]ValidatedWriteSource(nil), validated...) |
| 265 | mutate(&candidate, &evidence) |
| 266 | if _, err := AdmitWriterWithLoss(ctx, s, pub, obs, candidate, "loss_1", evidence, testLossRecoveryOwner{}); !errors.Is(err, ErrLossRecord) { |
| 267 | t.Fatalf("invalid declaration admitted: %v", err) |
| 268 | } |
| 269 | fresh, err := Read(ctx, s, obs.Host) |
| 270 | if err != nil || fresh.ETag != obs.ETag { |
| 271 | t.Fatalf("refusal changed claim: %v", err) |
| 272 | } |
| 273 | }) |
| 274 | } |
| 275 | admitted, err := AdmitWriterWithLoss(ctx, s, pub, obs, in, "loss_1", validated, testLossRecoveryOwner{}) |
| 276 | if err != nil { |
| 277 | t.Fatal(err) |
| 278 | } |
| 279 | last := admitted.Claim.Holder.Admissions[len(admitted.Claim.Holder.Admissions)-1] |
| 280 | if last.LossID != "loss_1" || !reflect.DeepEqual(last.AdditionalWritesLost, []WritesLost{earlier, source}) || last.WritesLost == nil || last.WritesLost.RemoteMaxTXID != 12 || last.WritesLost.Session != 4 { |
| 281 | t.Fatalf("loss admission = %+v", last) |
| 282 | } |
| 283 | t.Run("retry validates authorization and evidence", func(t *testing.T) { |
| 284 | denied := errors.New("recovery no longer authorized") |
| 285 | for _, name := range []string{ |
| 286 | "identical", "primary txid", "primary txid and baseline", "region", "predecessor region", |
| 287 | "observed session", "observed generation", "observed phase", "observed operation", |
| 288 | "certified", "coverage", "additional loss", "missing baseline", "changed baseline", |
| 289 | "owner denial", "operation", "phase", "executor", "incarnation", "signature", "missing declaration", |
| 290 | } { |
| 291 | t.Run(name, func(t *testing.T) { |
| 292 | candidate := in |
| 293 | candidate.AdditionalWritesLost = append([]WritesLost(nil), in.AdditionalWritesLost...) |
| 294 | evidence := append([]ValidatedWriteSource(nil), validated...) |
| 295 | observation := admitted |
| 296 | observation.Claim, err = cloneClaim(admitted.Claim) |
| 297 | if err != nil { |
| 298 | t.Fatal(err) |
| 299 | } |
| 300 | key := pub |
| 301 | var store release.Store = s |
| 302 | switch name { |
| 303 | case "primary txid": |
| 304 | candidate.RemoteMaxTXID++ |
| 305 | case "primary txid and baseline": |
| 306 | candidate.RemoteMaxTXID++ |
| 307 | evidence[0].RemoteMaxTXID++ |
| 308 | case "region": |
| 309 | candidate.Region = "uk" |
| 310 | case "predecessor region": |
| 311 | candidate.PredecessorRegion = "uk" |
| 312 | case "observed session": |
| 313 | candidate.ObservedSession++ |
| 314 | case "observed generation": |
| 315 | candidate.ObservedGeneration = "other" |
| 316 | case "observed phase": |
| 317 | candidate.ObservedPhase = "other" |
| 318 | case "observed operation": |
| 319 | candidate.ObservedOpID = "other" |
| 320 | case "certified": |
| 321 | candidate.PredecessorCertified = true |
| 322 | case "coverage": |
| 323 | candidate.Coverage = "sealed" |
| 324 | case "additional loss": |
| 325 | candidate.AdditionalWritesLost[0].RemoteMaxTXID++ |
| 326 | case "missing baseline": |
| 327 | evidence = nil |
| 328 | case "changed baseline": |
| 329 | evidence[0].Baseline = "other" |
| 330 | case "operation": |
| 331 | observation.Claim.Op.ID = "later-operation" |
| 332 | candidate.ObservedOpID = "later-operation" |
| 333 | case "phase": |
| 334 | observation.Claim.Op.Phase = "other" |
| 335 | candidate.ObservedPhase = "other" |
| 336 | case "executor": |
| 337 | observation.Claim.Op.Exec = "other" |
| 338 | case "incarnation": |
| 339 | observation.Claim.Holder.Incarnation = strings.Repeat("b", 64) |
| 340 | case "signature": |
| 341 | key = make(ed25519.PublicKey, ed25519.PublicKeySize) |
| 342 | case "missing declaration": |
| 343 | store = release.NewDirStore(t.TempDir()) |
| 344 | } |
| 345 | calls := 0 |
| 346 | owner := lossRecoveryOwnerFunc(func(c Claim, loss LossRecord, a Admission) error { |
| 347 | calls++ |
| 348 | if name == "owner denial" { |
| 349 | return denied |
| 350 | } |
| 351 | return (testLossRecoveryOwner{}).ValidateLossRecovery(c, loss, a) |
| 352 | }) |
| 353 | retry, err := AdmitWriterWithLoss(ctx, store, key, observation, candidate, record.ID, evidence, owner) |
| 354 | if name == "identical" { |
| 355 | if err != nil || retry.ETag != admitted.ETag || !reflect.DeepEqual(retry.Claim, admitted.Claim) || calls != 1 { |
| 356 | t.Errorf("identical retry: err %v, owner calls %d, observation %+v", err, calls, retry) |
| 357 | } |
| 358 | } else if err == nil { |
| 359 | t.Errorf("invalid retry accepted") |
| 360 | } else if name == "owner denial" && !errors.Is(err, denied) { |
| 361 | t.Errorf("owner denial = %v", err) |
| 362 | } |
| 363 | fresh, err := Read(ctx, s, admitted.Host) |
| 364 | if err != nil || fresh.ETag != admitted.ETag { |
| 365 | t.Fatalf("retry changed claim: %v", err) |
| 366 | } |
| 367 | }) |
| 368 | } |
| 369 | }) |
| 370 | } |
| 371 | |
| 372 | func TestLossAdmissionRetryInheritsPredecessorRegion(t *testing.T) { |
| 373 | for _, region := range []string{"ie", "uk"} { |
| 374 | for _, fallback := range []string{"", "fr"} { |
| 375 | t.Run(region+"/fallback="+fallback, func(t *testing.T) { |
| 376 | ctx := t.Context() |
| 377 | s := release.NewDirStore(t.TempDir()) |
| 378 | pub, priv, err := ed25519.GenerateKey(nil) |
| 379 | if err != nil { |
| 380 | t.Fatal(err) |
| 381 | } |
| 382 | obs := servingClaim(t, ctx, s) |
| 383 | obs = updateGenerationTest(t, ctx, s, obs, AdmitWriter(Admission{ |
| 384 | Region: "ie", Box: obs.Claim.Op.Exec, Mode: "cold", NewGeneration: "g-target", PredecessorCertified: true, |
| 385 | })) |
| 386 | obs = updateGenerationTest(t, ctx, s, obs, func(c *Claim) error { |
| 387 | c.Op = Op{Kind: KindReverting, ID: "return", Phase: "recover", Exec: "recovery", Participants: []string{"recovery"}} |
| 388 | c.LossRefs = []string{"loss_1"} |
| 389 | return nil |
| 390 | }) |
| 391 | h := obs.Claim.Holder |
| 392 | record := validLossRecord(t) |
| 393 | record.Host, record.HolderIncarnation = obs.Host, h.Incarnation |
| 394 | record.HolderStamp, record.HolderSession = h.Stamp, h.Session |
| 395 | record.OpID, record.OpKind, record.OpPhase, record.Box = "return", KindReverting, "recover", h.WriterBox |
| 396 | record.WriteSources = []WriteSource{{Reason: "target", Box: h.WriterBox, Generation: h.Generation, Session: h.Session, Region: "ie", Baseline: "target-snapshot"}} |
| 397 | created, err := CreateLossRecord(ctx, s, release.RootSigner(priv), record) |
| 398 | if err != nil { |
| 399 | t.Fatal(err) |
| 400 | } |
| 401 | committed := created.Record |
| 402 | committed.State, committed.ResultClaimGen = "committed", obs.Claim.Gen |
| 403 | if _, err := ReplaceLossRecord(ctx, s, release.RootSigner(priv), created, committed); err != nil { |
| 404 | t.Fatal(err) |
| 405 | } |
| 406 | in := Admission{ObservedOpID: "return", ObservedPhase: "recover", ObservedSession: h.Session, ObservedGeneration: h.Generation, |
| 407 | Box: "recovery", Mode: "cold", NewGeneration: "g-recovery", Region: region, PredecessorRegion: fallback, RemoteMaxTXID: 12} |
| 408 | validated := []ValidatedWriteSource{{Reason: "target", Box: h.WriterBox, Generation: h.Generation, Session: h.Session, Region: "ie", Baseline: "target-snapshot", RemoteMaxTXID: 12}} |
| 409 | admitted, err := AdmitWriterWithLoss(ctx, s, pub, obs, in, record.ID, validated, testLossRecoveryOwner{}) |
| 410 | if err != nil { |
| 411 | t.Fatal(err) |
| 412 | } |
| 413 | last := admitted.Claim.Holder.Admissions[len(admitted.Claim.Holder.Admissions)-1] |
| 414 | if last.PredecessorRegion != "ie" || last.Region != region { |
| 415 | t.Fatalf("admission regions = %q / %q, want %q / %q", last.Region, last.PredecessorRegion, region, "ie") |
| 416 | } |
| 417 | calls := 0 |
| 418 | owner := lossRecoveryOwnerFunc(func(c Claim, loss LossRecord, a Admission) error { |
| 419 | calls++ |
| 420 | return (testLossRecoveryOwner{}).ValidateLossRecovery(c, loss, a) |
| 421 | }) |
| 422 | retry, err := AdmitWriterWithLoss(ctx, s, pub, admitted, in, record.ID, validated, owner) |
| 423 | if err != nil || retry.ETag != admitted.ETag || !reflect.DeepEqual(retry.Claim, admitted.Claim) || calls != 1 { |
| 424 | t.Errorf("identical retry: err %v, owner calls %d, observation %+v", err, calls, retry) |
| 425 | } |
| 426 | assertAdmissionStored(t, ctx, s, admitted) |
| 427 | }) |
| 428 | } |
| 429 | } |
| 430 | } |
| 431 | |
| 432 | func TestTargetOnlyLossAdmissionRequiresColdUncoveredRecovery(t *testing.T) { |
| 433 | ctx := context.Background() |
| 434 | s := release.NewDirStore(t.TempDir()) |
| 435 | pub, priv, err := ed25519.GenerateKey(nil) |
| 436 | if err != nil { |
| 437 | t.Fatal(err) |
| 438 | } |
| 439 | obs := servingClaim(t, ctx, s) |
| 440 | obs, err = Update(ctx, s, obs, func(c *Claim) error { |
| 441 | c.Op = Op{Kind: KindReverting, ID: "return", Phase: "recover", Exec: "recovery", Participants: []string{"recovery"}} |
| 442 | c.Holder.WriterBox, c.Holder.Generation, c.Holder.Session = "target", "g-target", 4 |
| 443 | c.LossRefs = []string{"loss_1"} |
| 444 | return nil |
| 445 | }) |
| 446 | if err != nil { |
| 447 | t.Fatal(err) |
| 448 | } |
| 449 | record := validLossRecord(t) |
| 450 | record.Host, record.HolderIncarnation = obs.Host, obs.Claim.Holder.Incarnation |
| 451 | record.HolderStamp, record.HolderSession = obs.Claim.Holder.Stamp, 4 |
| 452 | record.OpID, record.OpKind, record.OpPhase, record.Box = "return", KindReverting, "recover", "target" |
| 453 | record.WriteSources = []WriteSource{{Reason: "target", Box: "target", Generation: "g-target", Session: 4, Region: "ie", Baseline: "target-snapshot"}} |
| 454 | created, err := CreateLossRecord(ctx, s, release.RootSigner(priv), record) |
| 455 | if err != nil { |
| 456 | t.Fatal(err) |
| 457 | } |
| 458 | committed := created.Record |
| 459 | committed.State, committed.ResultClaimGen = "committed", obs.Claim.Gen |
| 460 | if _, err := ReplaceLossRecord(ctx, s, release.RootSigner(priv), created, committed); err != nil { |
| 461 | t.Fatal(err) |
| 462 | } |
| 463 | base := Admission{ObservedOpID: "return", ObservedPhase: "recover", ObservedSession: 4, ObservedGeneration: "g-target", Box: "recovery", Mode: "cold", NewGeneration: "g-recovery", Region: "ie", PredecessorRegion: "ie", RemoteMaxTXID: 12} |
| 464 | validated := []ValidatedWriteSource{{Reason: "target", Box: "target", Generation: "g-target", Session: 4, Region: "ie", Baseline: "target-snapshot", RemoteMaxTXID: 12}} |
| 465 | for name, mutate := range map[string]func(*Admission){ |
| 466 | "warm": func(a *Admission) { a.Mode, a.NewGeneration = "warm", "" }, |
| 467 | "certified": func(a *Admission) { a.PredecessorCertified = true }, |
| 468 | "sealed": func(a *Admission) { a.Coverage = "sealed" }, |
| 469 | } { |
| 470 | t.Run(name, func(t *testing.T) { |
| 471 | in := base |
| 472 | mutate(&in) |
| 473 | if _, err := AdmitWriterWithLoss(ctx, s, pub, obs, in, "loss_1", validated, testLossRecoveryOwner{}); !errors.Is(err, ErrLossRecord) { |
| 474 | t.Fatalf("unsafe target-loss mode = %v", err) |
| 475 | } |
| 476 | }) |
| 477 | } |
| 478 | fresh, err := Read(ctx, s, obs.Host) |
| 479 | if err != nil || fresh.Claim.Gen != obs.Claim.Gen { |
| 480 | t.Fatalf("refusals mutated claim: gen %d, err %v", fresh.Claim.Gen, err) |
| 481 | } |
| 482 | } |
| 483 | |
| 484 | func TestLossDeclarationHandoverAdmission(t *testing.T) { |
| 485 | ctx := context.Background() |
| 486 | s := release.NewDirStore(t.TempDir()) |
| 487 | pub, priv, err := ed25519.GenerateKey(nil) |
| 488 | if err != nil { |
| 489 | t.Fatal(err) |
| 490 | } |
| 491 | sg := release.RootSigner(priv) |
| 492 | obs := servingClaim(t, ctx, s) |
| 493 | obs, err = Update(ctx, s, obs, func(c *Claim) error { |
| 494 | c.Op = Op{Kind: KindLanding, ID: "move", Phase: "probe", Exec: "target", Participants: []string{"target", "recovery"}} |
| 495 | c.Holder.WriterBox, c.Holder.Generation, c.Holder.Session = "target", "g-target", 4 |
| 496 | return nil |
| 497 | }) |
| 498 | if err != nil { |
| 499 | t.Fatal(err) |
| 500 | } |
| 501 | record := validLossRecord(t) |
| 502 | record.Host, record.HolderIncarnation = obs.Host, obs.Claim.Holder.Incarnation |
| 503 | record.HolderStamp, record.HolderSession = obs.Claim.Holder.Stamp, obs.Claim.Holder.Session |
| 504 | record.ExpectedClaimGen, record.ExpectedClaimETag = obs.Claim.Gen, obs.ETag |
| 505 | record.OpID, record.OpKind, record.OpPhase, record.Box = "move", KindLanding, "probe", "target" |
| 506 | record.WriteSources = []WriteSource{ |
| 507 | {Reason: "target", Box: "target", Generation: "g-target", Session: 4, Region: "ie", Baseline: "target-snapshot"}, |
| 508 | {Reason: "source-cut", Box: "source", Generation: "g-source", Session: 2, Region: "ie", Baseline: "source-seal"}, |
| 509 | } |
| 510 | prepared, err := CreateLossRecord(ctx, s, sg, record) |
| 511 | if err != nil { |
| 512 | t.Fatal(err) |
| 513 | } |
| 514 | declared, err := DeclareLost(ctx, s, pub, obs, record.ID, testLossOwner{}) |
| 515 | if err != nil { |
| 516 | t.Fatal(err) |
| 517 | } |
| 518 | if declared.Claim.Op.Kind != KindLanding || declared.Claim.Op.Phase != "probe" || declared.Claim.Op.Exec != "none" || |
| 519 | declared.Claim.Holder.Stamp != record.HolderStamp || declared.Claim.Holder.Session != record.HolderSession { |
| 520 | t.Fatalf("declaration changed recovery authority: %+v", declared.Claim) |
| 521 | } |
| 522 | committed := prepared.Record |
| 523 | committed.State, committed.ResultClaimGen = "committed", declared.Claim.Gen |
| 524 | if _, err := ReplaceLossRecord(ctx, s, sg, prepared, committed); err != nil { |
| 525 | t.Fatal(err) |
| 526 | } |
| 527 | // The operation owner hands execution to a new installation in the same |
| 528 | // operation, preserving the original declaration and writer provenance. |
| 529 | recoveryStamp := declared.Claim.Gen + 1 |
| 530 | handover, err := Update(ctx, s, declared, func(c *Claim) error { |
| 531 | if c.Op.Kind != KindLanding || c.Op.Phase != "probe" || c.Op.Exec != "none" || !lossRefPresent(c, record.ID) { |
| 532 | return ErrWrongState |
| 533 | } |
| 534 | c.Op.Kind, c.Op.Phase, c.Op.Exec = KindRestoring, "restore", "recovery" |
| 535 | c.Op.Journal["recovery.loss"] = record.ID |
| 536 | c.Holder.Stamp = recoveryStamp |
| 537 | return nil |
| 538 | }) |
| 539 | if err != nil { |
| 540 | t.Fatal(err) |
| 541 | } |
| 542 | source := WritesLost{Box: "source", Generation: "g-source", Session: 2, RemoteMaxTXID: 10} |
| 543 | in := Admission{ObservedOpID: "move", ObservedPhase: "restore", ObservedSession: 4, ObservedGeneration: "g-target", Box: "recovery", Mode: "cold", NewGeneration: "g-recovery", Region: "ie", PredecessorRegion: "ie", RemoteMaxTXID: 12, AdditionalWritesLost: []WritesLost{source}} |
| 544 | validated := []ValidatedWriteSource{ |
| 545 | {Reason: "target", Box: "target", Generation: "g-target", Session: 4, Region: "ie", Baseline: "target-snapshot", RemoteMaxTXID: 12}, |
| 546 | {Reason: "source-cut", Box: "source", Generation: "g-source", Session: 2, Region: "ie", Baseline: "source-seal", RemoteMaxTXID: 10}, |
| 547 | } |
| 548 | owner := lossRecoveryOwnerFunc(func(c Claim, loss LossRecord, a Admission) error { |
| 549 | if loss.OpKind != KindLanding || loss.OpPhase != "probe" || c.Op.Kind != KindRestoring || c.Op.Phase != "restore" || |
| 550 | c.Op.Exec != "recovery" || a.Box != c.Op.Exec || c.Holder.Stamp != recoveryStamp || |
| 551 | c.Op.Journal["recovery.loss"] != loss.ID || c.Op.Journal["loss."+loss.ID] != `{"kind":"lost-target"}` { |
| 552 | return ErrWrongState |
| 553 | } |
| 554 | // A policy callback cannot rewrite any input used by the admission. |
| 555 | c.Holder.Incarnation = "changed" |
| 556 | c.Op.Journal["recovery.loss"] = "changed" |
| 557 | loss.WriteSources[0].Baseline = "changed" |
| 558 | a.AdditionalWritesLost[0].RemoteMaxTXID = 999 |
| 559 | return nil |
| 560 | }) |
| 561 | if _, err := AdmitWriterWithLoss(ctx, s, pub, declared, in, record.ID, validated, owner); !errors.Is(err, ErrWrongState) { |
| 562 | t.Fatalf("admitted before handover: %v", err) |
| 563 | } |
| 564 | if _, err := AdmitWriterWithLoss(ctx, s, pub, handover, in, record.ID, validated, nil); !errors.Is(err, ErrLossOwnerRequired) { |
| 565 | t.Fatalf("admitted without owner: %v", err) |
| 566 | } |
| 567 | for name, mutate := range map[string]func(*Claim){ |
| 568 | "kind": func(c *Claim) { c.Op.Kind = KindReverting }, |
| 569 | "phase": func(c *Claim) { c.Op.Phase = "unauthorized" }, |
| 570 | "executor": func(c *Claim) { c.Op.Exec = "other" }, |
| 571 | "stamp": func(c *Claim) { c.Holder.Stamp++ }, |
| 572 | "handover": func(c *Claim) { delete(c.Op.Journal, "recovery.loss") }, |
| 573 | "intent": func(c *Claim) { delete(c.Op.Journal, "loss."+record.ID) }, |
| 574 | "operation": func(c *Claim) { c.Op.ID = "other" }, |
| 575 | "incarnation": func(c *Claim) { c.Holder.Incarnation = "other" }, |
| 576 | "loss ref": func(c *Claim) { c.LossRefs = nil }, |
| 577 | } { |
| 578 | t.Run(name, func(t *testing.T) { |
| 579 | candidate := handover |
| 580 | candidate.Claim, err = cloneClaim(handover.Claim) |
| 581 | if err != nil { |
| 582 | t.Fatal(err) |
| 583 | } |
| 584 | mutate(candidate.Claim) |
| 585 | if _, err := AdmitWriterWithLoss(ctx, s, pub, candidate, in, record.ID, validated, owner); err == nil { |
| 586 | t.Fatal("admitted with changed binding") |
| 587 | } |
| 588 | }) |
| 589 | } |
| 590 | stale := in |
| 591 | stale.ObservedPhase = "probe" |
| 592 | if _, err := AdmitWriterWithLoss(ctx, s, pub, handover, stale, record.ID, validated, owner); !errors.Is(err, ErrWrongState) { |
| 593 | t.Fatalf("stale admission phase = %v", err) |
| 594 | } |
| 595 | fresh, err := Read(ctx, s, obs.Host) |
| 596 | if err != nil || fresh.ETag != handover.ETag { |
| 597 | t.Fatalf("refusal changed claim: %v", err) |
| 598 | } |
| 599 | admitted, err := AdmitWriterWithLoss(ctx, s, pub, handover, in, record.ID, validated, owner) |
| 600 | if err != nil { |
| 601 | t.Fatal(err) |
| 602 | } |
| 603 | last := admitted.Claim.Holder.Admissions[len(admitted.Claim.Holder.Admissions)-1] |
| 604 | if last.LossID != record.ID || last.WritesLost == nil || *last.WritesLost != (WritesLost{Box: "target", Generation: "g-target", Session: 4, RemoteMaxTXID: 12}) || |
| 605 | !reflect.DeepEqual(last.AdditionalWritesLost, []WritesLost{source}) || admitted.Claim.Holder.WriterBox != "recovery" || |
| 606 | admitted.Claim.Op.ID != record.OpID || admitted.Claim.Op.Kind != KindRestoring || admitted.Claim.Op.Phase != "restore" || |
| 607 | admitted.Claim.Holder.Incarnation != record.HolderIncarnation || admitted.Claim.Op.Journal["recovery.loss"] != record.ID { |
| 608 | t.Fatalf("recovery admission = %+v", admitted.Claim) |
| 609 | } |
| 610 | frozen, err := ReadLossRecord(ctx, s, pub, record.ID) |
| 611 | if err != nil || !reflect.DeepEqual(frozen.Record, committed) { |
| 612 | t.Fatalf("recovery rewrote frozen declaration: %+v, %v", frozen.Record, err) |
| 613 | } |
| 614 | } |
| 615 | |
| 616 | func TestLegacyLossWithoutDeclarationRetainsEvidence(t *testing.T) { |
| 617 | a := AdmissionRecord{Region: "ie", Session: 3, WritesLost: &WritesLost{Box: "legacy", Session: 0}} |
| 618 | if !validWriteLosses(a.WritesLost, nil) { |
| 619 | t.Fatal("legacy zero-session loss no longer parses") |
| 620 | } |
| 621 | c := &Claim{Holder: &Holder{Admissions: []AdmissionRecord{a}}} |
| 622 | if err := RequireLossEvidenceArchived(c); !errors.Is(err, ErrLossEvidencePending) { |
| 623 | t.Fatalf("unarchived legacy loss discarded: %v", err) |
| 624 | } |
| 625 | } |
| 626 | |