package hostops import ( "context" "encoding/json" "reflect" "testing" "time" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/provision" ) func TestJ4DestroyedLocalOnlyAfterCommit(t *testing.T) { w := newLossWorld(t, "landing", func(c *hostclaim.Claim, r *provision.Record, _ *AppAdmission) { c.Op.Phase = hostclaim.PhaseCommitted r.Move.State = provision.MoveCommitted c.Obligations = []hostclaim.Obligation{ {ID: "local", Op: lossOp, Kind: "park-local", Box: "ie-box", Retirement: "ret-local", Owner: "box:ie-box"}, {ID: "remote", Op: lossOp, Kind: "park-replica", Region: "ie", Retirement: "ret-remote", Owner: "box:ie-box", Park: &hostclaim.ParkScope{Location: c.Holder.Location, Incarnation: c.Holder.Incarnation, EvidenceAt: r.CreatedAt, RequestLimit: 1}}, {ID: "other", Op: "older-op", Kind: "park-local", Box: "uk-box", Retirement: "ret-other", Owner: "box:uk-box"}, } }) before := w.claim(t) record, tag := w.record(t) in := w.inspect(t, "ie-label") if in.Details.Consequences.Transition != "destroyed-local-only" || len(in.Details.Consequences.DischargeLocal) != 1 || in.Details.Consequences.PossibleWriteLoss || in.Details.Consequences.PossibleTimerLoss { t.Fatalf("effects %+v", in.Details.Consequences) } sub := w.submission(in) sub.Observation.Region = "eu-west-1" sub.Observation.InstanceID = "i-ie" got, err := w.control.DeclareMoveLoss(context.Background(), w.signer, lossBucket, "operator", "bab", "app", lossHost, "ie-box", sub) if err != nil { t.Fatal(err) } if !reflect.DeepEqual(before.Claim.Op, got.Claim.Op) || !reflect.DeepEqual(before.Claim.Holder, got.Claim.Holder) || !reflect.DeepEqual(before.Claim.Inventory, got.Claim.Inventory) { t.Fatal("local discharge changed execution, sessions, holder or inventory") } if !reflect.DeepEqual(got.Claim.Obligations, before.Claim.Obligations[1:]) { t.Fatalf("other obligations changed: %+v", got.Claim.Obligations) } after, afterTag := w.record(t) if tag != afterTag || !reflect.DeepEqual(record, after) { t.Fatal("local discharge wrote committed record") } if _, err := w.control.DeclareMoveLoss(context.Background(), w.signer, lossBucket, "operator", "bab", "app", lossHost, "ie-box", sub); err != nil { t.Fatal("replay", err) } // A local-only signed record grants no second claim or record write. scoped := &lossScopedStore{raw: w.raw, pub: w.pub, now: func() time.Time { return w.clock }, b: lossBinding{lossID: in.Details.ID, host: lossHost, opID: lossOp, kind: hostclaim.KindLanding, box: "ie-box", claimETag: before.ETag, incarnation: w.inc, account: "bab", app: "app"}} for _, key := range []string{w.key, w.claimK} { b, tag, err := w.raw.Get(context.Background(), key) if err != nil { t.Fatal(err) } if _, err := scoped.Put(context.Background(), key, b, tag); err == nil { t.Fatal("local-only grant authorized follow-up write", key) } } } func TestJ4DestroyedLocalOnlyAlreadyDeclaredTarget(t *testing.T) { w := newLossWorld(t, "landing", func(c *hostclaim.Claim, _ *provision.Record, _ *AppAdmission) { c.Inventory = map[string]hostclaim.InventoryEntry{"uk-box": {Paths: map[string]uint64{"root": 21}}} }) if _, err := w.declare(w.submission(w.inspect(t, "uk-label"))); err != nil { t.Fatal(err) } before := w.claim(t) _, tag := w.record(t) var local hostclaim.Obligation for _, o := range before.Claim.Obligations { if o.Kind == "park-local" && o.Box == "uk-box" { local = o } } if local.ID == "" { t.Fatal("lost target installation has no park-local obligation") } in := w.inspect(t, "uk-label") sub := w.submission(in) got, err := w.declare(sub) if err != nil { t.Fatal(err) } if !reflect.DeepEqual(before.Claim.Op, got.Claim.Op) || !reflect.DeepEqual(before.Claim.Holder, got.Claim.Holder) { t.Fatal("second declaration changed data authority") } want := *before.Claim want.Obligations = append([]hostclaim.Obligation(nil), before.Claim.Obligations...) if err := hostclaim.CompleteObligation(local.Op, local.ID)(&want); err != nil { t.Fatal(err) } if !reflect.DeepEqual(want.Obligations, got.Claim.Obligations) { t.Fatal("regional or staging obligations changed") } _, afterTag := w.record(t) if tag != afterTag { t.Fatal("local-only changed rollback record") } loss, err := hostclaim.ReadLossRecord(context.Background(), w.raw, w.pub, in.Details.ID) if err != nil { t.Fatal(err) } if len(loss.Record.WriteSources) != 0 || loss.Record.ConfirmedConsequences.RevokeExecution || loss.Record.ConfirmedConsequences.RemoveParticipation { t.Fatal("local-only reopened data authority") } } func TestJ4DestroyedLocalOnlyKeepsOperationLossGate(t *testing.T) { w := newLossWorld(t, "landing", func(c *hostclaim.Claim, _ *provision.Record, _ *AppAdmission) { c.Inventory = map[string]hostclaim.InventoryEntry{"uk-box": {Paths: map[string]uint64{"root": 21}}} }) first := w.inspect(t, "uk-label") before, err := w.declare(w.submission(first)) if err != nil { t.Fatal(err) } if id, err := hostclaim.OperationLossID(before.Claim); err != nil || id != first.Details.ID { t.Fatalf("target loss gate = %q, %v", id, err) } local := w.inspect(t, "uk-label") sub := w.submission(local) for _, step := range []string{"discharge", "replay"} { got, err := w.declare(sub) if err != nil { t.Fatal(step, err) } if id, err := hostclaim.OperationLossID(got.Claim); err != nil || id != first.Details.ID { t.Fatalf("%s changed target loss gate: %q, %v", step, id, err) } if !reflect.DeepEqual(before.Claim.Lost, got.Claim.Lost) || !reflect.DeepEqual(before.Claim.Op, got.Claim.Op) { t.Fatal(step, "added loss or changed operation") } if _, exists := got.Claim.Op.Journal["loss."+local.Details.ID]; exists { t.Fatal("local-only declaration added recovery loss") } } } func TestJ4DestroyedLocalOnlyRequiresExactAttestation(t *testing.T) { for _, which := range []string{"missing", "wrong-retirement", "terminated-only", "stale-claim", "stale-record"} { t.Run(which, func(t *testing.T) { w := newLossWorld(t, "landing", func(c *hostclaim.Claim, r *provision.Record, _ *AppAdmission) { c.Op.Phase = hostclaim.PhaseCommitted r.Move.State = provision.MoveCommitted c.Obligations = []hostclaim.Obligation{{ID: "local", Op: lossOp, Kind: "park-local", Box: "ie-box", Owner: "box:ie-box", Retirement: "ret"}} }) in := w.inspect(t, "ie-label") sub := w.submission(in) sub.Observation.Region = "eu-west-1" sub.Observation.InstanceID = "i-ie" switch which { case "missing": sub.Attestation.Disks = nil case "wrong-retirement": sub.Attestation.Disks = []string{lossOp + "/local/other"} case "terminated-only": sub.Observation.State = "terminated" sub.Attestation.Disks = nil case "stale-claim": o := w.claim(t) o.Claim.Obligations[0].Retirement = "other" b, _ := json.Marshal(o.Claim) if _, err := w.raw.Store.Put(context.Background(), w.claimK, b, o.ETag); err != nil { t.Fatal(err) } case "stale-record": r, tag := w.record(t) r.RecordSeq++ b, _ := json.Marshal(r) if _, err := w.raw.Store.Put(context.Background(), w.key, b, tag); err != nil { t.Fatal(err) } } snap := lossSnapshot(t, w) if _, err := w.control.DeclareMoveLoss(context.Background(), w.signer, lossBucket, "operator", "bab", "app", lossHost, "ie-box", sub); err == nil { t.Fatal("unattested or stale discharge accepted") } if lossSnapshot(t, w) != snap { t.Fatal("refusal wrote state") } }) } } func TestJ4DestroyedLocalOnlyRejectsWidenedEffects(t *testing.T) { w := newLossWorld(t, "landing", func(c *hostclaim.Claim, r *provision.Record, _ *AppAdmission) { c.Op.Phase = hostclaim.PhaseCommitted r.Move.State = provision.MoveCommitted c.Obligations = []hostclaim.Obligation{{ID: "local", Op: "discovery", Kind: "park-local", Box: "ie-box", Retirement: "ret-local", Owner: "box:ie-box"}} }) c := w.claim(t).Claim _, base, err := moveLocalLossConsequences(c, "ie-box") if err != nil { t.Fatal(err) } owner := &moveLossOwner{} for name, change := range map[string]func(*hostclaim.LossConsequences){ "execution": func(e *hostclaim.LossConsequences) { e.RevokeExecution = true }, "participation": func(e *hostclaim.LossConsequences) { e.RemoveParticipation = true }, "writes": func(e *hostclaim.LossConsequences) { e.WritesPotentiallyLost = true }, "timers": func(e *hostclaim.LossConsequences) { e.TimersPotentiallyLost = true }, "regional": func(e *hostclaim.LossConsequences) { e.ReassignRegional = e.DischargeLocal }, "original-op": func(e *hostclaim.LossConsequences) { e.DischargeLocal[0].Op = "other" }, "retirement": func(e *hostclaim.LossConsequences) { e.DischargeLocal[0].Retirement = "other" }, "no-attestation": func(e *hostclaim.LossConsequences) { e.DestroyedStorage = nil }, } { t.Run(name, func(t *testing.T) { b, _ := json.Marshal(base) var effects hostclaim.LossConsequences json.Unmarshal(b, &effects) change(&effects) if err := owner.ValidateDeclareLost(*c, hostclaim.LostDeclaration{Box: "ie-box", Consequences: effects}); err == nil { t.Fatal("widened local-only consequences accepted") } }) } }