package hostops import ( "context" "encoding/json" "errors" "fmt" "os" "reflect" "testing" "time" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/release" ) type authority struct { err error // selects is region HA's create-time choice; false is today's create. // selected is the box it chooses (box-a when empty); executor is the // box completion offers (box-a when empty); selections counts calls. selects bool selected, executor string selections int } func (a *authority) SelectExecutor(context.Context, hostclaim.Observed, Record) (hostclaim.ExecutorSelection, bool, error) { a.selections++ if !a.selects { return hostclaim.ExecutorSelection{}, false, a.err } box := a.selected if box == "" { box = "box-a" } return hostclaim.ExecutorSelection{Box: box, Enrollment: "enrollment-" + box[len("box-"):]}, true, a.err } func (a *authority) Create(context.Context, hostclaim.Observed, Record, string) error { return a.err } func (a *authority) Executor(context.Context, hostclaim.Observed, Record) (string, error) { if a.executor != "" { return a.executor, a.err } return "box-a", a.err } func (a *authority) StartDelete(context.Context, hostclaim.Observed, Record) error { return a.err } func (a *authority) FirstPhase(context.Context, hostclaim.Observed) error { return a.err } func (a *authority) Inventory(context.Context, hostclaim.Observed) ([]string, []string, error) { return []string{"ie", "us"}, []string{"box-a", "box-b"}, a.err } func (a *authority) Adoption(context.Context, Record) (string, string, []string, []string, error) { return "ie", "box-a", []string{"us"}, []string{"box-b"}, a.err } func (a *authority) PurgeRetirement(context.Context, hostclaim.Observed) error { return a.err } type faultStore struct { release.Store failGet string afterList func(string, []string) error beforePut func(string) error afterGet func(string) afterPut func(string) beforeDelete func(string, string) error bareDeletes int } func (s *faultStore) List(ctx context.Context, prefix string) ([]string, error) { keys, err := s.Store.List(ctx, prefix) if err == nil && s.afterList != nil { err = s.afterList(prefix, keys) } return keys, err } func (s *faultStore) Get(ctx context.Context, key string) ([]byte, string, error) { if key == s.failGet { return nil, "", errors.New("read failed") } b, tag, err := s.Store.Get(ctx, key) if err == nil && s.afterGet != nil { s.afterGet(key) } return b, tag, err } func (s *faultStore) Put(ctx context.Context, key string, b []byte, e string) (string, error) { if s.beforePut != nil { if err := s.beforePut(key); err != nil { return "", err } } tag, err := s.Store.Put(ctx, key, b, e) if err == nil && s.afterPut != nil { s.afterPut(key) } return tag, err } func (s *faultStore) Delete(context.Context, string) error { s.bareDeletes++ return errors.New("bare delete forbidden") } func (s *faultStore) DeleteIfMatch(ctx context.Context, key, e string) error { if s.beforeDelete != nil { if err := s.beforeDelete(key, e); err != nil { return err } } return release.DeleteIfMatch(ctx, s.Store, key, e) } type fixture struct { ctx context.Context store *faultStore c Controller auth *authority now time.Time serial int } func newFixture(t *testing.T) *fixture { t.Helper() f := &fixture{ctx: context.Background(), now: time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC), auth: &authority{}} f.store = &faultStore{Store: release.NewDirStore(t.TempDir())} f.c = Controller{Store: f.store, Authority: f.auth, Now: func() time.Time { return f.now }, NewID: func() string { f.serial++; return fmt.Sprintf("id-%d", f.serial) }} return f } func (f *fixture) record(t *testing.T, host string) Record { t.Helper() r, err := PrepareInstance(provision.Record{Schema: 1, Account: "bab", App: "jam", Host: host, Slug: "shop", Channel: "edge", Region: "ie"}, f.now, "ie") if err != nil { t.Fatal(err) } return r } func (f *fixture) serving(t *testing.T, host string) hostclaim.Observed { t.Helper() r := f.record(t, host) before, err := hostclaim.Read(f.ctx, f.store, host) if err != nil { t.Fatal(err) } obs, err := f.c.Attempt(f.ctx, before, r, f.c.NewID(), "test") if err != nil { t.Fatal(err) } return obs } func TestAttemptUsesPreRegistrationObservation(t *testing.T) { f := newFixture(t) for _, host := range []string{"one.bab.example.com", "two.bab.example.com"} { before, err := hostclaim.Read(f.ctx, f.store, host) if err != nil { t.Fatal(err) } fenced, err := hostclaim.Update(f.ctx, f.store, before, FencePurge("purge", f.now)) if err != nil { t.Fatal(err) } if _, err = hostclaim.Update(f.ctx, f.store, fenced, ReleasePurgeFence("purge")); err != nil { t.Fatal(err) } r := f.record(t, host) if _, err = f.c.Attempt(f.ctx, before, r, "paused", "test"); !errors.Is(err, hostclaim.ErrConflict) { t.Fatalf("late claim: %v", err) } if _, _, err = f.store.Get(f.ctx, r.Holder.Key); !errors.Is(err, os.ErrNotExist) { t.Fatalf("record landed: %v", err) } } if f.store.bareDeletes != 0 { t.Fatal("deleted claim") } } // TestCreateIsTodaysWithoutASelection pins dormancy (region HA ยง8 step 2): // with no create-time choice the claim carries no selected executor and // completion takes the box Executor names. With one, Attempt itself // records the selection in the create CAS (round 9 finding 2): selection // chooses box-b while completion offers box-a, so the completion refuses, // the persisted intent names box-b and its enrollment, and a completion // with box-b serves without selecting again. func TestCreateIsTodaysWithoutASelection(t *testing.T) { f := newFixture(t) obs := f.serving(t, "off.bab.example.com") if obs.Claim.Op.Exec != "box-a" || obs.Claim.Op.Intent["selected_executor"] != "" || obs.Claim.Op.Intent["executor_enrollment"] != "" { t.Fatalf("HA off: %+v", obs.Claim.Op) } f.auth.selects, f.auth.selected, f.auth.selections = true, "box-b", 0 const host = "on.bab.example.com" before, err := hostclaim.Read(f.ctx, f.store, host) if err != nil { t.Fatal(err) } id := f.c.NewID() if _, err := f.c.Attempt(f.ctx, before, f.record(t, host), id, "test"); !errors.Is(err, hostclaim.ErrWrongState) { t.Fatalf("completion substituted box-a for the selected box-b: %v", err) } pending, err := hostclaim.Read(f.ctx, f.store, host) if err != nil { t.Fatal(err) } if pending.Claim.Op.Kind != hostclaim.KindCreating || pending.Claim.Op.Intent["selected_executor"] != "box-b" || pending.Claim.Op.Intent["executor_enrollment"] != "enrollment-b" { t.Fatalf("HA on, the persisted intent: %+v", pending.Claim.Op) } f.auth.executor = "box-b" done, err := f.c.Complete(f.ctx, host, id) if err != nil || done.Claim.Op.Kind != hostclaim.KindServing || done.Claim.Op.Exec != "box-b" { t.Fatalf("completion with the selected box: %+v %v", done.Claim, err) } if f.auth.selections != 1 { t.Fatalf("selection ran %d times, want once", f.auth.selections) } } func TestRecoverCreateAbsentAndLateOrphan(t *testing.T) { f := newFixture(t) host := "shop.bab.example.com" r := f.record(t, host) before, _ := hostclaim.Read(f.ctx, f.store, host) if _, err := hostclaim.Update(f.ctx, f.store, before, hostclaim.BeginCreate(r.Holder, r.Body, "create", "test", f.now)); err != nil { t.Fatal(err) } f.now = f.now.Add(5 * time.Minute) if err := f.c.RecoverCreate(f.ctx, host); !errors.Is(err, ErrPending) { t.Fatal(err) } f.now = f.now.Add(time.Nanosecond) if err := f.c.RecoverCreate(f.ctx, host); err != nil { t.Fatal(err) } obs, _ := hostclaim.Read(f.ctx, f.store, host) if !obs.Claim.Free() || len(obs.Claim.CancelledCreates) != 1 || len(obs.Claim.RetiredIncarnations) != 1 { t.Fatalf("cancel: %+v", obs.Claim) } f.now = f.now.Add(2 * time.Hour) if _, err := f.store.Put(f.ctx, r.Holder.Key, r.Body, ""); err != nil { t.Fatal(err) } late, err := ReadRecord(f.ctx, f.store, r.Holder.Key) if err != nil { t.Fatal(err) } removed, err := RemoveOrphan(f.ctx, f.store, late) if err != nil || !removed { t.Fatalf("late orphan: %t %v", removed, err) } } func TestCreateRecoveryReadErrorsNeverCancel(t *testing.T) { f := newFixture(t) r := f.record(t, "shop.bab.example.com") before, _ := hostclaim.Read(f.ctx, f.store, r.Holder.Host) obs, err := hostclaim.Update(f.ctx, f.store, before, hostclaim.BeginCreate(r.Holder, r.Body, "create", "test", f.now)) if err != nil { t.Fatal(err) } f.now = f.now.Add(6 * time.Minute) f.store.failGet = r.Holder.Key if err := f.c.RecoverCreate(f.ctx, r.Holder.Host); err == nil { t.Fatal("read error accepted") } got, _ := hostclaim.Read(f.ctx, f.store, r.Holder.Host) if got.ETag != obs.ETag { t.Fatal("acted after read error") } f.store.failGet = "" if _, err := f.store.Put(f.ctx, r.Holder.Key, r.Body, ""); err != nil { t.Fatal(err) } if err := f.c.RecoverCreate(f.ctx, r.Holder.Host); err != nil { t.Fatal(err) } got, _ = hostclaim.Read(f.ctx, f.store, r.Holder.Host) if got.Claim.Op.Kind != hostclaim.KindServing || got.Claim.Holder.Stamp != got.Claim.Gen { t.Fatal("not completed") } } func TestRetireWaitsForExactRecordAndAuthority(t *testing.T) { f := newFixture(t) obs := f.serving(t, "shop.bab.example.com") key := obs.Claim.Holder.Key deleting, err := f.c.BeginRetire(f.ctx, key, "test", obs.Claim.Holder.Incarnation) if err != nil { t.Fatal(err) } f.auth.err = ErrPending if err := f.c.Retire(f.ctx, obs.Host, deleting.Claim.Op.ID); !errors.Is(err, ErrPending) { t.Fatal(err) } if _, _, err := f.store.Get(f.ctx, key); err != nil { t.Fatal("record deleted before barrier") } f.auth.err = nil f.store.beforeDelete = func(k, e string) error { return release.ErrCASConflict } if err := f.c.Retire(f.ctx, obs.Host, deleting.Claim.Op.ID); !errors.Is(err, release.ErrCASConflict) { t.Fatal(err) } current, _ := hostclaim.Read(f.ctx, f.store, obs.Host) if current.Claim.Op.Kind != hostclaim.KindDeleting { t.Fatal("parked after 412") } f.store.beforeDelete = nil if err := f.c.Retire(f.ctx, obs.Host, deleting.Claim.Op.ID); !errors.Is(err, ErrPending) { t.Fatal(err) } current, _ = hostclaim.Read(f.ctx, f.store, obs.Host) if current.Claim.Op.Kind != hostclaim.KindParking || len(current.Claim.Obligations) != 2 { t.Fatalf("parking: %+v", current.Claim) } if err := f.c.Free(f.ctx, obs.Host, deleting.Claim.Op.ID); err == nil { t.Fatal("freed with chores") } for _, ob := range current.Claim.Obligations { var err error current, err = hostclaim.Update(f.ctx, f.store, current, hostclaim.CompleteObligation(ob.Op, ob.ID)) if err != nil { t.Fatal(err) } } if err := f.c.Free(f.ctx, obs.Host, deleting.Claim.Op.ID); err != nil { t.Fatal(err) } if f.store.bareDeletes != 0 { t.Fatal("bare delete") } } func TestAdoptionIsNotOrphanAndHasDiscovery(t *testing.T) { f := newFixture(t) r := f.record(t, "shop.bab.example.com") if _, err := f.store.Put(f.ctx, r.Holder.Key, r.Body, ""); err != nil { t.Fatal(err) } read, err := ReadRecord(f.ctx, f.store, r.Holder.Key) if err != nil { t.Fatal(err) } if removed, err := RemoveOrphan(f.ctx, f.store, read); err != nil || removed { t.Fatal("legacy treated as orphan") } obs, err := f.c.Adopt(f.ctx, read) if err != nil { t.Fatal(err) } if obs.Claim.Touched.Complete || len(obs.Claim.Obligations) != 2 || obs.Claim.Holder.Location != "instances/"+r.Holder.Host || obs.Claim.Holder.WriterBox != "box-a" { t.Fatalf("adopt: %+v", obs.Claim) } if _, err := f.c.BeginRetire(f.ctx, r.Holder.Key, "test", obs.Claim.Holder.Incarnation); err == nil { t.Fatal("delete overlapped discovery") } } func TestPurgeJournalPhasesAndFenceRetention(t *testing.T) { f := newFixture(t) p, _, err := ReservePurge(f.ctx, f.store, "bab", "jam", "purge") if err != nil || p.Phase != "reserved" { t.Fatal(err) } if _, _, err := ReservePurge(f.ctx, f.store, "bab", "jam", "other"); !errors.Is(err, ErrPending) { t.Fatal(err) } if err := RemovePurge(f.ctx, f.store, "bab", "jam", "purge"); !errors.Is(err, ErrPending) { t.Fatal(err) } if err := AdvancePurge(f.ctx, f.store, "bab", "jam", "purge", "reserved", "done"); err == nil { t.Fatal("skipped phases") } for i := 0; i < len(phases)-1; i++ { if err := AdvancePurge(f.ctx, f.store, "bab", "jam", "purge", phases[i], phases[i+1]); err != nil { t.Fatal(err) } } if err := RemovePurge(f.ctx, f.store, "bab", "jam", "purge"); err != nil { t.Fatal(err) } } func TestPurgeRetiresUnderOwnOperation(t *testing.T) { f := newFixture(t) obs := f.serving(t, "shop.bab.example.com") rec, err := ReadRecord(f.ctx, f.store, obs.Claim.Holder.Key) if err != nil { t.Fatal(err) } obs, err = hostclaim.Update(f.ctx, f.store, obs, BeginPurge("purge", "test", f.now, []string{"ie"}, []string{"box-a"}, "")) if err != nil { t.Fatal(err) } obs, err = hostclaim.Update(f.ctx, f.store, obs, PurgeRecordIntent("purge", rec.Holder.Key, rec.ETag)) if err != nil { t.Fatal(err) } f.auth.err = ErrPending if err := f.c.Retire(f.ctx, obs.Host, "purge"); !errors.Is(err, ErrPending) { t.Fatal(err) } f.auth.err = nil if err := f.c.Retire(f.ctx, obs.Host, "purge"); !errors.Is(err, ErrPending) { t.Fatal(err) } got, _ := hostclaim.Read(f.ctx, f.store, obs.Host) if got.Claim.Op.Kind != hostclaim.KindParking || got.Claim.Op.ID != "purge" { t.Fatal("purge identity lost") } } func TestPrepareRejectsCallerTimeAndSidecarIdentity(t *testing.T) { f := newFixture(t) if _, err := PrepareInstance(provision.Record{CreatedAt: f.now}, f.now, "ie"); err == nil { t.Fatal("accepted caller time") } r, err := PrepareSidecar(provision.SidecarRecord{Account: "bab", App: "jam", Name: "job", Host: provision.SidecarHost("job", "jam", "bab"), Region: "ie"}, f.now, "ie") if err != nil { t.Fatal(err) } before, _ := hostclaim.Read(f.ctx, f.store, r.Holder.Host) obs, err := f.c.Attempt(f.ctx, before, r, "sidecar", "test") if err != nil { t.Fatal(err) } if obs.Claim.Holder.Family != "sidecar" { t.Fatal("sidecar became instance") } } func TestRecoveryFreshReadsAndRequiredHooks(t *testing.T) { f := newFixture(t) if err := (Recovery{Controller: f.c}).Pass(f.ctx); !errors.Is(err, ErrContract) { t.Fatal(err) } for _, tc := range []struct { name string replace bool wantCalls int }{ {name: "advanced purge", replace: true, wantCalls: 1}, {name: "released purge", wantCalls: 0}, } { t.Run(tc.name, func(t *testing.T) { f := newFixture(t) host := "shop.bab.example.com" before, err := hostclaim.Read(f.ctx, f.store, host) if err != nil { t.Fatal(err) } before, err = hostclaim.Update(f.ctx, f.store, before, FencePurge("purge-old", f.now)) if err != nil { t.Fatal(err) } startedAt := f.now f.now = f.now.Add(6 * time.Minute) key, err := hostclaim.Key(host) if err != nil { t.Fatal(err) } var fresh hostclaim.Observed listCalls := 0 f.store.afterList = func(prefix string, keys []string) error { listCalls++ if prefix != "control/hosts/" || len(keys) != 1 || keys[0] != key { t.Fatalf("discovery = %q %v, want the populated claim", prefix, keys) } var err error fresh, err = hostclaim.Update(f.ctx, f.store.Store, before, ReleasePurgeFence("purge-old")) if err == nil && tc.replace { fresh, err = hostclaim.Update(f.ctx, f.store.Store, fresh, FencePurge("purge-new", startedAt)) } return err } purgeCalls, recordSweeps, purgeSweeps, admissionSweeps := 0, 0, 0, 0 r := Recovery{ Controller: f.c, Purge: func(_ context.Context, got hostclaim.Observed) error { purgeCalls++ if !tc.replace { t.Fatal("dispatched a purge released after discovery") } if !reflect.DeepEqual(got, fresh) { t.Fatalf("dispatched stale claim: gen=%d op=%q etag=%q; want gen=%d op=%q etag=%q", got.Claim.Gen, got.Claim.Op.ID, got.ETag, fresh.Claim.Gen, fresh.Claim.Op.ID, fresh.ETag) } return nil }, Placement: func(context.Context, hostclaim.Observed) error { t.Fatal("dispatched placement for a purge claim") return nil }, SweepRecords: func(context.Context) error { recordSweeps++; return nil }, SweepPurges: func(context.Context) error { purgeSweeps++; return nil }, SweepAdmissions: func(context.Context) error { admissionSweeps++; return nil }, } if err := r.Pass(f.ctx); err != nil { t.Fatal(err) } if listCalls != 1 || fresh.Claim.Gen <= before.Claim.Gen || fresh.ETag == before.ETag { t.Fatal("claim did not advance between discovery and recovery") } if purgeCalls != tc.wantCalls { t.Fatalf("purge calls = %d, want %d", purgeCalls, tc.wantCalls) } if recordSweeps != 1 || purgeSweeps != 1 || admissionSweeps != 1 { t.Fatalf("sweeps: records=%d purges=%d admissions=%d; want one each", recordSweeps, purgeSweeps, admissionSweeps) } }) } } func TestAppsPreserveLegacyPrefixesAndFindClaimlessPurge(t *testing.T) { f := newFixture(t) for key, body := range map[string][]byte{ release.LegacyChannelKey("my-app", "stable"): []byte("legacy channel"), release.LegacyChannelKey("myapp", "stable"): []byte("legacy channel"), release.LegacyManifestKey("myapp", "v1"): []byte("legacy manifest"), release.LegacyArtifactKey("myapp", "v1", "app"): []byte("legacy artifact"), } { if _, err := f.store.Put(f.ctx, key, body, ""); err != nil { t.Fatal(err) } } if _, _, err := ReservePurge(f.ctx, f.store, "bab", "doomed", "purge"); err != nil { t.Fatal(err) } hosts, err := ListClaims(f.ctx, f.store) if err != nil || len(hosts) != 0 { t.Fatalf("claims = %v, %v; want none", hosts, err) } if err := f.c.SweepAllRecords(f.ctx); err != nil { t.Fatalf("record sweep with legacy prefixes: %v", err) } var replayed []App err = SweepAllPurges(f.ctx, f.store, func(_ context.Context, app App, p Purge) error { if p.ID != "purge" { t.Fatalf("purge ID = %q", p.ID) } replayed = append(replayed, app) return nil }) if err != nil { t.Fatal(err) } if len(replayed) != 1 || replayed[0] != (App{Account: "bab", Name: "doomed"}) { t.Fatalf("replayed = %+v", replayed) } } func TestOrphanConditionalDeleteCannotEraseReplacement(t *testing.T) { f := newFixture(t) r := f.record(t, "shop.bab.example.com") if _, err := f.store.Put(f.ctx, r.Holder.Key, r.Body, ""); err != nil { t.Fatal(err) } old, err := ReadRecord(f.ctx, f.store, r.Holder.Key) if err != nil { t.Fatal(err) } absent, _ := hostclaim.Read(f.ctx, f.store, r.Holder.Host) if _, err := hostclaim.Update(f.ctx, f.store, absent, func(*hostclaim.Claim) error { return nil }); err != nil { t.Fatal(err) } replacement := append([]byte(nil), r.Body...) replacement = append(replacement, ' ') f.store.beforeDelete = func(key, etag string) error { _, err := f.store.Store.Put(f.ctx, key, replacement, etag) return err } if removed, err := RemoveOrphan(f.ctx, f.store, old); removed || !errors.Is(err, release.ErrCASConflict) { t.Fatalf("removed=%t err=%v", removed, err) } if _, _, err := f.store.Get(f.ctx, r.Holder.Key); err != nil { t.Fatal("replacement lost") } } func TestPlacementStartKeepsInstallationAndExcludesDelete(t *testing.T) { f := newFixture(t) obs := f.serving(t, "shop.bab.example.com") stamp := obs.Claim.Holder.Stamp placed, err := hostclaim.Update(f.ctx, f.store, obs, BeginPlacement("place", "test", "fleet/customer", f.now, []string{"ie", "us"}, []string{"box-a", "box-b"})) if err != nil { t.Fatal(err) } if placed.Claim.Op.Exec != obs.Claim.Op.Exec || placed.Claim.Holder.Stamp != stamp || placed.Claim.Op.Intent["target"] != "fleet/customer" { t.Fatal("placement changed authority") } rec, err := ReadRecord(f.ctx, f.store, obs.Claim.Holder.Key) if err != nil { t.Fatal(err) } if _, err := hostclaim.Update(f.ctx, f.store, placed, hostclaim.BeginDelete("delete", "test", rec.ETag, f.now, nil, nil, "")); err == nil { t.Fatal("delete overlapped placement") } } func TestFenceReleasePreservesHistoryAndRefusesAnotherPurge(t *testing.T) { f := newFixture(t) before, _ := hostclaim.Read(f.ctx, f.store, "shop.bab.example.com") obs, err := hostclaim.Update(f.ctx, f.store, before, func(c *hostclaim.Claim) error { c.Lost = []string{"lost-box"} c.QuietAfter = map[string]time.Time{"ie": f.now.Add(time.Hour)} return FencePurge("purge", f.now)(c) }) if err != nil { t.Fatal(err) } if _, err := hostclaim.Update(f.ctx, f.store, obs, ReleasePurgeFence("other")); err == nil { t.Fatal("released other's fence") } obs, err = hostclaim.Update(f.ctx, f.store, obs, ReleasePurgeFence("purge")) if err != nil { t.Fatal(err) } if !obs.Claim.Free() || len(obs.Claim.Lost) != 1 || obs.Claim.QuietAfter["ie"].IsZero() { t.Fatal("lost history") } } type repairAuthority struct { Authority afterAdoption func() } func (a repairAuthority) Adoption(ctx context.Context, r Record) (string, string, []string, []string, error) { region, box, regions, boxes, err := a.Authority.Adoption(ctx, r) if a.afterAdoption != nil { a.afterAdoption() } return region, box, regions, boxes, err } func TestAdoptLegacyRepairRequiresCensusIdentityAndFreshRecord(t *testing.T) { for _, scenario := range []string{"wrong-executor", "not-serving", "owner-not-touched", "changed-record", "missing-record", "record-read-error"} { t.Run(scenario, func(t *testing.T) { f := newFixture(t) before := f.serving(t, "legacy.example.com") var err error before, err = hostclaim.Update(f.ctx, f.store, before, func(c *hostclaim.Claim) error { switch scenario { case "wrong-executor": c.Op.Exec = "box-b" c.Touched.Boxes = append(c.Touched.Boxes, "box-b") case "not-serving": c.Op.Kind, c.Op.ID = hostclaim.KindMoving, "move" case "owner-not-touched": c.Touched.Boxes = []string{"box-b"} } return nil }) if err != nil { t.Fatal(err) } r, err := ReadRecord(f.ctx, f.store, before.Claim.Holder.Key) if err != nil { t.Fatal(err) } var body provision.Record if err := json.Unmarshal(r.Body, &body); err != nil { t.Fatal(err) } body.Acquisition = "ownership-acquisition" raw, err := json.Marshal(body) if err != nil { t.Fatal(err) } if _, err := f.store.Put(f.ctx, r.Holder.Key, raw, r.ETag); err != nil { t.Fatal(err) } r, err = ReadRecord(f.ctx, f.store, r.Holder.Key) if err != nil { t.Fatal(err) } want := ErrIdentity if scenario == "changed-record" { want = ErrPending } else if scenario == "missing-record" { want = os.ErrNotExist } f.c.Authority = repairAuthority{Authority: f.auth, afterAdoption: func() { switch scenario { case "changed-record": if _, err := f.store.Put(f.ctx, r.Holder.Key, append(raw, ' '), r.ETag); err != nil { t.Fatal(err) } case "missing-record": if err := f.store.DeleteIfMatch(f.ctx, r.Holder.Key, r.ETag); err != nil { t.Fatal(err) } case "record-read-error": f.store.failGet = r.Holder.Key } }} writes := 0 f.store.beforePut = func(string) error { writes++; return nil } _, err = f.c.Adopt(f.ctx, r) if scenario == "record-read-error" { if err == nil || err.Error() != "read failed" { t.Errorf("repair error = %v, want record read failure", err) } } else if !errors.Is(err, want) { t.Errorf("repair error = %v, want %v", err, want) } if scenario == "changed-record" { writes-- } after, readErr := hostclaim.Read(f.ctx, f.store, before.Host) if readErr != nil || writes != 0 || after.ETag != before.ETag || !reflect.DeepEqual(after.Claim, before.Claim) { t.Fatalf("refused repair changed claim: writes=%d, read=%v", writes, readErr) } }) } } func TestCompleteMetadataPlacementPreservesExecution(t *testing.T) { claim := hostclaim.Claim{Holder: &hostclaim.Holder{Incarnation: "old", Stamp: 42}, Gen: 50, Op: hostclaim.Op{Kind: hostclaim.KindPlacing, ID: "place", Exec: "box", Participants: []string{"box"}, Intent: map[string]string{"app_id": "app"}}, Obligations: []hostclaim.Obligation{{ID: "keep", Op: "old", Kind: "park-local", Box: "other", Owner: "box:other"}}} if err := CompleteMetadataPlacement("wrong", "app")(&claim); err == nil { t.Fatal("wrong operation accepted") } if err := CompleteMetadataPlacement("place", "wrong")(&claim); err == nil { t.Fatal("wrong app accepted") } if err := CompleteMetadataPlacement("place", "app")(&claim); err != nil { t.Fatal(err) } if claim.Op.Kind != hostclaim.KindServing || claim.Op.Exec != "box" || claim.Holder.Stamp != 42 || len(claim.Obligations) != 1 || claim.Gen != 50 { t.Fatalf("authority changed: %+v", claim) } }