//go:build unix package hostguard import ( "context" "errors" "fmt" "os" "path/filepath" "reflect" "strings" "testing" "github.com/carlosframework/platform/internal/controlname" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/release" "golang.org/x/sys/unix" ) 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) } } func TestLifecycleRetrySyncsRecord(t *testing.T) { for _, retired := range []bool{false, true} { name := "activate" if retired { name = "retire" } t.Run(name, func(t *testing.T) { f := newGuardFixture(t) host := f.obs.Host operation := func() error { return f.g.Activate(t.Context(), host, "uk", nil) } wantStamp := uint64(1) if retired { if err := operation(); err != nil { t.Fatal(err) } operation = func() error { return f.g.RetireInstallation(t.Context(), host, allow) } wantStamp = 0 } failure := errors.New("injected fsync failure") failKind := "directory" var calls []string f.g.openRecords = func(path string) (*recordDir, error) { d, err := openRecordDir(path) if err != nil { return nil, err } d.fsync = func(fd int) error { var st unix.Stat_t if err := unix.Fstat(fd, &st); err != nil { return err } kind := "file" if st.Mode&unix.S_IFMT == unix.S_IFDIR { kind = "directory" } calls = append(calls, kind) if kind == failKind { return failure } return unix.Fsync(fd) } return d, nil } if err := operation(); !errors.Is(err, failure) { t.Fatalf("post-rename failure: %v", err) } if !reflect.DeepEqual(calls, []string{"file", "directory"}) { t.Fatalf("initial syncs: %v", calls) } captured, err := Capture(f.g.DataDir, host) if err != nil || captured.Stamp != wantStamp { t.Fatalf("rename did not publish: %+v %v", captured, err) } defer unchangedFile(t, filepath.Join(f.g.DataDir, "hostguard", host+".json"))() for _, kind := range []string{"file", "directory", ""} { failKind, calls = kind, nil err := operation() if kind == "" { if err != nil { t.Fatal(err) } } else if !errors.Is(err, failure) { t.Fatalf("retry acknowledged %s sync failure: %v", kind, err) } want := []string{"file", "directory"} if kind == "file" { want = []string{"file"} } if !reflect.DeepEqual(calls, want) { t.Fatalf("retry syncs: %v want %v", calls, want) } } }) } } func TestGuardHostnameLengthBoundary(t *testing.T) { for _, length := range []int{221, 222, 250, 251, 252, 253, 254} { t.Run(fmt.Sprint(length), func(t *testing.T) { host := strings.Repeat(strings.Repeat("a", 63)+".", 3) + strings.Repeat("b", length-192) if controlname.ValidHostShape(host) != (length <= 253) { t.Fatalf("unexpected hostname validation for %d bytes", length) } f := newGuardFixture(t) f.obs.Host, f.obs.Claim.Host = host, host if length > 250 { want := ErrHostTooLong if length > 253 { want = ErrInvalidHost } for name, operation := range map[string]func() error{ "capture": func() error { _, err := Capture(f.g.DataDir, host); return err }, "activate": func() error { return f.g.Activate(t.Context(), host, "uk", nil) }, "retire": func() error { return f.g.RetireInstallation(t.Context(), host, allow) }, "with": func() error { return f.g.With(t.Context(), host, Captured{Incarnation: "inc", Stamp: 1}, allow, func() error { t.Fatal("callback ran"); return nil }) }, } { if err := operation(); !errors.Is(err, want) { t.Errorf("%s: got %v, want %v", name, err, want) } } if f.reads != 0 { t.Fatalf("read claim before rejecting hostname: %d", f.reads) } for _, dir := range []string{f.g.LockDir, filepath.Join(f.g.DataDir, "hostguard")} { files, err := os.ReadDir(dir) if err != nil || len(files) != 0 { t.Fatalf("refusal created files: %v %v", files, err) } } return } if err := f.g.Activate(t.Context(), host, "uk", nil); err != nil { t.Fatal(err) } captured, err := Capture(f.g.DataDir, host) if err != nil || captured != (Captured{Incarnation: "inc", Stamp: 1}) { t.Fatal(captured, err) } if err := f.g.Activate(t.Context(), host, "uk", nil); err != nil { t.Fatal(err) } called := false if err := f.g.With(t.Context(), host, captured, allow, func() error { called = true; return nil }); err != nil || !called { t.Fatal(called, err) } for i := 0; i < 2; i++ { if err := f.g.RetireInstallation(t.Context(), host, allow); err != nil { t.Fatal(err) } } if got, err := Capture(f.g.DataDir, host); err != nil || got.Stamp != 0 { t.Fatal(got, err) } if err := f.g.With(t.Context(), host, captured, allow, func() error { t.Fatal("retired callback ran"); return nil }); !errors.Is(err, ErrInactive) { t.Fatal(err) } f.obs.Claim.Holder.Stamp = 2 if err := f.g.Activate(t.Context(), host, "uk", nil); err != nil { t.Fatal(err) } for _, path := range []string{filepath.Join(f.g.DataDir, "hostguard", host+".json"), filepath.Join(f.g.LockDir, host+".lock")} { if _, err := os.Stat(path); err != nil { t.Fatal(err) } } files, err := os.ReadDir(filepath.Join(f.g.DataDir, "hostguard")) if err != nil || len(files) != 1 { t.Fatalf("temporary files remain: %v %v", files, err) } }) } }