From 13ce787a3f3f12a9908956b94183bfe5273c97ba Mon Sep 17 00:00:00 2001 From: "R.B. Boyer" <4903+rboyer@users.noreply.github.com> Date: Tue, 1 Aug 2023 13:39:15 -0500 Subject: [PATCH] resource: adding various helpers for working with resources (#18342) This is a bit of a grab bag of helpers that I found useful for working with them when authoring substantial Controllers. Subsequent PRs will make use of them. --- internal/resource/decode.go | 38 +++ internal/resource/decode_test.go | 66 ++++ internal/resource/equality.go | 16 + internal/resource/equality_test.go | 304 ++++++++++++++++++ .../resource/mappers/bimapper/bimapper.go | 212 ++++++++++++ .../mappers/bimapper/bimapper_test.go | 169 ++++++++++ internal/resource/reference.go | 23 ++ internal/resource/refkey.go | 93 ++++++ internal/resource/refkey_test.go | 87 +++++ internal/resource/resourcetest/builder.go | 20 +- internal/resource/resourcetest/client.go | 33 +- internal/resource/resourcetest/decode.go | 23 ++ internal/resource/resourcetest/validation.go | 30 ++ internal/resource/stringer.go | 60 ++++ 14 files changed, 1166 insertions(+), 8 deletions(-) create mode 100644 internal/resource/decode.go create mode 100644 internal/resource/decode_test.go create mode 100644 internal/resource/mappers/bimapper/bimapper.go create mode 100644 internal/resource/mappers/bimapper/bimapper_test.go create mode 100644 internal/resource/refkey.go create mode 100644 internal/resource/refkey_test.go create mode 100644 internal/resource/resourcetest/decode.go create mode 100644 internal/resource/resourcetest/validation.go create mode 100644 internal/resource/stringer.go diff --git a/internal/resource/decode.go b/internal/resource/decode.go new file mode 100644 index 0000000000..b93b799c52 --- /dev/null +++ b/internal/resource/decode.go @@ -0,0 +1,38 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package resource + +import ( + "google.golang.org/protobuf/proto" + + "github.com/hashicorp/consul/proto-public/pbresource" +) + +// DecodedResource is a generic holder to contain an original Resource and its +// decoded contents. +type DecodedResource[V any, PV interface { + proto.Message + *V +}] struct { + Resource *pbresource.Resource + Data PV +} + +// Decode will generically decode the provided resource into a 2-field +// structure that holds onto the original Resource and the decoded contents. +// +// Returns an ErrDataParse on unmarshalling errors. +func Decode[V any, PV interface { + proto.Message + *V +}](res *pbresource.Resource) (*DecodedResource[V, PV], error) { + data := PV(new(V)) + if err := res.Data.UnmarshalTo(data); err != nil { + return nil, NewErrDataParse(data, err) + } + return &DecodedResource[V, PV]{ + Resource: res, + Data: data, + }, nil +} diff --git a/internal/resource/decode_test.go b/internal/resource/decode_test.go new file mode 100644 index 0000000000..10b61cfa14 --- /dev/null +++ b/internal/resource/decode_test.go @@ -0,0 +1,66 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package resource_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/anypb" + + "github.com/hashicorp/consul/internal/resource" + "github.com/hashicorp/consul/internal/resource/demo" + "github.com/hashicorp/consul/proto-public/pbresource" + pbdemo "github.com/hashicorp/consul/proto/private/pbdemo/v2" + "github.com/hashicorp/consul/proto/private/prototest" +) + +func TestDecode(t *testing.T) { + t.Run("good", func(t *testing.T) { + fooData := &pbdemo.Artist{ + Name: "caspar babypants", + } + any, err := anypb.New(fooData) + require.NoError(t, err) + + foo := &pbresource.Resource{ + Id: &pbresource.ID{ + Type: demo.TypeV2Artist, + Tenancy: demo.TenancyDefault, + Name: "babypants", + }, + Data: any, + Metadata: map[string]string{ + "generated_at": time.Now().Format(time.RFC3339), + }, + } + + dec, err := resource.Decode[pbdemo.Artist, *pbdemo.Artist](foo) + require.NoError(t, err) + + prototest.AssertDeepEqual(t, foo, dec.Resource) + prototest.AssertDeepEqual(t, fooData, dec.Data) + }) + + t.Run("bad", func(t *testing.T) { + foo := &pbresource.Resource{ + Id: &pbresource.ID{ + Type: demo.TypeV2Artist, + Tenancy: demo.TenancyDefault, + Name: "babypants", + }, + Data: &anypb.Any{ + TypeUrl: "garbage", + Value: []byte("more garbage"), + }, + Metadata: map[string]string{ + "generated_at": time.Now().Format(time.RFC3339), + }, + } + + _, err := resource.Decode[pbdemo.Artist, *pbdemo.Artist](foo) + require.Error(t, err) + }) +} diff --git a/internal/resource/equality.go b/internal/resource/equality.go index c7c880cddc..20c65e2fce 100644 --- a/internal/resource/equality.go +++ b/internal/resource/equality.go @@ -120,6 +120,22 @@ func EqualReference(a, b *pbresource.Reference) bool { a.Section == b.Section } +// ReferenceOrIDMatch compares two references or IDs to see if they both refer +// to the same thing. +// +// Note that this only compares fields that are common between them as +// represented by the ReferenceOrID interface and notably ignores the section +// field on references and the uid field on ids. +func ReferenceOrIDMatch(ref1, ref2 ReferenceOrID) bool { + if ref1 == nil || ref2 == nil { + return false + } + + return EqualType(ref1.GetType(), ref2.GetType()) && + EqualTenancy(ref1.GetTenancy(), ref2.GetTenancy()) && + ref1.GetName() == ref2.GetName() +} + // EqualStatusMap compares two status maps for equality without reflection. func EqualStatusMap(a, b map[string]*pbresource.Status) bool { if len(a) != len(b) { diff --git a/internal/resource/equality_test.go b/internal/resource/equality_test.go index 4fb7cb666b..7a0ec72f3c 100644 --- a/internal/resource/equality_test.go +++ b/internal/resource/equality_test.go @@ -283,6 +283,310 @@ func TestEqualID(t *testing.T) { }) } +func TestEqualReference(t *testing.T) { + t.Run("same pointer", func(t *testing.T) { + id := &pbresource.Reference{ + Type: &pbresource.Type{ + Group: "demo", + GroupVersion: "v2", + Kind: "artist", + }, + Tenancy: &pbresource.Tenancy{ + Partition: "foo", + PeerName: "bar", + Namespace: "baz", + }, + Name: "qux", + Section: "blah", + } + require.True(t, resource.EqualReference(id, id)) + }) + + t.Run("equal", func(t *testing.T) { + a := &pbresource.Reference{ + Type: &pbresource.Type{ + Group: "demo", + GroupVersion: "v2", + Kind: "artist", + }, + Tenancy: &pbresource.Tenancy{ + Partition: "foo", + PeerName: "bar", + Namespace: "baz", + }, + Name: "qux", + Section: "blah", + } + b := clone(a) + require.True(t, resource.EqualReference(a, b)) + }) + + t.Run("nil", func(t *testing.T) { + a := &pbresource.Reference{ + Type: &pbresource.Type{ + Group: "demo", + GroupVersion: "v2", + Kind: "artist", + }, + Tenancy: &pbresource.Tenancy{ + Partition: "foo", + PeerName: "bar", + Namespace: "baz", + }, + Name: "qux", + Section: "blah", + } + require.False(t, resource.EqualReference(a, nil)) + require.False(t, resource.EqualReference(nil, a)) + }) + + t.Run("different type", func(t *testing.T) { + a := &pbresource.Reference{ + Type: &pbresource.Type{ + Group: "demo", + GroupVersion: "v2", + Kind: "artist", + }, + Tenancy: &pbresource.Tenancy{ + Partition: "foo", + PeerName: "bar", + Namespace: "baz", + }, + Name: "qux", + Section: "blah", + } + b := clone(a) + b.Type.Kind = "album" + require.False(t, resource.EqualReference(a, b)) + }) + + t.Run("different tenancy", func(t *testing.T) { + a := &pbresource.Reference{ + Type: &pbresource.Type{ + Group: "demo", + GroupVersion: "v2", + Kind: "artist", + }, + Tenancy: &pbresource.Tenancy{ + Partition: "foo", + PeerName: "bar", + Namespace: "baz", + }, + Name: "qux", + Section: "blah", + } + b := clone(a) + b.Tenancy.Namespace = "qux" + require.False(t, resource.EqualReference(a, b)) + }) + + t.Run("different name", func(t *testing.T) { + a := &pbresource.Reference{ + Type: &pbresource.Type{ + Group: "demo", + GroupVersion: "v2", + Kind: "artist", + }, + Tenancy: &pbresource.Tenancy{ + Partition: "foo", + PeerName: "bar", + Namespace: "baz", + }, + Name: "qux", + Section: "blah", + } + b := clone(a) + b.Name = "boom" + require.False(t, resource.EqualReference(a, b)) + }) + + t.Run("different section", func(t *testing.T) { + a := &pbresource.Reference{ + Type: &pbresource.Type{ + Group: "demo", + GroupVersion: "v2", + Kind: "artist", + }, + Tenancy: &pbresource.Tenancy{ + Partition: "foo", + PeerName: "bar", + Namespace: "baz", + }, + Name: "qux", + Section: "blah", + } + b := clone(a) + b.Section = "not-blah" + require.False(t, resource.EqualReference(a, b)) + }) +} + +func TestReferenceOrIDMatch(t *testing.T) { + t.Run("equal", func(t *testing.T) { + a := &pbresource.Reference{ + Type: &pbresource.Type{ + Group: "demo", + GroupVersion: "v2", + Kind: "artist", + }, + Tenancy: &pbresource.Tenancy{ + Partition: "foo", + PeerName: "bar", + Namespace: "baz", + }, + Name: "qux", + Section: "blah", + } + b := &pbresource.ID{ + Type: &pbresource.Type{ + Group: "demo", + GroupVersion: "v2", + Kind: "artist", + }, + Tenancy: &pbresource.Tenancy{ + Partition: "foo", + PeerName: "bar", + Namespace: "baz", + }, + Name: "qux", + Uid: ulid.Make().String(), + } + require.True(t, resource.ReferenceOrIDMatch(a, b)) + }) + + t.Run("nil", func(t *testing.T) { + a := &pbresource.Reference{ + Type: &pbresource.Type{ + Group: "demo", + GroupVersion: "v2", + Kind: "artist", + }, + Tenancy: &pbresource.Tenancy{ + Partition: "foo", + PeerName: "bar", + Namespace: "baz", + }, + Name: "qux", + Section: "blah", + } + b := &pbresource.ID{ + Type: &pbresource.Type{ + Group: "demo", + GroupVersion: "v2", + Kind: "artist", + }, + Tenancy: &pbresource.Tenancy{ + Partition: "foo", + PeerName: "bar", + Namespace: "baz", + }, + Name: "qux", + Uid: ulid.Make().String(), + } + require.False(t, resource.ReferenceOrIDMatch(a, nil)) + require.False(t, resource.ReferenceOrIDMatch(nil, b)) + }) + + t.Run("different type", func(t *testing.T) { + a := &pbresource.Reference{ + Type: &pbresource.Type{ + Group: "demo", + GroupVersion: "v2", + Kind: "artist", + }, + Tenancy: &pbresource.Tenancy{ + Partition: "foo", + PeerName: "bar", + Namespace: "baz", + }, + Name: "qux", + Section: "blah", + } + b := &pbresource.ID{ + Type: &pbresource.Type{ + Group: "demo", + GroupVersion: "v2", + Kind: "artist", + }, + Tenancy: &pbresource.Tenancy{ + Partition: "foo", + PeerName: "bar", + Namespace: "baz", + }, + Name: "qux", + Uid: ulid.Make().String(), + } + b.Type.Kind = "album" + require.False(t, resource.ReferenceOrIDMatch(a, b)) + }) + + t.Run("different tenancy", func(t *testing.T) { + a := &pbresource.Reference{ + Type: &pbresource.Type{ + Group: "demo", + GroupVersion: "v2", + Kind: "artist", + }, + Tenancy: &pbresource.Tenancy{ + Partition: "foo", + PeerName: "bar", + Namespace: "baz", + }, + Name: "qux", + Section: "blah", + } + b := &pbresource.ID{ + Type: &pbresource.Type{ + Group: "demo", + GroupVersion: "v2", + Kind: "artist", + }, + Tenancy: &pbresource.Tenancy{ + Partition: "foo", + PeerName: "bar", + Namespace: "baz", + }, + Name: "qux", + Uid: ulid.Make().String(), + } + b.Tenancy.Namespace = "qux" + require.False(t, resource.ReferenceOrIDMatch(a, b)) + }) + + t.Run("different name", func(t *testing.T) { + a := &pbresource.Reference{ + Type: &pbresource.Type{ + Group: "demo", + GroupVersion: "v2", + Kind: "artist", + }, + Tenancy: &pbresource.Tenancy{ + Partition: "foo", + PeerName: "bar", + Namespace: "baz", + }, + Name: "qux", + Section: "blah", + } + b := &pbresource.ID{ + Type: &pbresource.Type{ + Group: "demo", + GroupVersion: "v2", + Kind: "artist", + }, + Tenancy: &pbresource.Tenancy{ + Partition: "foo", + PeerName: "bar", + Namespace: "baz", + }, + Name: "qux", + Uid: ulid.Make().String(), + } + b.Name = "boom" + require.False(t, resource.ReferenceOrIDMatch(a, b)) + }) +} + func TestEqualStatus(t *testing.T) { orig := &pbresource.Status{ ObservedGeneration: ulid.Make().String(), diff --git a/internal/resource/mappers/bimapper/bimapper.go b/internal/resource/mappers/bimapper/bimapper.go new file mode 100644 index 0000000000..3f81e29442 --- /dev/null +++ b/internal/resource/mappers/bimapper/bimapper.go @@ -0,0 +1,212 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package bimapper + +import ( + "context" + "fmt" + "sync" + + "github.com/hashicorp/consul/internal/controller" + "github.com/hashicorp/consul/internal/resource" + "github.com/hashicorp/consul/proto-public/pbresource" +) + +// Mapper tracks bidirectional lookup for an item that contains references to +// other items. For example: an HTTPRoute has many references to Services. +// +// The primary object is called the "item" and an item has many "links". +// Tracking is done on items. +type Mapper struct { + itemType, linkType *pbresource.Type + + lock sync.Mutex + itemToLink map[resource.ReferenceKey]map[resource.ReferenceKey]struct{} + linkToItem map[resource.ReferenceKey]map[resource.ReferenceKey]struct{} +} + +// New creates a bimapper between the two required provided types. +func New(itemType, linkType *pbresource.Type) *Mapper { + if itemType == nil { + panic("itemType is required") + } + if linkType == nil { + panic("linkType is required") + } + return &Mapper{ + itemType: itemType, + linkType: linkType, + itemToLink: make(map[resource.ReferenceKey]map[resource.ReferenceKey]struct{}), + linkToItem: make(map[resource.ReferenceKey]map[resource.ReferenceKey]struct{}), + } +} + +// Reset clears the internal mappings. +func (m *Mapper) Reset() { + m.lock.Lock() + defer m.lock.Unlock() + m.itemToLink = make(map[resource.ReferenceKey]map[resource.ReferenceKey]struct{}) + m.linkToItem = make(map[resource.ReferenceKey]map[resource.ReferenceKey]struct{}) +} + +// IsEmpty returns true if the internal structures are empty. +func (m *Mapper) IsEmpty() bool { + m.lock.Lock() + defer m.lock.Unlock() + return len(m.itemToLink) == 0 && len(m.linkToItem) == 0 +} + +// UntrackItem removes tracking for the provided item. The item type MUST match +// the type configured for the item. +func (m *Mapper) UntrackItem(item *pbresource.ID) { + if !resource.EqualType(item.Type, m.itemType) { + panic(fmt.Sprintf("expected item type %q got %q", + resource.TypeToString(m.itemType), + resource.TypeToString(item.Type), + )) + } + m.untrackItem(resource.NewReferenceKey(item)) +} + +func (m *Mapper) untrackItem(item resource.ReferenceKey) { + m.lock.Lock() + defer m.lock.Unlock() + m.removeItemLocked(item) +} + +// TrackItem adds tracking for the provided item. The item and link types MUST +// match the types configured for the items and links. +func (m *Mapper) TrackItem(item *pbresource.ID, links []*pbresource.Reference) { + if !resource.EqualType(item.Type, m.itemType) { + panic(fmt.Sprintf("expected item type %q got %q", + resource.TypeToString(m.itemType), + resource.TypeToString(item.Type), + )) + } + + linksAsKeys := make([]resource.ReferenceKey, 0, len(links)) + for _, link := range links { + if !resource.EqualType(link.Type, m.linkType) { + panic(fmt.Sprintf("expected link type %q got %q", + resource.TypeToString(m.linkType), + resource.TypeToString(link.Type), + )) + } + linksAsKeys = append(linksAsKeys, resource.NewReferenceKey(link)) + } + + m.trackItem(resource.NewReferenceKey(item), linksAsKeys) +} + +func (m *Mapper) trackItem(item resource.ReferenceKey, links []resource.ReferenceKey) { + m.lock.Lock() + defer m.lock.Unlock() + + m.removeItemLocked(item) + m.addItemLocked(item, links) +} + +// you must hold the lock before calling this function +func (m *Mapper) removeItemLocked(item resource.ReferenceKey) { + for link := range m.itemToLink[item] { + delete(m.linkToItem[link], item) + if len(m.linkToItem[link]) == 0 { + delete(m.linkToItem, link) + } + } + delete(m.itemToLink, item) +} + +// you must hold the lock before calling this function +func (m *Mapper) addItemLocked(item resource.ReferenceKey, links []resource.ReferenceKey) { + if m.itemToLink[item] == nil { + m.itemToLink[item] = make(map[resource.ReferenceKey]struct{}) + } + for _, link := range links { + m.itemToLink[item][link] = struct{}{} + + if m.linkToItem[link] == nil { + m.linkToItem[link] = make(map[resource.ReferenceKey]struct{}) + } + m.linkToItem[link][item] = struct{}{} + } +} + +// LinksForItem returns references to links related to the requested item. +func (m *Mapper) LinksForItem(item *pbresource.ID) []*pbresource.Reference { + if !resource.EqualType(item.Type, m.itemType) { + panic(fmt.Sprintf("expected item type %q got %q", + resource.TypeToString(m.itemType), + resource.TypeToString(item.Type), + )) + } + + m.lock.Lock() + defer m.lock.Unlock() + + items, ok := m.linkToItem[resource.NewReferenceKey(item)] + if !ok { + return nil + } + + out := make([]*pbresource.Reference, 0, len(items)) + for item := range items { + out = append(out, item.ToReference()) + } + return out +} + +// ItemsForLink returns item ids for items related to the provided link. +func (m *Mapper) ItemsForLink(link *pbresource.ID) []*pbresource.ID { + if !resource.EqualType(link.Type, m.linkType) { + panic(fmt.Sprintf("expected type %q got %q", + resource.TypeToString(m.linkType), + resource.TypeToString(link.Type), + )) + } + + return m.itemsByLink(resource.NewReferenceKey(link)) +} + +// MapLink is suitable as a DependencyMapper to map the provided link event to its item. +func (m *Mapper) MapLink(_ context.Context, _ controller.Runtime, res *pbresource.Resource) ([]controller.Request, error) { + link := res.Id + + if !resource.EqualType(link.Type, m.linkType) { + return nil, fmt.Errorf("expected type %q got %q", + resource.TypeToString(m.linkType), + resource.TypeToString(link.Type), + ) + } + + itemIDs := m.itemsByLink(resource.NewReferenceKey(link)) + + out := make([]controller.Request, 0, len(itemIDs)) + for _, item := range itemIDs { + if !resource.EqualType(item.Type, m.itemType) { + return nil, fmt.Errorf("expected type %q got %q", + resource.TypeToString(m.itemType), + resource.TypeToString(item.Type), + ) + } + out = append(out, controller.Request{ID: item}) + } + return out, nil +} + +func (m *Mapper) itemsByLink(ref resource.ReferenceKey) []*pbresource.ID { + m.lock.Lock() + defer m.lock.Unlock() + + items, ok := m.linkToItem[ref] + if !ok { + return nil + } + + out := make([]*pbresource.ID, 0, len(items)) + for item := range items { + out = append(out, item.ToID()) + } + return out +} diff --git a/internal/resource/mappers/bimapper/bimapper_test.go b/internal/resource/mappers/bimapper/bimapper_test.go new file mode 100644 index 0000000000..c0f8709ecd --- /dev/null +++ b/internal/resource/mappers/bimapper/bimapper_test.go @@ -0,0 +1,169 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package bimapper + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/hashicorp/consul/internal/controller" + rtest "github.com/hashicorp/consul/internal/resource/resourcetest" + "github.com/hashicorp/consul/proto-public/pbresource" + "github.com/hashicorp/consul/proto/private/prototest" +) + +const ( + fakeGroupName = "catalog" + fakeVersion = "v1" +) + +var ( + fakeFooType = &pbresource.Type{ + Group: fakeGroupName, + GroupVersion: fakeVersion, + Kind: "Foo", + } + fakeBarType = &pbresource.Type{ + Group: fakeGroupName, + GroupVersion: fakeVersion, + Kind: "Bar", + } +) + +func TestMapper(t *testing.T) { + // Create an advance pointer to some services. + + randoSvc := rtest.Resource(fakeBarType, "rando").Build() + apiSvc := rtest.Resource(fakeBarType, "api").Build() + fooSvc := rtest.Resource(fakeBarType, "foo").Build() + barSvc := rtest.Resource(fakeBarType, "bar").Build() + wwwSvc := rtest.Resource(fakeBarType, "www").Build() + + fail1 := rtest.Resource(fakeFooType, "api").Build() + fail1_refs := []*pbresource.Reference{ + newRef(fakeBarType, "api"), + newRef(fakeBarType, "foo"), + newRef(fakeBarType, "bar"), + } + + fail2 := rtest.Resource(fakeFooType, "www").Build() + fail2_refs := []*pbresource.Reference{ + newRef(fakeBarType, "www"), + newRef(fakeBarType, "foo"), + } + + fail1_updated := rtest.Resource(fakeFooType, "api").Build() + fail1_updated_refs := []*pbresource.Reference{ + newRef(fakeBarType, "api"), + newRef(fakeBarType, "bar"), + } + + m := New(fakeFooType, fakeBarType) + + // Nothing tracked yet so we assume nothing. + requireServicesTracked(t, m, randoSvc) + requireServicesTracked(t, m, apiSvc) + requireServicesTracked(t, m, fooSvc) + requireServicesTracked(t, m, barSvc) + requireServicesTracked(t, m, wwwSvc) + + // no-ops + m.UntrackItem(fail1.Id) + + // still nothing + requireServicesTracked(t, m, randoSvc) + requireServicesTracked(t, m, apiSvc) + requireServicesTracked(t, m, fooSvc) + requireServicesTracked(t, m, barSvc) + requireServicesTracked(t, m, wwwSvc) + + // Actually insert some data. + m.TrackItem(fail1.Id, fail1_refs) + + requireServicesTracked(t, m, randoSvc) + requireServicesTracked(t, m, apiSvc, fail1.Id) + requireServicesTracked(t, m, fooSvc, fail1.Id) + requireServicesTracked(t, m, barSvc, fail1.Id) + requireServicesTracked(t, m, wwwSvc) + + // track it again, no change + m.TrackItem(fail1.Id, fail1_refs) + + requireServicesTracked(t, m, randoSvc) + requireServicesTracked(t, m, apiSvc, fail1.Id) + requireServicesTracked(t, m, fooSvc, fail1.Id) + requireServicesTracked(t, m, barSvc, fail1.Id) + requireServicesTracked(t, m, wwwSvc) + + // track new one that overlaps slightly + m.TrackItem(fail2.Id, fail2_refs) + + requireServicesTracked(t, m, randoSvc) + requireServicesTracked(t, m, apiSvc, fail1.Id) + requireServicesTracked(t, m, fooSvc, fail1.Id, fail2.Id) + requireServicesTracked(t, m, barSvc, fail1.Id) + requireServicesTracked(t, m, wwwSvc, fail2.Id) + + // update the original to change it + m.TrackItem(fail1_updated.Id, fail1_updated_refs) + + requireServicesTracked(t, m, randoSvc) + requireServicesTracked(t, m, apiSvc, fail1.Id) + requireServicesTracked(t, m, fooSvc, fail2.Id) + requireServicesTracked(t, m, barSvc, fail1.Id) + requireServicesTracked(t, m, wwwSvc, fail2.Id) + + // delete the original + m.UntrackItem(fail1.Id) + + requireServicesTracked(t, m, randoSvc) + requireServicesTracked(t, m, apiSvc) + requireServicesTracked(t, m, fooSvc, fail2.Id) + requireServicesTracked(t, m, barSvc) + requireServicesTracked(t, m, wwwSvc, fail2.Id) + + // delete the other one + m.UntrackItem(fail2.Id) + + requireServicesTracked(t, m, randoSvc) + requireServicesTracked(t, m, apiSvc) + requireServicesTracked(t, m, fooSvc) + requireServicesTracked(t, m, barSvc) + requireServicesTracked(t, m, wwwSvc) +} + +func requireServicesTracked(t *testing.T, mapper *Mapper, link *pbresource.Resource, items ...*pbresource.ID) { + t.Helper() + + reqs, err := mapper.MapLink( + context.Background(), + controller.Runtime{}, + link, + ) + require.NoError(t, err) + + require.Len(t, reqs, len(items)) + + for _, item := range items { + prototest.AssertContainsElement(t, reqs, controller.Request{ID: item}) + } +} + +func newRef(typ *pbresource.Type, name string) *pbresource.Reference { + return rtest.Resource(typ, name).Reference("") +} + +func newID(typ *pbresource.Type, name string) *pbresource.ID { + return rtest.Resource(typ, name).ID() +} + +func defaultTenancy() *pbresource.Tenancy { + return &pbresource.Tenancy{ + Partition: "default", + Namespace: "default", + PeerName: "local", + } +} diff --git a/internal/resource/reference.go b/internal/resource/reference.go index 80492c9878..7610f6288d 100644 --- a/internal/resource/reference.go +++ b/internal/resource/reference.go @@ -14,3 +14,26 @@ func Reference(id *pbresource.ID, section string) *pbresource.Reference { Section: section, } } + +// IDFromReference returns a Reference converted into an ID. NOTE: the UID +// field is not populated, and the Section field of a reference is dropped. +func IDFromReference(ref *pbresource.Reference) *pbresource.ID { + return &pbresource.ID{ + Type: ref.Type, + Tenancy: ref.Tenancy, + Name: ref.Name, + } +} + +// ReferenceOrID is the common accessors shared by pbresource.Reference and +// pbresource.ID. +type ReferenceOrID interface { + GetType() *pbresource.Type + GetTenancy() *pbresource.Tenancy + GetName() string +} + +var ( + _ ReferenceOrID = (*pbresource.ID)(nil) + _ ReferenceOrID = (*pbresource.Reference)(nil) +) diff --git a/internal/resource/refkey.go b/internal/resource/refkey.go new file mode 100644 index 0000000000..d6ddcac6a1 --- /dev/null +++ b/internal/resource/refkey.go @@ -0,0 +1,93 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package resource + +import ( + "fmt" + "strings" + + "github.com/hashicorp/consul/proto-public/pbresource" +) + +// ReferenceKey is the pointer-free representation of a ReferenceOrID +// suitable for a go map key. +type ReferenceKey struct { + GVK string + Partition string // Tenancy.* + Namespace string // Tenancy.* + PeerName string // Tenancy.* + Name string +} + +// String returns a string representation of the ReferenceKey. This should not +// be relied upon nor parsed and is provided just for debugging and logging +// reasons. +// +// This format should be aligned with IDToString and ReferenceToString. +func (r ReferenceKey) String() string { + return fmt.Sprintf("%s/%s.%s.%s/%s", + r.GVK, + orDefault(r.Partition, "default"), + orDefault(r.PeerName, "local"), + orDefault(r.Namespace, "default"), + r.Name, + ) +} + +func (r ReferenceKey) GetTenancy() *pbresource.Tenancy { + return &pbresource.Tenancy{ + Partition: r.Partition, + PeerName: r.PeerName, + Namespace: r.Namespace, + } +} + +// ToReference converts this back into a pbresource.ID. +func (r ReferenceKey) ToID() *pbresource.ID { + return &pbresource.ID{ + Type: GVKToType(r.GVK), + Tenancy: r.GetTenancy(), + Name: r.Name, + } +} + +// ToReference converts this back into a pbresource.Reference. +func (r ReferenceKey) ToReference() *pbresource.Reference { + return &pbresource.Reference{ + Type: GVKToType(r.GVK), + Tenancy: r.GetTenancy(), + Name: r.Name, + } +} + +func (r ReferenceKey) GoString() string { return r.String() } + +func NewReferenceKey(refOrID ReferenceOrID) ReferenceKey { + return ReferenceKey{ + GVK: ToGVK(refOrID.GetType()), + Partition: orDefault(refOrID.GetTenancy().GetPartition(), "default"), + Namespace: orDefault(refOrID.GetTenancy().GetNamespace(), "default"), + PeerName: orDefault(refOrID.GetTenancy().GetPeerName(), "local"), + Name: refOrID.GetName(), + } +} + +func orDefault(v, def string) string { + if v == "" { + return def + } + return v +} + +func GVKToType(gvk string) *pbresource.Type { + parts := strings.Split(gvk, ".") + if len(parts) != 3 { + panic("bad gvk") + } + return &pbresource.Type{ + Group: parts[0], + GroupVersion: parts[1], + Kind: parts[2], + } +} diff --git a/internal/resource/refkey_test.go b/internal/resource/refkey_test.go new file mode 100644 index 0000000000..f5f1a7796f --- /dev/null +++ b/internal/resource/refkey_test.go @@ -0,0 +1,87 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package resource_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/hashicorp/consul/internal/resource" + "github.com/hashicorp/consul/internal/resource/demo" + "github.com/hashicorp/consul/proto-public/pbresource" + "github.com/hashicorp/consul/proto/private/prototest" +) + +func TestReferenceKey(t *testing.T) { + tenancy1 := &pbresource.Tenancy{} + tenancy1_actual := defaultTenancy() + tenancy2 := &pbresource.Tenancy{ + Partition: "ap1", + Namespace: "ns-billing", + PeerName: "peer-dc4", + } + tenancy3 := &pbresource.Tenancy{ + Partition: "ap2", + Namespace: "ns-intern", + PeerName: "peer-sea", + } + + res1, err := demo.GenerateV2Artist() + require.NoError(t, err) + res1.Id.Tenancy = tenancy1 + + res2, err := demo.GenerateV2Artist() + require.NoError(t, err) + res2.Id.Tenancy = tenancy2 + + res3, err := demo.GenerateV2Artist() + require.NoError(t, err) + res3.Id.Tenancy = tenancy3 + + id1 := res1.Id + id2 := res2.Id + id3 := res3.Id + + ref1 := resource.Reference(id1, "") + ref2 := resource.Reference(id2, "") + ref3 := resource.Reference(id3, "") + + idRK1 := resource.NewReferenceKey(id1) + idRK2 := resource.NewReferenceKey(id2) + idRK3 := resource.NewReferenceKey(id3) + + refRK1 := resource.NewReferenceKey(ref1) + refRK2 := resource.NewReferenceKey(ref2) + refRK3 := resource.NewReferenceKey(ref3) + + require.Equal(t, idRK1, refRK1) + require.Equal(t, idRK2, refRK2) + require.Equal(t, idRK3, refRK3) + + prototest.AssertDeepEqual(t, tenancy1_actual, idRK1.GetTenancy()) + prototest.AssertDeepEqual(t, tenancy2, idRK2.GetTenancy()) + prototest.AssertDeepEqual(t, tenancy3, idRK3.GetTenancy()) + + // Now that we tested the defaulting, swap out the tenancy in the id so + // that the comparisons work. + id1.Tenancy = tenancy1_actual + ref1.Tenancy = tenancy1_actual + + prototest.AssertDeepEqual(t, id1, idRK1.ToID()) + prototest.AssertDeepEqual(t, id2, idRK2.ToID()) + prototest.AssertDeepEqual(t, id3, idRK3.ToID()) + + prototest.AssertDeepEqual(t, ref1, refRK1.ToReference()) + prototest.AssertDeepEqual(t, ref2, refRK2.ToReference()) + prototest.AssertDeepEqual(t, ref3, refRK3.ToReference()) +} + +func defaultTenancy() *pbresource.Tenancy { + return &pbresource.Tenancy{ + Partition: "default", + Namespace: "default", + PeerName: "local", + } +} diff --git a/internal/resource/resourcetest/builder.go b/internal/resource/resourcetest/builder.go index 749ff4fea2..395cba57b2 100644 --- a/internal/resource/resourcetest/builder.go +++ b/internal/resource/resourcetest/builder.go @@ -1,12 +1,11 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + package resourcetest import ( "strings" - "github.com/hashicorp/consul/internal/storage" - "github.com/hashicorp/consul/proto-public/pbresource" - "github.com/hashicorp/consul/sdk/testutil" - "github.com/hashicorp/consul/sdk/testutil/retry" "github.com/oklog/ulid/v2" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" @@ -14,6 +13,12 @@ import ( "google.golang.org/protobuf/proto" "google.golang.org/protobuf/reflect/protoreflect" "google.golang.org/protobuf/types/known/anypb" + + "github.com/hashicorp/consul/internal/resource" + "github.com/hashicorp/consul/internal/storage" + "github.com/hashicorp/consul/proto-public/pbresource" + "github.com/hashicorp/consul/sdk/testutil" + "github.com/hashicorp/consul/sdk/testutil/retry" ) type resourceBuilder struct { @@ -118,6 +123,10 @@ func (b *resourceBuilder) ID() *pbresource.ID { return b.resource.Id } +func (b *resourceBuilder) Reference(section string) *pbresource.Reference { + return resource.Reference(b.ID(), section) +} + func (b *resourceBuilder) Write(t T, client pbresource.ResourceServiceClient) *pbresource.Resource { t.Helper() @@ -136,6 +145,9 @@ func (b *resourceBuilder) Write(t T, client pbresource.ResourceServiceClient) *p }) if err == nil || res.Id.Uid != "" || status.Code(err) != codes.FailedPrecondition { + if err != nil { + t.Logf("write saw error: %v", err) + } return } diff --git a/internal/resource/resourcetest/client.go b/internal/resource/resourcetest/client.go index 5047406d05..17d621884e 100644 --- a/internal/resource/resourcetest/client.go +++ b/internal/resource/resourcetest/client.go @@ -1,3 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + package resourcetest import ( @@ -5,14 +8,15 @@ import ( "math/rand" "time" - "github.com/hashicorp/consul/internal/resource" - "github.com/hashicorp/consul/proto-public/pbresource" - "github.com/hashicorp/consul/sdk/testutil" - "github.com/hashicorp/consul/sdk/testutil/retry" "github.com/stretchr/testify/require" "golang.org/x/exp/slices" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + + "github.com/hashicorp/consul/internal/resource" + "github.com/hashicorp/consul/proto-public/pbresource" + "github.com/hashicorp/consul/sdk/testutil" + "github.com/hashicorp/consul/sdk/testutil/retry" ) type Client struct { @@ -157,6 +161,16 @@ func (client *Client) RequireStatusConditionForCurrentGen(t T, id *pbresource.ID return res } +func (client *Client) RequireStatusConditionsForCurrentGen(t T, id *pbresource.ID, statusKey string, conditions []*pbresource.Condition) *pbresource.Resource { + t.Helper() + + res := client.RequireResourceExists(t, id) + for _, condition := range conditions { + RequireStatusConditionForCurrentGen(t, res, statusKey, condition) + } + return res +} + func (client *Client) RequireResourceMeta(t T, id *pbresource.ID, key string, value string) *pbresource.Resource { t.Helper() @@ -196,6 +210,17 @@ func (client *Client) WaitForStatusCondition(t T, id *pbresource.ID, statusKey s return res } +func (client *Client) WaitForStatusConditions(t T, id *pbresource.ID, statusKey string, conditions ...*pbresource.Condition) *pbresource.Resource { + t.Helper() + + var res *pbresource.Resource + client.retry(t, func(r *retry.R) { + res = client.RequireStatusConditionsForCurrentGen(r, id, statusKey, conditions) + }) + + return res +} + func (client *Client) WaitForNewVersion(t T, id *pbresource.ID, version string) *pbresource.Resource { t.Helper() diff --git a/internal/resource/resourcetest/decode.go b/internal/resource/resourcetest/decode.go new file mode 100644 index 0000000000..3bdb35cc02 --- /dev/null +++ b/internal/resource/resourcetest/decode.go @@ -0,0 +1,23 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package resourcetest + +import ( + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + + "github.com/hashicorp/consul/internal/resource" + "github.com/hashicorp/consul/proto-public/pbresource" +) + +func MustDecode[V any, PV interface { + proto.Message + *V +}](t *testing.T, res *pbresource.Resource) *resource.DecodedResource[V, PV] { + dec, err := resource.Decode[V, PV](res) + require.NoError(t, err) + return dec +} diff --git a/internal/resource/resourcetest/validation.go b/internal/resource/resourcetest/validation.go new file mode 100644 index 0000000000..e8f3ee2218 --- /dev/null +++ b/internal/resource/resourcetest/validation.go @@ -0,0 +1,30 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package resourcetest + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/hashicorp/consul/internal/resource" + "github.com/hashicorp/consul/proto-public/pbresource" +) + +func ValidateAndNormalize(t *testing.T, registry resource.Registry, res *pbresource.Resource) { + typ := res.Id.Type + + typeInfo, ok := registry.Resolve(typ) + if !ok { + t.Fatalf("unhandled resource type: %q", resource.ToGVK(typ)) + } + + if typeInfo.Mutate != nil { + require.NoError(t, typeInfo.Mutate(res), "failed to apply type mutation to resource") + } + + if typeInfo.Validate != nil { + require.NoError(t, typeInfo.Validate(res), "failed to validate resource") + } +} diff --git a/internal/resource/stringer.go b/internal/resource/stringer.go new file mode 100644 index 0000000000..927a86f2ba --- /dev/null +++ b/internal/resource/stringer.go @@ -0,0 +1,60 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package resource + +import ( + "fmt" + + "github.com/hashicorp/consul/proto-public/pbresource" +) + +// IDToString returns a string representation of pbresource.ID. This should not +// be relied upon nor parsed and is provided just for debugging and logging +// reasons. +// +// This format should be aligned with ReferenceToString and +// (ReferenceKey).String. +func IDToString(id *pbresource.ID) string { + s := fmt.Sprintf("%s/%s/%s", + TypeToString(id.Type), + TenancyToString(id.Tenancy), + id.Name, + ) + if id.Uid != "" { + return s + "?uid=" + id.Uid + } + return s +} + +// ReferenceToString returns a string representation of pbresource.Reference. +// This should not be relied upon nor parsed and is provided just for debugging +// and logging reasons. +// +// This format should be aligned with IDToString and (ReferenceKey).String. +func ReferenceToString(ref *pbresource.Reference) string { + s := fmt.Sprintf("%s/%s/%s", + TypeToString(ref.Type), + TenancyToString(ref.Tenancy), + ref.Name, + ) + + if ref.Section != "" { + return s + "?section=" + ref.Section + } + return s +} + +// TenancyToString returns a string representation of pbresource.Tenancy. This +// should not be relied upon nor parsed and is provided just for debugging and +// logging reasons. +func TenancyToString(tenancy *pbresource.Tenancy) string { + return fmt.Sprintf("%s.%s.%s", tenancy.Partition, tenancy.PeerName, tenancy.Namespace) +} + +// TypeToString returns a string representation of pbresource.Type. This should +// not be relied upon nor parsed and is provided just for debugging and logging +// reasons. +func TypeToString(typ *pbresource.Type) string { + return ToGVK(typ) +}