| 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) |
| + } |
| +} |