carlos / platform Public

hostguard: fresh-claim admission, activation and installation retirement under a context-aware flock (claim Plan 3 Task 2)

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
codex agent, operated by paul@keymail.dev pushed by claude agent, operated by paul@keymail.dev 992dd4f4614875c769045e5f4e78063db68db091 parent 8975c30
4 files changed, +653 −0
  • internal/hostguard/flock_other.go +7 −0
  • internal/hostguard/flock_unix.go +77 −0
  • internal/hostguard/guard.go +225 −0
  • internal/hostguard/guard_test.go +344 −0
diff --git a/internal/hostguard/flock_other.go b/internal/hostguard/flock_other.go
new file mode 100644
index 000000000..8e0502b5a
--- /dev/null
+++ b/internal/hostguard/flock_other.go
@@ -0,0 +1,7 @@
+//go:build !unix
+
+package hostguard
+
+import "context"
+
+func acquire(context.Context, string, string) (func(), error) { return nil, ErrUnsupported }
diff --git a/internal/hostguard/flock_unix.go b/internal/hostguard/flock_unix.go
new file mode 100644
index 000000000..e425fe09c
--- /dev/null
+++ b/internal/hostguard/flock_unix.go
@@ -0,0 +1,77 @@
+//go:build unix
+
+package hostguard
+
+import (
+ "context"
+ "errors"
+ "os"
+ "time"
+
+ "github.com/carlosframework/platform/internal/controlname"
+ "golang.org/x/sys/unix"
+)
+
+func acquire(ctx context.Context, dir, host string) (func(), error) {
+ if ctx == nil {
+ return nil, ErrInvalidInput
+ }
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ if !controlname.ValidHostShape(host) {
+ return nil, ErrInvalidHost
+ }
+ dirFD, err := openDirectory(dir)
+ if err != nil {
+ return nil, err
+ }
+ defer unix.Close(dirFD)
+ flags := unix.O_RDWR | unix.O_CREAT | unix.O_NOFOLLOW | unix.O_CLOEXEC | unix.O_NONBLOCK
+ fd, err := unix.Openat(dirFD, host+".lock", flags|unix.O_EXCL, 0660)
+ created := err == nil
+ if errors.Is(err, unix.EEXIST) {
+ fd, err = unix.Openat(dirFD, host+".lock", flags, 0660)
+ }
+ if err != nil {
+ return nil, err
+ }
+ f := os.NewFile(uintptr(fd), host+".lock")
+ fail := func(err error) (func(), error) { f.Close(); return nil, err }
+ var st unix.Stat_t
+ if err := unix.Fstat(fd, &st); err != nil {
+ return fail(err)
+ }
+ if st.Mode&unix.S_IFMT != unix.S_IFREG {
+ return fail(ErrUnsafeFile)
+ }
+ if created || st.Mode&07777 != 0660 {
+ if err := unix.Fchmod(fd, 0660); err != nil {
+ return fail(err)
+ }
+ }
+ tick := time.NewTicker(10 * time.Millisecond)
+ defer tick.Stop()
+ for {
+ if err := ctx.Err(); err != nil {
+ return fail(err)
+ }
+ err := unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB)
+ if err == nil {
+ unlock := func() { _ = unix.Flock(int(f.Fd()), unix.LOCK_UN); _ = f.Close() }
+ if err := ctx.Err(); err != nil {
+ unlock()
+ return nil, err
+ }
+ return unlock, nil
+ }
+ if !errors.Is(err, unix.EWOULDBLOCK) && !errors.Is(err, unix.EAGAIN) && !errors.Is(err, unix.EINTR) {
+ return fail(err)
+ }
+ select {
+ case <-ctx.Done():
+ return fail(ctx.Err())
+ case <-tick.C:
+ }
+ }
+}
diff --git a/internal/hostguard/guard.go b/internal/hostguard/guard.go
new file mode 100644
index 000000000..5b4b356a1
--- /dev/null
+++ b/internal/hostguard/guard.go
@@ -0,0 +1,225 @@
+package hostguard
+
+import (
+ "context"
+ "errors"
+
+ "github.com/carlosframework/platform/internal/controlname"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+var (
+ ErrInvalidInput = errors.New("hostguard: invalid input")
+ ErrNoClaims = errors.New("hostguard: no claim reader")
+ ErrNoClaim = errors.New("hostguard: claim absent")
+ ErrMalformedClaim = errors.New("hostguard: malformed claim observation")
+ ErrNoHolder = errors.New("hostguard: claim has no holder")
+ ErrClaimIncarnation = errors.New("hostguard: captured incarnation differs from claim")
+ ErrRecordIncarnation = errors.New("hostguard: local incarnation differs")
+ ErrInactive = errors.New("hostguard: installation inactive")
+ ErrStampMismatch = errors.New("hostguard: captured stamp is not active")
+ ErrCheckRequired = errors.New("hostguard: identity check required")
+ ErrNotParticipant = errors.New("hostguard: box is not authorized to activate")
+ ErrRetiredStamp = errors.New("hostguard: installation stamp retired")
+ ErrOldStamp = errors.New("hostguard: installation stamp predates local history")
+)
+
+type ClaimReader interface {
+ Read(context.Context, string) (hostclaim.Observed, error)
+}
+
+type StoreReader struct{ Store release.Store }
+
+func (r StoreReader) Read(ctx context.Context, host string) (hostclaim.Observed, error) {
+ if r.Store == nil {
+ return hostclaim.Observed{}, ErrNoClaims
+ }
+ return hostclaim.Read(ctx, r.Store, host)
+}
+
+type Guard struct {
+ DataDir string
+ LockDir string
+ Claims ClaimReader
+}
+
+func (g Guard) locked(ctx context.Context, host string, fn func(*recordDir) error) error {
+ if !controlname.ValidHostShape(host) {
+ return ErrInvalidHost
+ }
+ if g.DataDir == "" || ctx == nil {
+ return ErrInvalidInput
+ }
+ dir := g.LockDir
+ if dir == "" {
+ dir = "/run/carlos/hostguard"
+ }
+ unlock, err := acquire(ctx, dir, host)
+ if err != nil {
+ return err
+ }
+ defer unlock()
+ d, err := openRecordDir(g.DataDir)
+ if err != nil {
+ return err
+ }
+ defer d.close()
+ return fn(d)
+}
+
+func (g Guard) fresh(ctx context.Context, host string) (*hostclaim.Claim, error) {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ if g.Claims == nil {
+ return nil, ErrNoClaims
+ }
+ obs, err := g.Claims.Read(ctx, host)
+ if err != nil {
+ return nil, err
+ }
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ if obs.Host != host {
+ return nil, ErrMalformedClaim
+ }
+ if obs.Absent {
+ return nil, ErrNoClaim
+ }
+ if obs.Claim == nil || obs.Claim.Host != host {
+ return nil, ErrMalformedClaim
+ }
+ if obs.Claim.Holder == nil {
+ return nil, ErrNoHolder
+ }
+ if obs.Claim.Holder.Incarnation == "" {
+ return nil, ErrMalformedClaim
+ }
+ return obs.Claim, nil
+}
+
+func (g Guard) With(ctx context.Context, host string, captured Captured, check func(*hostclaim.Claim) error, fn func() error) error {
+ return g.locked(ctx, host, func(d *recordDir) error {
+ c, err := g.fresh(ctx, host)
+ if err != nil {
+ return err
+ }
+ if c.Holder.Incarnation != captured.Incarnation {
+ return ErrClaimIncarnation
+ }
+ if check == nil {
+ return ErrCheckRequired
+ }
+ if err := check(c); err != nil {
+ return err
+ }
+ r, err := d.read(host)
+ if err != nil {
+ return err
+ }
+ if r.Incarnation != captured.Incarnation {
+ return ErrRecordIncarnation
+ }
+ if r.ActiveStamp == 0 {
+ return ErrInactive
+ }
+ if captured.Stamp != r.ActiveStamp {
+ return ErrStampMismatch
+ }
+ if fn == nil {
+ return ErrInvalidInput
+ }
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ return fn()
+ })
+}
+
+func (r *record) retire() {
+ if r.ActiveStamp == 0 {
+ return
+ }
+ r.RetiredStamps = append(r.RetiredStamps, r.ActiveStamp)
+ if len(r.RetiredStamps) > 32 {
+ r.RetiredStamps = r.RetiredStamps[len(r.RetiredStamps)-32:]
+ }
+ r.ActiveStamp = 0
+}
+
+func (g Guard) Activate(ctx context.Context, host, box string, participant func(*hostclaim.Claim) bool) error {
+ return g.locked(ctx, host, func(d *recordDir) error {
+ c, err := g.fresh(ctx, host)
+ if err != nil {
+ return err
+ }
+ if box == "" || (c.Op.Exec != box && (participant == nil || !participant(c))) {
+ return ErrNotParticipant
+ }
+ stamp := c.Holder.Stamp
+ if stamp == 0 {
+ return ErrMalformedClaim
+ }
+ r, err := d.read(host)
+ if err != nil && !errors.Is(err, ErrNoRecord) {
+ return err
+ }
+ if errors.Is(err, ErrNoRecord) || r.Incarnation != c.Holder.Incarnation {
+ r = record{V: 1, Host: host, Incarnation: c.Holder.Incarnation, RetiredStamps: []uint64{}}
+ } else {
+ for _, s := range r.RetiredStamps {
+ if stamp == s {
+ return ErrRetiredStamp
+ }
+ }
+ if stamp < r.ActiveStamp {
+ return ErrOldStamp
+ }
+ // Keep refusing trimmed stamps even after retirement sets active to zero.
+ if n := len(r.RetiredStamps); n != 0 && stamp < r.RetiredStamps[n-1] {
+ return ErrOldStamp
+ }
+ if stamp == r.ActiveStamp {
+ return nil
+ }
+ r.retire()
+ }
+ r.ActiveStamp = stamp
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ return d.write(r)
+ })
+}
+
+func (g Guard) RetireInstallation(ctx context.Context, host string, check func(*hostclaim.Claim) error) error {
+ return g.locked(ctx, host, func(d *recordDir) error {
+ c, err := g.fresh(ctx, host)
+ if err != nil {
+ return err
+ }
+ if check == nil {
+ return ErrCheckRequired
+ }
+ if err := check(c); err != nil {
+ return err
+ }
+ r, err := d.read(host)
+ if err != nil {
+ return err
+ }
+ if r.Incarnation != c.Holder.Incarnation {
+ return ErrRecordIncarnation
+ }
+ if r.ActiveStamp == 0 {
+ return nil
+ }
+ r.retire()
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ return d.write(r)
+ })
+}
diff --git a/internal/hostguard/guard_test.go b/internal/hostguard/guard_test.go
new file mode 100644
index 000000000..f6496618b
--- /dev/null
+++ b/internal/hostguard/guard_test.go
@@ -0,0 +1,344 @@
+//go:build unix
+
+package hostguard
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "reflect"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+type readerFunc func(context.Context, string) (hostclaim.Observed, error)
+
+func (f readerFunc) Read(ctx context.Context, host string) (hostclaim.Observed, error) {
+ return f(ctx, host)
+}
+
+type guardFixture struct {
+ g Guard
+ obs hostclaim.Observed
+ err error
+ reads int
+}
+
+func newGuardFixture(t *testing.T) *guardFixture {
+ t.Helper()
+ f := &guardFixture{obs: hostclaim.Observed{
+ Host: "shop.example", ETag: "one",
+ Claim: &hostclaim.Claim{V: 2, Host: "shop.example", Gen: 1,
+ Holder: &hostclaim.Holder{Incarnation: "inc", Stamp: 1},
+ Op: hostclaim.Op{Kind: hostclaim.KindServing, Exec: "uk"}},
+ }}
+ f.g = Guard{DataDir: recordFixture(t), LockDir: t.TempDir(), Claims: readerFunc(func(ctx context.Context, host string) (hostclaim.Observed, error) {
+ f.reads++
+ return f.obs, f.err
+ })}
+ return f
+}
+
+func allow(*hostclaim.Claim) error { return nil }
+
+func TestWithRefusals(t *testing.T) {
+ failure := errors.New("read or identity failure")
+ for _, tc := range []struct {
+ name string
+ change func(*testing.T, *guardFixture, *Captured)
+ check func(*hostclaim.Claim) error
+ want error
+ }{
+ {"read", func(t *testing.T, f *guardFixture, c *Captured) { f.err = failure }, allow, failure},
+ {"absent", func(t *testing.T, f *guardFixture, c *Captured) { f.obs.Absent = true }, allow, ErrNoClaim},
+ {"nil-claim", func(t *testing.T, f *guardFixture, c *Captured) { f.obs.Claim = nil }, allow, ErrMalformedClaim},
+ {"wrong-observed-host", func(t *testing.T, f *guardFixture, c *Captured) { f.obs.Host = "else.example" }, allow, ErrMalformedClaim},
+ {"wrong-claim-host", func(t *testing.T, f *guardFixture, c *Captured) { f.obs.Claim.Host = "else.example" }, allow, ErrMalformedClaim},
+ {"nil-holder", func(t *testing.T, f *guardFixture, c *Captured) { f.obs.Claim.Holder = nil }, allow, ErrNoHolder},
+ {"claim-incarnation", func(t *testing.T, f *guardFixture, c *Captured) { f.obs.Claim.Holder.Incarnation = "other" }, allow, ErrClaimIncarnation},
+ {"check", func(t *testing.T, f *guardFixture, c *Captured) {}, func(*hostclaim.Claim) error { return failure }, failure},
+ {"nil-check", func(t *testing.T, f *guardFixture, c *Captured) {}, nil, ErrCheckRequired},
+ {"missing-record", func(t *testing.T, f *guardFixture, c *Captured) {
+ if err := os.Remove(filepath.Join(f.g.DataDir, "hostguard", f.obs.Host+".json")); err != nil {
+ t.Fatal(err)
+ }
+ }, allow, ErrNoRecord},
+ {"record-incarnation", func(t *testing.T, f *guardFixture, c *Captured) {
+ r := record{V: 1, Host: f.obs.Host, Incarnation: "other", ActiveStamp: 1}
+ if err := writeRecord(f.g.DataDir, r); err != nil {
+ t.Fatal(err)
+ }
+ }, allow, ErrRecordIncarnation},
+ {"inactive", func(t *testing.T, f *guardFixture, c *Captured) {
+ if err := f.g.RetireInstallation(t.Context(), f.obs.Host, allow); err != nil {
+ t.Fatal(err)
+ }
+ }, allow, ErrInactive},
+ {"lower", func(t *testing.T, f *guardFixture, c *Captured) { c.Stamp = 0 }, allow, ErrStampMismatch},
+ {"higher", func(t *testing.T, f *guardFixture, c *Captured) { c.Stamp = 2 }, allow, ErrStampMismatch},
+ {"retired", func(t *testing.T, f *guardFixture, c *Captured) {
+ f.obs.Claim.Holder.Stamp = 3
+ if err := f.g.Activate(t.Context(), f.obs.Host, "uk", nil); err != nil {
+ t.Fatal(err)
+ }
+ }, allow, ErrStampMismatch},
+ {"malformed-record", func(t *testing.T, f *guardFixture, c *Captured) {
+ if err := os.WriteFile(filepath.Join(f.g.DataDir, "hostguard", f.obs.Host+".json"), []byte(`{`), 0660); err != nil {
+ t.Fatal(err)
+ }
+ }, allow, ErrMalformedRecord},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ f := newGuardFixture(t)
+ if err := f.g.Activate(t.Context(), f.obs.Host, "uk", nil); err != nil {
+ t.Fatal(err)
+ }
+ captured, err := Capture(f.g.DataDir, f.obs.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ tc.change(t, f, &captured)
+ called := false
+ err = f.g.With(t.Context(), "shop.example", captured, tc.check, func() error { called = true; return nil })
+ if !errors.Is(err, tc.want) || called {
+ t.Fatalf("err=%v called=%v want=%v", err, called, tc.want)
+ }
+ })
+ }
+}
+
+func TestActivationAndRetirement(t *testing.T) {
+ f := newGuardFixture(t)
+ ctx, host := t.Context(), f.obs.Host
+ if err := f.g.Activate(ctx, host, "uk", nil); err != nil {
+ t.Fatal(err)
+ }
+ before, err := os.ReadFile(filepath.Join(f.g.DataDir, "hostguard", host+".json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := f.g.Activate(ctx, host, "uk", nil); err != nil {
+ t.Fatal(err)
+ }
+ after, err := os.ReadFile(filepath.Join(f.g.DataDir, "hostguard", host+".json"))
+ if err != nil || string(before) != string(after) {
+ t.Fatal("equal stamp changed record", err)
+ }
+ for s := uint64(2); s <= 40; s++ {
+ f.obs.Claim.Holder.Stamp = s
+ if err := f.g.Activate(ctx, host, "uk", nil); err != nil {
+ t.Fatal(err)
+ }
+ }
+ r, err := readRecord(f.g.DataDir, host)
+ if err != nil || r.ActiveStamp != 40 || len(r.RetiredStamps) != 32 || r.RetiredStamps[0] != 8 || r.RetiredStamps[31] != 39 {
+ t.Fatalf("%+v %v", r, err)
+ }
+ if err := f.g.RetireInstallation(ctx, host, allow); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.g.RetireInstallation(ctx, host, allow); err != nil {
+ t.Fatal(err)
+ }
+ for _, tc := range []struct {
+ stamp uint64
+ want error
+ }{{40, ErrRetiredStamp}, {1, ErrOldStamp}} {
+ f.obs.Claim.Holder.Stamp = tc.stamp
+ if err := f.g.Activate(ctx, host, "uk", nil); !errors.Is(err, tc.want) {
+ t.Fatal(err)
+ }
+ }
+ f.obs.Claim.Holder.Incarnation = "replacement"
+ f.obs.Claim.Holder.Stamp = 50
+ if err := f.g.Activate(ctx, host, "uk", nil); err != nil {
+ t.Fatal(err)
+ }
+ r, err = readRecord(f.g.DataDir, host)
+ if err != nil || r.Incarnation != "replacement" || r.ActiveStamp != 50 || len(r.RetiredStamps) != 0 {
+ t.Fatalf("%+v %v", r, err)
+ }
+ if err := os.Remove(filepath.Join(f.g.DataDir, "hostguard", host+".json")); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.g.Activate(ctx, host, "uk", nil); err != nil {
+ t.Fatal("lost record recovery", err)
+ }
+}
+
+func TestLifecycleRefusals(t *testing.T) {
+ failure := errors.New("unavailable")
+ for _, tc := range []struct {
+ name string
+ change func(*guardFixture)
+ want error
+ }{
+ {"read", func(f *guardFixture) { f.err = failure }, failure},
+ {"absent", func(f *guardFixture) { f.obs.Absent = true }, ErrNoClaim},
+ {"holder", func(f *guardFixture) { f.obs.Claim.Holder = nil }, ErrNoHolder},
+ {"reader", func(f *guardFixture) { f.g.Claims = nil }, ErrNoClaims},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ f := newGuardFixture(t)
+ tc.change(f)
+ if err := f.g.Activate(t.Context(), f.obs.Host, "uk", nil); !errors.Is(err, tc.want) {
+ t.Fatal(err)
+ }
+ if err := f.g.RetireInstallation(t.Context(), f.obs.Host, allow); !errors.Is(err, tc.want) {
+ t.Fatal(err)
+ }
+ })
+ }
+ f := newGuardFixture(t)
+ ctx, host := t.Context(), f.obs.Host
+ for _, box := range []string{"", "us"} {
+ if err := f.g.Activate(ctx, host, box, nil); !errors.Is(err, ErrNotParticipant) {
+ t.Fatal(err)
+ }
+ }
+ if err := f.g.Activate(ctx, host, "us", func(*hostclaim.Claim) bool { return false }); !errors.Is(err, ErrNotParticipant) {
+ t.Fatal(err)
+ }
+ f.obs.Claim.Op.Kind, f.obs.Claim.Op.Phase = hostclaim.KindMoving, "landing"
+ participant := func(c *hostclaim.Claim) bool { return c.Op.Kind == hostclaim.KindMoving && c.Op.Phase == "landing" }
+ if err := f.g.Activate(ctx, host, "us", participant); err != nil {
+ t.Fatal(err)
+ }
+ f.obs.Claim.Holder.Stamp = 0
+ if err := f.g.Activate(ctx, host, "uk", nil); !errors.Is(err, ErrMalformedClaim) {
+ t.Fatal(err)
+ }
+ f.obs.Claim.Holder.Stamp = 1
+ if err := f.g.RetireInstallation(ctx, host, nil); !errors.Is(err, ErrCheckRequired) {
+ t.Fatal(err)
+ }
+ if err := f.g.RetireInstallation(ctx, host, func(*hostclaim.Claim) error { return failure }); !errors.Is(err, failure) {
+ t.Fatal(err)
+ }
+ f.obs.Claim.Holder.Incarnation = "other"
+ if err := f.g.RetireInstallation(ctx, host, allow); !errors.Is(err, ErrRecordIncarnation) {
+ t.Fatal(err)
+ }
+ f.obs.Claim.Holder.Incarnation = "inc"
+ if err := os.Remove(filepath.Join(f.g.DataDir, "hostguard", host+".json")); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.g.RetireInstallation(ctx, host, allow); !errors.Is(err, ErrNoRecord) {
+ t.Fatal(err)
+ }
+}
+
+func TestLifecycleRecordReadFailures(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ directory bool
+ want error
+ }{
+ {"malformed", false, ErrMalformedRecord}, {"read-error", true, nil},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ f := newGuardFixture(t)
+ host := f.obs.Host
+ if err := f.g.Activate(t.Context(), host, "uk", nil); err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(f.g.DataDir, "hostguard", host+".json")
+ if tc.directory {
+ if err := os.Remove(path); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Mkdir(path, 0700); err != nil {
+ t.Fatal(err)
+ }
+ } else if err := os.WriteFile(path, []byte(`{`), 0660); err != nil {
+ t.Fatal(err)
+ }
+ for _, operation := range []func() error{
+ func() error { return f.g.Activate(t.Context(), host, "uk", nil) },
+ func() error { return f.g.RetireInstallation(t.Context(), host, allow) },
+ } {
+ err := operation()
+ if err == nil || errors.Is(err, ErrNoRecord) || (tc.want != nil && !errors.Is(err, tc.want)) {
+ t.Fatal("read failure acted", err)
+ }
+ }
+ })
+ }
+ f := newGuardFixture(t)
+ f.obs.Claim.Holder.Stamp = 3
+ if err := f.g.Activate(t.Context(), f.obs.Host, "uk", nil); err != nil {
+ t.Fatal(err)
+ }
+ f.obs.Claim.Holder.Stamp = 2
+ if err := f.g.Activate(t.Context(), f.obs.Host, "uk", nil); !errors.Is(err, ErrOldStamp) {
+ t.Fatal("lower than active stamp", err)
+ }
+ f.obs.Claim.Holder.Incarnation = ""
+ if err := f.g.Activate(t.Context(), f.obs.Host, "uk", nil); !errors.Is(err, ErrMalformedClaim) {
+ t.Fatal("empty incarnation", err)
+ }
+}
+
+func TestFenceStampRoundTrip(t *testing.T) {
+ f := newGuardFixture(t)
+ ctx, host := t.Context(), f.obs.Host
+ if err := f.g.Activate(ctx, host, "uk", nil); err != nil {
+ t.Fatal(err)
+ }
+ old, err := Capture(f.g.DataDir, host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.obs.Claim.Op = hostclaim.Op{Kind: hostclaim.KindMoving, ID: "away", Exec: "us"}
+ f.obs.Claim.Holder.Stamp = 2
+ if err := f.g.RetireInstallation(ctx, host, func(c *hostclaim.Claim) error {
+ if c.Op.ID != "away" || c.Op.Exec != "us" {
+ return errors.New("wrong move")
+ }
+ return nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+ f.obs.Claim.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: "uk"}
+ f.obs.Claim.Holder.Stamp = 3
+ if err := f.g.Activate(ctx, host, "uk", nil); err != nil {
+ t.Fatal(err)
+ }
+ writes := 0
+ write := func() error { writes++; return nil }
+ if err := f.g.With(ctx, host, old, allow, write); !errors.Is(err, ErrStampMismatch) {
+ t.Fatal(err)
+ }
+ current, err := Capture(f.g.DataDir, host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := f.g.With(ctx, host, current, allow, write); err != nil {
+ t.Fatal(err)
+ }
+ if writes != 1 {
+ t.Fatal(writes)
+ }
+ r, err := readRecord(f.g.DataDir, host)
+ if err != nil || !reflect.DeepEqual(r.RetiredStamps, []uint64{1}) {
+ t.Fatalf("%+v %v", r, err)
+ }
+}
+
+func TestStoreReaderUsesHostclaimRead(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ r := StoreReader{Store: s}
+ obs, err := r.Read(t.Context(), "shop.example")
+ if err != nil || !obs.Absent || obs.Host != "shop.example" {
+ t.Fatalf("%+v %v", obs, err)
+ }
+ if _, err := s.Put(t.Context(), "control/hosts/shop.example.json", []byte(`{"unknown":true}`), ""); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := r.Read(t.Context(), "shop.example"); !errors.Is(err, hostclaim.ErrMalformed) {
+ t.Fatal(err)
+ }
+}