// Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: BUSL-1.1 package conformance import ( "context" "errors" "fmt" "math/rand" "testing" "time" "github.com/stretchr/testify/require" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/testing/protocmp" "github.com/hashicorp/consul/internal/storage" "github.com/hashicorp/consul/proto-public/pbresource" "github.com/hashicorp/consul/proto/private/prototest" "github.com/hashicorp/consul/sdk/testutil/retry" ) type TestOptions struct { // NewBackend will be called to construct a storage.Backend to run the tests // against. NewBackend func(t *testing.T) storage.Backend // SupportsStronglyConsistentList indicates whether the given storage backend // supports strongly consistent list operations. SupportsStronglyConsistentList bool // IgnoreWatchListSnapshotOperations indicates whether a given storage // backend is expected to be consistent enough with reads to emit // WatchEvent_Upsert after the initial sync. // // For instance, a replicated copy of the state store will have stale data // and may return an initial snapshot of nothing, and follow it up by an // upsert. IgnoreWatchListSnapshotOperations bool } // Test runs a suite of tests against a storage.Backend implementation to check // it correctly implements our required behaviours. func Test(t *testing.T, opts TestOptions) { require.NotNil(t, opts.NewBackend, "NewBackend method is required") t.Run("Read", func(t *testing.T) { testRead(t, opts) }) t.Run("CAS Write", func(t *testing.T) { testCASWrite(t, opts) }) t.Run("CAS Delete", func(t *testing.T) { testCASDelete(t, opts) }) t.Run("ListByOwner", func(t *testing.T) { testListByOwner(t, opts) }) testListWatch(t, opts) } func testRead(t *testing.T, opts TestOptions) { ctx := testContext(t) for consistency, check := range map[storage.ReadConsistency]consistencyChecker{ storage.EventualConsistency: eventually, storage.StrongConsistency: immediately, } { t.Run(consistency.String(), func(t *testing.T) { res := &pbresource.Resource{ Id: &pbresource.ID{ Type: typeAv1, Tenancy: tenancyDefault, Name: "web", Uid: "a", }, } t.Run("simple", func(t *testing.T) { backend := opts.NewBackend(t) _, err := backend.WriteCAS(ctx, res) require.NoError(t, err) check(t, func(t testingT) { output, err := backend.Read(ctx, consistency, res.Id) require.NoError(t, err) prototest.AssertDeepEqual(t, res, output, ignoreVersion) }) }) t.Run("no uid", func(t *testing.T) { backend := opts.NewBackend(t) _, err := backend.WriteCAS(ctx, res) require.NoError(t, err) id := clone(res.Id) id.Uid = "" check(t, func(t testingT) { output, err := backend.Read(ctx, consistency, id) require.NoError(t, err) prototest.AssertDeepEqual(t, res, output, ignoreVersion) }) }) t.Run("different id", func(t *testing.T) { backend := opts.NewBackend(t) _, err := backend.WriteCAS(ctx, res) require.NoError(t, err) id := clone(res.Id) id.Name = "different" check(t, func(t testingT) { _, err := backend.Read(ctx, consistency, id) require.ErrorIs(t, err, storage.ErrNotFound) }) }) t.Run("different uid", func(t *testing.T) { backend := opts.NewBackend(t) _, err := backend.WriteCAS(ctx, res) require.NoError(t, err) id := clone(res.Id) id.Uid = "b" check(t, func(t testingT) { _, err := backend.Read(ctx, consistency, id) require.ErrorIs(t, err, storage.ErrNotFound) }) }) t.Run("different GroupVersion", func(t *testing.T) { backend := opts.NewBackend(t) _, err := backend.WriteCAS(ctx, res) require.NoError(t, err) id := clone(res.Id) id.Type = typeAv2 check(t, func(t testingT) { _, err := backend.Read(ctx, consistency, id) require.Error(t, err) var e storage.GroupVersionMismatchError if errors.As(err, &e) { prototest.AssertDeepEqual(t, id.Type, e.RequestedType) prototest.AssertDeepEqual(t, res, e.Stored, ignoreVersion) } else { t.Fatalf("expected storage.GroupVersionMismatchError, got: %T", err) } }) }) }) } } func testCASWrite(t *testing.T, opts TestOptions) { t.Run("version-based CAS", func(t *testing.T) { backend := opts.NewBackend(t) ctx := testContext(t) v1 := &pbresource.Resource{ Id: &pbresource.ID{ Type: typeB, Tenancy: tenancyDefault, Name: "web", Uid: "a", }, } v1.Version = "some-version" _, err := backend.WriteCAS(ctx, v1) require.ErrorIs(t, err, storage.ErrCASFailure) v1.Version = "" v1, err = backend.WriteCAS(ctx, v1) require.NoError(t, err) require.NotEmpty(t, v1.Version) v2, err := backend.WriteCAS(ctx, v1) require.NoError(t, err) require.NotEmpty(t, v2.Version) require.NotEqual(t, v1.Version, v2.Version) v3 := clone(v2) v3.Version = "" _, err = backend.WriteCAS(ctx, v3) require.ErrorIs(t, err, storage.ErrCASFailure) v3.Version = v1.Version _, err = backend.WriteCAS(ctx, v3) require.ErrorIs(t, err, storage.ErrCASFailure) }) t.Run("uid immutability", func(t *testing.T) { backend := opts.NewBackend(t) ctx := testContext(t) v1, err := backend.WriteCAS(ctx, &pbresource.Resource{ Id: &pbresource.ID{ Type: typeB, Tenancy: tenancyDefault, Name: "web", Uid: "a", }, }) require.NoError(t, err) // Uid cannot change. v2 := clone(v1) v2.Id.Uid = "" _, err = backend.WriteCAS(ctx, v2) require.Error(t, err) v2.Id.Uid = "b" _, err = backend.WriteCAS(ctx, v2) require.ErrorIs(t, err, storage.ErrWrongUid) v2.Id.Uid = v1.Id.Uid v2, err = backend.WriteCAS(ctx, v2) require.NoError(t, err) // Uid can change after original resource is deleted. require.NoError(t, backend.DeleteCAS(ctx, v2.Id, v2.Version)) v3 := clone(v2) v3.Id.Uid = "b" v3.Version = "" _, err = backend.WriteCAS(ctx, v3) require.NoError(t, err) }) } func testCASDelete(t *testing.T, opts TestOptions) { t.Run("version-based CAS", func(t *testing.T) { backend := opts.NewBackend(t) ctx := testContext(t) res, err := backend.WriteCAS(ctx, &pbresource.Resource{ Id: &pbresource.ID{ Type: typeB, Tenancy: tenancyDefault, Name: "web", Uid: "a", }, }) require.NoError(t, err) require.ErrorIs(t, backend.DeleteCAS(ctx, res.Id, ""), storage.ErrCASFailure) require.ErrorIs(t, backend.DeleteCAS(ctx, res.Id, "some-version"), storage.ErrCASFailure) require.NoError(t, backend.DeleteCAS(ctx, res.Id, res.Version)) eventually(t, func(t testingT) { _, err = backend.Read(ctx, storage.EventualConsistency, res.Id) require.ErrorIs(t, err, storage.ErrNotFound) }) }) t.Run("uid must match", func(t *testing.T) { backend := opts.NewBackend(t) ctx := testContext(t) res, err := backend.WriteCAS(ctx, &pbresource.Resource{ Id: &pbresource.ID{ Type: typeB, Tenancy: tenancyDefault, Name: "web", Uid: "a", }, }) require.NoError(t, err) id := clone(res.Id) id.Uid = "b" require.NoError(t, backend.DeleteCAS(ctx, id, res.Version)) eventually(t, func(t testingT) { _, err = backend.Read(ctx, storage.EventualConsistency, res.Id) require.NoError(t, err) }) }) } func testListWatch(t *testing.T, opts TestOptions) { testCases := map[string]struct { resourceType storage.UnversionedType tenancy *pbresource.Tenancy namePrefix string results []*pbresource.Resource }{ "simple #1": { resourceType: storage.UnversionedTypeFrom(typeAv1), tenancy: tenancyDefault, namePrefix: "", results: []*pbresource.Resource{ seedData[0], seedData[1], seedData[2], }, }, "simple #2": { resourceType: storage.UnversionedTypeFrom(typeAv1), tenancy: tenancyOther, namePrefix: "", results: []*pbresource.Resource{ seedData[3], }, }, "fixed tenancy, name prefix": { resourceType: storage.UnversionedTypeFrom(typeAv1), tenancy: tenancyDefault, namePrefix: "a", results: []*pbresource.Resource{ seedData[0], seedData[1], }, }, "wildcard tenancy": { resourceType: storage.UnversionedTypeFrom(typeAv1), tenancy: &pbresource.Tenancy{ Partition: storage.Wildcard, Namespace: storage.Wildcard, }, namePrefix: "", results: []*pbresource.Resource{ seedData[0], seedData[1], seedData[2], seedData[3], seedData[5], }, }, "fixed partition, wildcard namespace": { resourceType: storage.UnversionedTypeFrom(typeAv1), tenancy: &pbresource.Tenancy{ Partition: "default", Namespace: storage.Wildcard, }, namePrefix: "", results: []*pbresource.Resource{ seedData[0], seedData[1], seedData[2], seedData[5], }, }, "wildcard partition, fixed namespace": { resourceType: storage.UnversionedTypeFrom(typeAv1), tenancy: &pbresource.Tenancy{ Partition: storage.Wildcard, Namespace: "default", }, namePrefix: "", results: []*pbresource.Resource{ seedData[0], seedData[1], seedData[2], }, }, "wildcard tenancy, name prefix": { resourceType: storage.UnversionedTypeFrom(typeAv1), tenancy: &pbresource.Tenancy{ Partition: storage.Wildcard, Namespace: storage.Wildcard, }, namePrefix: "a", results: []*pbresource.Resource{ seedData[0], seedData[1], seedData[3], seedData[5], }, }, // TODO(peering/v2) add tests for peer tenancy } t.Run("List", func(t *testing.T) { ctx := testContext(t) consistencyModes := map[storage.ReadConsistency]consistencyChecker{ storage.EventualConsistency: eventually, } if opts.SupportsStronglyConsistentList { consistencyModes[storage.StrongConsistency] = immediately } for consistency, check := range consistencyModes { t.Run(consistency.String(), func(t *testing.T) { for desc, tc := range testCases { t.Run(desc, func(t *testing.T) { backend := opts.NewBackend(t) for _, r := range seedData { _, err := backend.WriteCAS(ctx, r) require.NoError(t, err) } check(t, func(t testingT) { res, err := backend.List(ctx, consistency, tc.resourceType, tc.tenancy, tc.namePrefix) require.NoError(t, err) prototest.AssertElementsMatch(t, res, tc.results, ignoreVersion) }) }) } }) } }) t.Run("WatchList", func(t *testing.T) { for desc, tc := range testCases { t.Run(fmt.Sprintf("%s - initial snapshot", desc), func(t *testing.T) { backend := opts.NewBackend(t) ctx := testContext(t) // Write the seed data before the watch has been established. for _, r := range seedData { _, err := backend.WriteCAS(ctx, r) require.NoError(t, err) } watch, err := backend.WatchList(ctx, tc.resourceType, tc.tenancy, tc.namePrefix) require.NoError(t, err) t.Cleanup(watch.Close) expectNum := len(tc.results) + 1 for i := 0; i < expectNum; i++ { ctx, cancel := context.WithTimeout(ctx, 5*time.Second) t.Cleanup(cancel) event, err := watch.Next(ctx) require.NoError(t, err) if opts.IgnoreWatchListSnapshotOperations && event.GetEndOfSnapshot() != nil { continue // ignore } else if !opts.IgnoreWatchListSnapshotOperations && i == expectNum-1 { require.NotNil(t, event.GetEndOfSnapshot(), "expected EndOfSnapshot got %T", event.GetEvent()) continue } require.NotNil(t, event.GetUpsert(), "index=%d", i) prototest.AssertContainsElement(t, tc.results, event.GetUpsert().Resource, ignoreVersion) } }) t.Run(fmt.Sprintf("%s - following events", desc), func(t *testing.T) { backend := opts.NewBackend(t) ctx := testContext(t) watch, err := backend.WatchList(ctx, tc.resourceType, tc.tenancy, tc.namePrefix) require.NoError(t, err) t.Cleanup(watch.Close) { // read snapshot end ctx, cancel := context.WithTimeout(ctx, 5*time.Second) t.Cleanup(cancel) event, err := watch.Next(ctx) require.NoError(t, err) require.NotNil(t, event.GetEndOfSnapshot()) } // Write the seed data after the watch has been established. for _, r := range seedData { _, err := backend.WriteCAS(ctx, r) require.NoError(t, err) } for i := 0; i < len(tc.results); i++ { ctx, cancel := context.WithTimeout(ctx, 5*time.Second) t.Cleanup(cancel) event, err := watch.Next(ctx) require.NoError(t, err) require.NotNil(t, event.GetUpsert()) prototest.AssertContainsElement(t, tc.results, event.GetUpsert().Resource, ignoreVersion) // Check that Read implements "monotonic reads" with Watch. readRes, err := backend.Read(ctx, storage.EventualConsistency, event.GetUpsert().Resource.Id) require.NoError(t, err) prototest.AssertDeepEqual(t, event.GetUpsert().Resource, readRes) } // Delete a random resource to check we get an event. del, err := backend.Read(ctx, storage.EventualConsistency, tc.results[rand.Intn(len(tc.results))].Id) require.NoError(t, err) require.NoError(t, backend.DeleteCAS(ctx, del.Id, del.Version)) ctx, cancel := context.WithTimeout(ctx, 5*time.Second) t.Cleanup(cancel) event, err := watch.Next(ctx) require.NoError(t, err) require.NotNil(t, event.GetDelete()) prototest.AssertDeepEqual(t, del, event.GetDelete().Resource) // Check that Read implements "monotonic reads" with Watch. _, err = backend.Read(ctx, storage.EventualConsistency, del.Id) require.ErrorIs(t, err, storage.ErrNotFound) }) } }) } func testListByOwner(t *testing.T, opts TestOptions) { backend := opts.NewBackend(t) ctx := testContext(t) owner, err := backend.WriteCAS(ctx, &pbresource.Resource{ Id: &pbresource.ID{ Type: typeAv1, Tenancy: tenancyDefault, Name: "owner", Uid: "a", }, }) require.NoError(t, err) r1, err := backend.WriteCAS(ctx, &pbresource.Resource{ Id: &pbresource.ID{ Type: typeB, Tenancy: tenancyDefault, Name: "r1", Uid: "a", }, Owner: owner.Id, }) require.NoError(t, err) r2, err := backend.WriteCAS(ctx, &pbresource.Resource{ Id: &pbresource.ID{ Type: typeAv2, Tenancy: tenancyDefault, Name: "r2", Uid: "a", }, Owner: owner.Id, }) require.NoError(t, err) eventually(t, func(t testingT) { res, err := backend.ListByOwner(ctx, owner.Id) require.NoError(t, err) prototest.AssertElementsMatch(t, res, []*pbresource.Resource{r1, r2}) }) t.Run("references are anchored to a specific uid", func(t *testing.T) { id := clone(owner.Id) id.Uid = "different" eventually(t, func(t testingT) { res, err := backend.ListByOwner(ctx, id) require.NoError(t, err) require.Empty(t, res) }) }) t.Run("deleting the owner doesn't remove the references", func(t *testing.T) { require.NoError(t, backend.DeleteCAS(ctx, owner.Id, owner.Version)) eventually(t, func(t testingT) { res, err := backend.ListByOwner(ctx, owner.Id) require.NoError(t, err) prototest.AssertElementsMatch(t, res, []*pbresource.Resource{r1, r2}) }) }) t.Run("deleting the owned resource removes its reference", func(t *testing.T) { require.NoError(t, backend.DeleteCAS(ctx, r2.Id, r2.Version)) eventually(t, func(t testingT) { res, err := backend.ListByOwner(ctx, owner.Id) require.NoError(t, err) prototest.AssertElementsMatch(t, res, []*pbresource.Resource{r1}) }) }) } var ( typeAv1 = &pbresource.Type{ Group: "test", GroupVersion: "v1", Kind: "a", } typeAv2 = &pbresource.Type{ Group: "test", GroupVersion: "v2", Kind: "a", } typeB = &pbresource.Type{ Group: "test", GroupVersion: "v1", Kind: "b", } tenancyDefault = &pbresource.Tenancy{ Partition: "default", Namespace: "default", } tenancyDefaultOtherNamespace = &pbresource.Tenancy{ Partition: "default", Namespace: "other", } tenancyOther = &pbresource.Tenancy{ Partition: "billing", Namespace: "payments", } seedData = []*pbresource.Resource{ resource(typeAv1, tenancyDefault, "admin"), // 0 resource(typeAv1, tenancyDefault, "api"), // 1 resource(typeAv2, tenancyDefault, "web"), // 2 resource(typeAv1, tenancyOther, "api"), // 3 resource(typeB, tenancyDefault, "admin"), // 4 resource(typeAv1, tenancyDefaultOtherNamespace, "autoscaler"), // 5 } ignoreVersion = protocmp.IgnoreFields(&pbresource.Resource{}, "version") ) func resource(typ *pbresource.Type, ten *pbresource.Tenancy, name string) *pbresource.Resource { return &pbresource.Resource{ Id: &pbresource.ID{ Type: typ, Tenancy: ten, Name: name, Uid: "a", }, } } func testContext(t *testing.T) context.Context { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) return ctx } func clone[T proto.Message](v T) T { return proto.Clone(v).(T) } type testingT interface { require.TestingT prototest.TestingT } type consistencyChecker func(t *testing.T, fn func(testingT)) func eventually(t *testing.T, fn func(testingT)) { t.Helper() retry.Run(t, func(r *retry.R) { fn(r) }) } func immediately(t *testing.T, fn func(testingT)) { t.Helper() fn(t) }