diff --git a/lib/scopes/cursor.go b/lib/scopes/cursor.go new file mode 100644 index 00000000000..7d700fe759d --- /dev/null +++ b/lib/scopes/cursor.go @@ -0,0 +1,110 @@ +// Teleport +// Copyright (C) 2026 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package scopes + +import ( + "strings" + + "github.com/gravitational/trace" +) + +// ResourceCursorPrefix prefixes cursors for scoped resources in a +// logical resource stream. +// +// The prefix starts with '~', which is not allowed in backend-safe resource +// names and sorts after all backend-safe name bytes, preserving historical +// name-only cursors for unscoped resources while ordering scoped resources +// after unscoped resources. +const ResourceCursorPrefix = "~scoped/" + +// ResourceCursorScopedStart returns the first cursor in the scoped portion of +// the logical resource stream. +func ResourceCursorScopedStart() string { + return ResourceCursorPrefix +} + +// IsScopedResourceCursor returns true if cursor is in the scoped portion of the +// logical resource stream. +func IsScopedResourceCursor(cursor string) bool { + return strings.HasPrefix(cursor, ResourceCursorPrefix) +} + +// MakeResourceCursor returns the cursor for a scoped or unscoped resource in a +// logical, lexicographically ordered resource stream. +// +// Resource cursors are intended for pagination tokens, range bounds, and +// in-memory cache indexes. They are not backend storage keys and must not be +// used to construct backend keys. +// +// Unscoped resource cursors preserve the historical name-only format: +// +// +// +// Scoped resource cursors use a synthetic prefix that cannot appear in +// backend-safe resource names and sorts after all backend-safe name bytes: +// +// ~scoped// +// +// The scope component is encoded with EncodeForKey so that scoped cursors +// preserve scope ordering and can safely use '/' as the cursor separator. +func MakeResourceCursor(scope, name string) (string, error) { + if scope == "" { + return name, nil + } + + encodedScope, err := EncodeForKey(scope) + if err != nil { + return "", trace.Wrap(err) + } + + return ResourceCursorPrefix + encodedScope + separator + name, nil +} + +// ParseResourceCursor parses a resource cursor produced by [MakeResourceCursor] +// into its scope and name components. +// +// Unscoped cursors are interpreted as historical name-only cursors. Scoped +// cursors must use the scoped cursor format: +// +// ~scoped// +func ParseResourceCursor(cursor string) (QualifiedName, error) { + encodedScopeAndName, ok := strings.CutPrefix(cursor, ResourceCursorPrefix) + if !ok { + return QualifiedName{Name: cursor}, nil + } + + encodedScope, name, ok := strings.Cut(encodedScopeAndName, separator) + if !ok { + return QualifiedName{}, trace.BadParameter("scoped resource cursor %q missing name separator", cursor) + } + if encodedScope == "" { + return QualifiedName{}, trace.BadParameter("scoped resource cursor %q has empty encoded scope", cursor) + } + if name == "" { + return QualifiedName{}, trace.BadParameter("scoped resource cursor %q has empty name", cursor) + } + if strings.Contains(name, separator) { + return QualifiedName{}, trace.BadParameter("scoped resource cursor %q has invalid name", cursor) + } + + scope, err := DecodeFromKey(encodedScope) + if err != nil { + return QualifiedName{}, trace.Wrap(err) + } + + return QualifiedName{Scope: scope, Name: name}, nil +} diff --git a/lib/scopes/cursor_test.go b/lib/scopes/cursor_test.go new file mode 100644 index 00000000000..e8b53168bda --- /dev/null +++ b/lib/scopes/cursor_test.go @@ -0,0 +1,113 @@ +// Teleport +// Copyright (C) 2026 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package scopes + +import ( + "slices" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestResourceCursor(t *testing.T) { + t.Parallel() + + t.Run("unscoped", func(t *testing.T) { + cursor, err := MakeResourceCursor("", "name") + require.NoError(t, err) + require.Equal(t, "name", cursor) + require.False(t, IsScopedResourceCursor(cursor)) + + parsed, err := ParseResourceCursor(cursor) + require.NoError(t, err) + require.Equal(t, QualifiedName{Name: "name"}, parsed) + }) + + t.Run("scoped", func(t *testing.T) { + cursor, err := MakeResourceCursor("/aa/bb", "name") + require.NoError(t, err) + require.True(t, IsScopedResourceCursor(cursor)) + require.Contains(t, cursor, ResourceCursorPrefix) + + parsed, err := ParseResourceCursor(cursor) + require.NoError(t, err) + require.Equal(t, QualifiedName{Scope: "/aa/bb", Name: "name"}, parsed) + }) + + t.Run("scoped start", func(t *testing.T) { + require.Equal(t, ResourceCursorPrefix, ResourceCursorScopedStart()) + require.True(t, IsScopedResourceCursor(ResourceCursorScopedStart())) + }) +} + +func TestParseResourceCursorErrors(t *testing.T) { + t.Parallel() + + for _, cursor := range []string{ + ResourceCursorPrefix, + ResourceCursorPrefix + "/name", + ResourceCursorPrefix + "++/", + ResourceCursorPrefix + "++/nested/name", + ResourceCursorPrefix + "invalid/name", + } { + t.Run(cursor, func(t *testing.T) { + _, err := ParseResourceCursor(cursor) + require.Error(t, err) + }) + } +} + +func TestResourceCursorSort(t *testing.T) { + t.Parallel() + + resources := []QualifiedName{ + {Scope: "/bb", Name: "aaa"}, + {Scope: "", Name: "zzz"}, + {Scope: "/aa", Name: "bbb"}, + {Scope: "/aa/bb", Name: "aaa"}, + {Scope: "", Name: "aaa"}, + {Scope: "/aa", Name: "aaa"}, + } + + cursors := make([]string, 0, len(resources)) + for _, resource := range resources { + cursor, err := MakeResourceCursor(resource.Scope, resource.Name) + require.NoError(t, err) + cursors = append(cursors, cursor) + } + + slices.Sort(cursors) + + var got []QualifiedName + for _, cursor := range cursors { + resource, err := ParseResourceCursor(cursor) + require.NoError(t, err) + got = append(got, resource) + } + + require.Equal(t, []QualifiedName{ + // Unscoped cursors preserve historical name-only ordering and sort before + // all scoped cursors. + {Scope: "", Name: "aaa"}, + {Scope: "", Name: "zzz"}, + // Scoped cursors sort by encoded scope, then name within the same scope. + {Scope: "/aa", Name: "aaa"}, + {Scope: "/aa", Name: "bbb"}, + {Scope: "/aa/bb", Name: "aaa"}, + {Scope: "/bb", Name: "aaa"}, + }, got) +} diff --git a/lib/scopes/qualified.go b/lib/scopes/qualified.go index 7162e188455..2905859ade1 100644 --- a/lib/scopes/qualified.go +++ b/lib/scopes/qualified.go @@ -25,7 +25,7 @@ import ( ) // QualifiedNameSeparator is the separator between scope and name in a -// scope-qualified name. This separator must never appear in scope segements. +// scope-qualified name. This separator must never appear in scope segments. const QualifiedNameSeparator = "::" // QualifiedName pairs a scope with a resource name to uniquely identify a scoped @@ -35,6 +35,10 @@ const QualifiedNameSeparator = "::" // is necessary to fully specify the unique identifier of a scoped resource. Internally, // teleport APIs should generally continue to use separate scope and name fields, as // should structured logs/events. +// +// A QualifiedName may be used in APIs that need to refer to a resource that +// may be scoped or unscoped. In these cases the Scope field may be empty, and +// WeakValidate and StrongValidate will return an error. type QualifiedName struct { // Scope is the resource's scope path, e.g. "/staging/west". Scope string @@ -42,8 +46,12 @@ type QualifiedName struct { Name string } -// String returns encodes the string representation of the QualifiedName. +// String returns the string representation of the QualifiedName. +// If the Scope is empty, the Name is returned verbatim. func (q QualifiedName) String() string { + if q.Scope == "" { + return q.Name + } return q.Scope + QualifiedNameSeparator + q.Name } diff --git a/lib/services/local/generic/generic.go b/lib/services/local/generic/generic.go index e4380bc4034..f7545ec2b80 100644 --- a/lib/services/local/generic/generic.go +++ b/lib/services/local/generic/generic.go @@ -252,17 +252,19 @@ func (s *Service[T]) Resources(ctx context.Context, startKey, endKey string) ite return resource, true } - return stream.TakeWhile( - stream.FilterMap(s.backend.Items(ctx, params), mapFn), - func(r T) bool { - // We promise the consumers of this function that the returned - // results an exclusive of the endkey but the underlying Items - // method returns us a stream that's inclusive of the end key - so - // if the user has provided us an end-key, we manually filter them - // out to convert from inclusive to exclusive. - return endKey == "" || r.GetName() < endKey - }, - ) + items := s.backend.Items(ctx, params) + if endKey != "" { + exclusiveEndKey := s.backendPrefix.AppendKey(backend.KeyFromString(endKey)) + items = stream.TakeWhile(items, func(item backend.Item) bool { + // We promise consumers that the returned results are exclusive of + // endKey, but the underlying Items method returns an inclusive end + // key. Compare the full backend key so composite relative keys such + // as "/" are handled correctly. + return item.Key.Compare(exclusiveEndKey) < 0 + }) + } + + return stream.FilterMap(items, mapFn) } // ListResources returns a paginated list of resources. diff --git a/lib/services/local/generic/generic_test.go b/lib/services/local/generic/generic_test.go index 28bfa5fb55d..9ce62730cab 100644 --- a/lib/services/local/generic/generic_test.go +++ b/lib/services/local/generic/generic_test.go @@ -45,7 +45,12 @@ import ( // testResource for testing the generic service. type testResource struct { types.ResourceHeader - Spec testResourceSpec + Spec testResourceSpec + Scope string +} + +func (r *testResource) GetScope() string { + return r.Scope } func newTestResource(name string) *testResource { diff --git a/lib/services/local/generic/scopeaware.go b/lib/services/local/generic/scopeaware.go new file mode 100644 index 00000000000..65c9c0a52bf --- /dev/null +++ b/lib/services/local/generic/scopeaware.go @@ -0,0 +1,324 @@ +// Teleport +// Copyright (C) 2026 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package generic + +import ( + "context" + "iter" + "strings" + "time" + + "github.com/gravitational/trace" + + "github.com/gravitational/teleport/lib/backend" + "github.com/gravitational/teleport/lib/itertools/stream" + "github.com/gravitational/teleport/lib/scopes" +) + +// ScopedResource is a resource type that has a scope. The scope may be empty +// on any individual resource of the type, that resource may be referred to as +// "unscoped" despite the type being scoped. +type ScopedResource interface { + Resource + // GetScope returns the scope of the resource. + GetScope() string +} + +// ScopeAwareService is a generic service for interacting with namespaced +// scoped resources in the backend. Scoped resources will be stored in a +// separate key range from resources with an empty scope, and namespaced by +// their scope. The ScopeAwareService transparently handles listing all scoped +// and unscoped resources, as well as creating and querying individual resources +// from the correct key range. +type ScopeAwareService[T ScopedResource] struct { + // UnscopedService is the underlying service for resources with an empty scope. + // Resources will be keyed at / + UnscopedService *Service[T] + // ScopedService is the underlying service for resources with a scope. + // Resources will be keyed at /// + ScopedService *Service[T] +} + +// ScopeAwareServiceConfig holds configuration options for ScopeAwareService. +type ScopeAwareServiceConfig[T ScopedResource] struct { + // Backend used to persist the resource. + Backend backend.Backend + // ResourceKind is the friendly name of the resource. + ResourceKind string + // UnscopedBackendPrefix used when constructing the [backend.Item.Key] for unscoped resources. + UnscopedBackendPrefix backend.Key + // ScopedBackendPrefix used when constructing the [backend.Item.Key] for scoped resources. + ScopedBackendPrefix backend.Key + // PageLimit + PageLimit uint + // MarshlFunc converts the resource to bytes for persistence. + MarshalFunc MarshalFunc[T] + // UnmarshalFunc converts the bytes read from the backend to the resource. + UnmarshalFunc UnmarshalFunc[T] + // ValidateFunc optionally validates the resource prior to persisting it. Any errors + // returned from the validation function will prevent writes to the backend. + ValidateFunc func(T) error + // RunWhileLockedRetryInterval is the interval to retry the RunWhileLocked function. + // If set to 0, the default interval of 250ms will be used. + // WARNING: If set to a negative value, the RunWhileLocked function will retry immediately. + RunWhileLockedRetryInterval time.Duration +} + +// NewScopeAwareService returns a new scope-aware service. +func NewScopeAwareService[T ScopedResource](cfg *ScopeAwareServiceConfig[T]) (*ScopeAwareService[T], error) { + unscopedService, err := NewService(&ServiceConfig[T]{ + Backend: cfg.Backend, + ResourceKind: cfg.ResourceKind, + PageLimit: cfg.PageLimit, + BackendPrefix: cfg.UnscopedBackendPrefix, + MarshalFunc: cfg.MarshalFunc, + UnmarshalFunc: cfg.UnmarshalFunc, + ValidateFunc: cfg.ValidateFunc, + RunWhileLockedRetryInterval: cfg.RunWhileLockedRetryInterval, + }) + if err != nil { + return nil, trace.Wrap(err) + } + + scopedService, err := NewService(&ServiceConfig[T]{ + Backend: cfg.Backend, + ResourceKind: cfg.ResourceKind, + PageLimit: cfg.PageLimit, + BackendPrefix: cfg.ScopedBackendPrefix, + MarshalFunc: cfg.MarshalFunc, + UnmarshalFunc: cfg.UnmarshalFunc, + ValidateFunc: cfg.ValidateFunc, + RunWhileLockedRetryInterval: cfg.RunWhileLockedRetryInterval, + }) + if err != nil { + return nil, trace.Wrap(err) + } + + return &ScopeAwareService[T]{ + UnscopedService: unscopedService, + ScopedService: scopedService, + }, nil +} + +// Resources returns a stream of resources within the unified scope-aware range +// [startKey, endKey). Unscoped resources are ordered before scoped resources. +// +// The startKey and endKey values are resource cursors as defined by +// [scopes.MakeResourceCursor]. An unscoped cursor is the resource name. A scoped +// cursor uses [scopes.ResourceCursorPrefix] followed by the scoped backend +// service's relative key. [scopes.ResourceCursorScopedStart] is the boundary +// between unscoped and scoped resources. +func (s *ScopeAwareService[T]) Resources(ctx context.Context, startKey, endKey string) iter.Seq2[T, error] { + var streams []stream.Stream[T] + + if !scopes.IsScopedResourceCursor(startKey) { + unscopedEndKey := endKey + if scopes.IsScopedResourceCursor(endKey) { + unscopedEndKey = "" + } + streams = append(streams, s.UnscopedService.Resources(ctx, startKey, unscopedEndKey)) + } + + if endKey != "" && !scopes.IsScopedResourceCursor(endKey) { + return stream.Chain(streams...) + } + if endKey == scopes.ResourceCursorScopedStart() { + return stream.Chain(streams...) + } + + scopedStartKey := "" + if scopes.IsScopedResourceCursor(startKey) { + scopedStartKey = strings.TrimPrefix(startKey, scopes.ResourceCursorPrefix) + } + scopedEndKey := strings.TrimPrefix(endKey, scopes.ResourceCursorPrefix) + streams = append(streams, s.ScopedService.Resources(ctx, scopedStartKey, scopedEndKey)) + + return stream.Chain(streams...) +} + +// GetResources returns all unscoped and scoped resources. +func (s *ScopeAwareService[T]) GetResources(ctx context.Context) ([]T, error) { + return stream.Collect(s.Resources(ctx, "", "")) +} + +// ListResources returns a page of resources over the unified scoped +// and unscoped collection. It always returns all unscoped resources before +// matching scoped resources. +func (s *ScopeAwareService[T]) ListResources(ctx context.Context, pageSize int, nextToken string) ([]T, string, error) { + return s.ListResourcesWithFilter(ctx, pageSize, nextToken, func(T) bool { return true }) +} + +// ListResourcesWithFilter returns a page of matching resources over the +// unified scoped and unscoped collection. It always returns all matching +// unscoped resources before matching scoped resources. +func (s *ScopeAwareService[T]) ListResourcesWithFilter(ctx context.Context, pageSize int, nextToken string, matcher func(T) bool) ([]T, string, error) { + if pageSize <= 0 || pageSize > int(s.UnscopedService.pageLimit) { + pageSize = int(s.UnscopedService.pageLimit) + } + + // Check if the token was scoped, if so the caller has already paged over + // all unscoped resources and we should return the next page of scoped + // resources. + if scopes.IsScopedResourceCursor(nextToken) { + nextToken = strings.TrimPrefix(nextToken, scopes.ResourceCursorPrefix) + resources, nextToken, err := s.ScopedService.ListResourcesWithFilter(ctx, pageSize, nextToken, matcher) + if nextToken != "" { + nextToken = scopes.ResourceCursorPrefix + nextToken + } + return resources, nextToken, trace.Wrap(err) + } + + // Fetch the next page of matching unscoped resources. + resources, nextToken, err := s.UnscopedService.ListResourcesWithFilter(ctx, pageSize, nextToken, matcher) + if err != nil { + return nil, "", trace.Wrap(err) + } + + if nextToken != "" { + // There are remaining unscoped resources, return this page. + return resources, nextToken, nil + } + if len(resources) >= pageSize { + // The page is full but nextToken is empty indicating there are no more + // unscoped resources. Return with scopedPageTokenPrefix so the next + // page begins with scoped resources. + return resources, scopes.ResourceCursorScopedStart(), nil + } + + // Reached the end of unscoped resources within pageSize, try to fill in + // the page with scoped resources. + remainingPageSize := pageSize - len(resources) + scopedResources, nextToken, err := s.ScopedService.ListResourcesWithFilter(ctx, remainingPageSize, "", matcher) + if nextToken != "" { + nextToken = scopes.ResourceCursorPrefix + nextToken + } + return append(resources, scopedResources...), nextToken, trace.Wrap(err) +} + +// GetResource returns a resource, if it exists, for the given scope-qualified name. +// If the scope is empty, it returns an unscoped resource from the unscoped key range. +// If the scope is non-empty, it returns a scoped resource from the scoped key range. +func (s *ScopeAwareService[T]) GetResource(ctx context.Context, scopedName scopes.QualifiedName) (T, error) { + svc, err := s.WithScopePrefix(scopedName.Scope) + if err != nil { + var nul T + return nul, trace.Wrap(err) + } + return svc.GetResource(ctx, scopedName.Name) +} + +// DeleteResource deletes a resource for the given scope-qualified name. +// If the scope is empty, it deletes an unscoped resource from the unscoped key range. +// If the scope is non-empty, it deletes a scoped resource from the scoped key range. +func (s *ScopeAwareService[T]) DeleteResource(ctx context.Context, scopedName scopes.QualifiedName) error { + svc, err := s.WithScopePrefix(scopedName.Scope) + if err != nil { + return trace.Wrap(err) + } + return svc.DeleteResource(ctx, scopedName.Name) +} + +// DeleteAllResources deletes all scoped and unscoped resources. +func (s *ScopeAwareService[T]) DeleteAllResources(ctx context.Context) error { + return trace.NewAggregate( + s.UnscopedService.DeleteAllResources(ctx), + s.ScopedService.DeleteAllResources(ctx), + ) +} + +// CreateResource creates the given scoped resource if it doesn't already +// exist. If the scope is empty, it will be inserted in the unscoped key range, +// else it will be inserted in the scoped key range. +func (s *ScopeAwareService[T]) CreateResource(ctx context.Context, resource T) (T, error) { + svc, err := s.WithScopePrefix(resource.GetScope()) + if err != nil { + var nul T + return nul, trace.Wrap(err) + } + return svc.CreateResource(ctx, resource) +} + +// UpsertResource upserts the given scoped resource. If the scope is empty, it +// will be inserted in the unscoped key range, else it will be inserted in the +// scoped key range. +func (s *ScopeAwareService[T]) UpsertResource(ctx context.Context, resource T) (T, error) { + svc, err := s.WithScopePrefix(resource.GetScope()) + if err != nil { + var nul T + return nul, trace.Wrap(err) + } + return svc.UpsertResource(ctx, resource) +} + +// UpdateResource updates the given scoped resource. If the scope is empty, it +// will be updated in the unscoped key range, else it will be updated in the +// scoped key range. +func (s *ScopeAwareService[T]) UpdateResource(ctx context.Context, resource T) (T, error) { + svc, err := s.WithScopePrefix(resource.GetScope()) + if err != nil { + var nul T + return nul, trace.Wrap(err) + } + return svc.UpdateResource(ctx, resource) +} + +// ConditionalUpdateResource updates the given scoped resource if the revision +// matches. If the scope is empty, it will be updated in the unscoped key +// range, else it will be updated in the scoped key range. +func (s *ScopeAwareService[T]) ConditionalUpdateResource(ctx context.Context, resource T) (T, error) { + svc, err := s.WithScopePrefix(resource.GetScope()) + if err != nil { + var nul T + return nul, trace.Wrap(err) + } + return svc.ConditionalUpdateResource(ctx, resource) +} + +// WithScopePrefix returns the unscoped service when scope is empty, otherwise +// returns the scoped service with the encoded scope appended to its backend prefix. +func (s *ScopeAwareService[T]) WithScopePrefix(scope string) (*Service[T], error) { + if scope == "" { + return s.UnscopedService, nil + } + encodedScope, err := scopes.EncodeForKey(scope) + if err != nil { + return nil, trace.Wrap(err) + } + return s.ScopedService.WithPrefix(encodedScope), nil +} + +// WithScopedResourcePrefix returns a [*Service] with a prefix for the given +// scope-qualified name appended to the backend prefix. +// +// If the given scope is empty, it will return the UnscopedService with the +// given name as an added prefix. +// +// If the given scope is non-empty, it will return the ScopedService with the +// encoded scope and the name as an added prefix. +// +// This may be appropriate for dependent resources keyed by a unique scoped +// resource, i.e. members of a scoped access list. +func (s *ScopeAwareService[T]) WithScopedResourcePrefix(scopedName scopes.QualifiedName) (*Service[T], error) { + if scopedName.Scope == "" { + return s.UnscopedService.WithPrefix(scopedName.Name), nil + } + encodedScope, err := scopes.EncodeForKey(scopedName.Scope) + if err != nil { + return nil, trace.Wrap(err) + } + return s.ScopedService.WithPrefix(encodedScope, scopedName.Name), nil +} diff --git a/lib/services/local/generic/scopeaware_test.go b/lib/services/local/generic/scopeaware_test.go new file mode 100644 index 00000000000..929454b0b94 --- /dev/null +++ b/lib/services/local/generic/scopeaware_test.go @@ -0,0 +1,265 @@ +// Teleport +// Copyright (C) 2026 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package generic + +import ( + "context" + "fmt" + "iter" + "maps" + "strings" + "testing" + + "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/api/utils/clientutils" + "github.com/gravitational/teleport/lib/backend" + "github.com/gravitational/teleport/lib/backend/memory" + "github.com/gravitational/teleport/lib/scopes" +) + +func newScopedTestResource(sqn scopes.QualifiedName) *testResource { + r := newTestResource(sqn.Name) + r.Scope = sqn.Scope + return r +} + +func TestScopeAwareService(t *testing.T) { + memBackend, err := memory.New(memory.Config{ + Context: t.Context(), + Clock: clockwork.NewFakeClock(), + }) + require.NoError(t, err) + + service, err := NewScopeAwareService(&ScopeAwareServiceConfig[*testResource]{ + Backend: memBackend, + ResourceKind: "generic resource", + UnscopedBackendPrefix: backend.NewKey("generic_prefix"), + ScopedBackendPrefix: backend.NewKey("scoped", "generic_prefix"), + PageLimit: 200, + UnmarshalFunc: unmarshalResource, + MarshalFunc: marshalResource, + }) + require.NoError(t, err) + + var resources []*testResource + + // Create some unscoped resources. + const numUnscopedResources = 10 + for nameIndex := range numUnscopedResources { + resources = append(resources, newTestResource(fmt.Sprintf("name%d", nameIndex))) + } + + // Add a bunch of scoped resources at various scopes. + for baseScopeIndex := range 10 { + // Add ten at this base scope. + for nameIndex := range 10 { + resources = append(resources, newScopedTestResource(scopes.QualifiedName{ + Scope: fmt.Sprintf("/base%d", baseScopeIndex), + Name: fmt.Sprintf("name%d", nameIndex), + })) + } + + // Add ten more at each of 10 sub scopes. + for subScopeIndex := range 10 { + for nameIndex := range 10 { + resources = append(resources, newScopedTestResource(scopes.QualifiedName{ + Scope: fmt.Sprintf("/base%d/sub%d", baseScopeIndex, subScopeIndex), + Name: fmt.Sprintf("name%d", nameIndex), + })) + } + } + } + + expectedResourceNames := make(map[scopes.QualifiedName]struct{}, len(resources)) + for _, resource := range resources { + expectedResourceNames[scopes.QualifiedName{ + Scope: resource.GetScope(), + Name: resource.GetName(), + }] = struct{}{} + } + + checkExpectedResources := func(t *testing.T, expectedResourceNames map[scopes.QualifiedName]struct{}, resources iter.Seq2[*testResource, error]) { + t.Helper() + + expected := maps.Clone(expectedResourceNames) + + for resource, err := range resources { + require.NoError(t, err) + + key := scopes.QualifiedName{ + Scope: resource.GetScope(), + Name: resource.GetName(), + } + + assert.Contains(t, expected, key, "found unexpected resource %v", key) + delete(expected, key) + } + assert.Empty(t, expected, "did not find expected resources") + } + + expectedResourcesInCursorRange := func(t *testing.T, startKey, endKey string) map[scopes.QualifiedName]struct{} { + t.Helper() + + expected := make(map[scopes.QualifiedName]struct{}) + for _, resource := range resources { + cursor, err := scopes.MakeResourceCursor(resource.GetScope(), resource.GetName()) + require.NoError(t, err) + if startKey != "" && cursor < startKey { + continue + } + if endKey != "" && cursor >= endKey { + continue + } + expected[scopes.QualifiedName{Scope: resource.GetScope(), Name: resource.GetName()}] = struct{}{} + } + return expected + } + + // Create all the resources. + for _, resource := range resources { + _, err := service.CreateResource(t.Context(), resource) + require.NoError(t, err) + } + + t.Run("Resources", func(t *testing.T) { + checkExpectedResources(t, expectedResourceNames, service.Resources(t.Context(), "", "")) + + foundScoped := false + for resource, err := range service.Resources(t.Context(), "", "") { + require.NoError(t, err) + if foundScoped && resource.GetScope() == "" { + t.Fatal("found unscoped resource after scoped resource") + } + foundScoped = foundScoped || resource.GetScope() != "" + } + + countResources := func(resources iter.Seq2[*testResource, error]) int { + t.Helper() + var count int + for _, err := range resources { + require.NoError(t, err) + count++ + } + return count + } + + // The scoped start cursor is the boundary between unscoped resources and + // scoped resources in the unified logical resource stream. + require.Equal(t, numUnscopedResources, + countResources(service.Resources(t.Context(), "", scopes.ResourceCursorScopedStart()))) + require.Equal(t, len(resources)-numUnscopedResources, + countResources(service.Resources(t.Context(), scopes.ResourceCursorScopedStart(), ""))) + + // Verify a range that starts in unscoped resources and ends in scoped + // resources, forcing Resources to concatenate both underlying services. + scopedEnd, err := scopes.MakeResourceCursor("/base1", "name0") + require.NoError(t, err) + checkExpectedResources(t, + expectedResourcesInCursorRange(t, "name5", scopedEnd), + service.Resources(t.Context(), "name5", scopedEnd), + ) + }) + + // Check all resources can be listed, at various page sizes. + for _, pageSize := range []int{0, 1, 3, 5, 10, 11, 99, 100, 101} { + t.Run(fmt.Sprintf("pageSize=%d", pageSize), func(t *testing.T) { + t.Run("unfiltered", func(t *testing.T) { + resourceIter := clientutils.ResourcesWithPageSize(t.Context(), service.ListResources, pageSize) + checkExpectedResources(t, expectedResourceNames, resourceIter) + }) + + t.Run("filtered", func(t *testing.T) { + iter := clientutils.ResourcesWithPageSize(t.Context(), func(ctx context.Context, pageSize int, nextPageToken string) ([]*testResource, string, error) { + return service.ListResourcesWithFilter(ctx, pageSize, nextPageToken, func(resource *testResource) bool { + return strings.HasPrefix(resource.GetScope(), "/base7/") + }) + }, pageSize) + count := 0 + for resource, err := range iter { + require.NoError(t, err) + require.True(t, strings.HasPrefix(resource.GetScope(), "/base7/")) + count++ + } + require.Equal(t, 100, count) + + }) + }) + + } + + // Check that unscoped resources sort before scoped resources. + foundScoped := false + for resource, err := range clientutils.Resources(t.Context(), service.ListResources) { + require.NoError(t, err) + scope := resource.GetScope() + if foundScoped && scope == "" { + t.Fatal("found unscoped resource after scoped resource") + } + foundScoped = foundScoped || scope != "" + } + + for resourceName := range expectedResourceNames { + // Get should work. + resource, err := service.GetResource(t.Context(), resourceName) + require.NoError(t, err) + + // Update should work. + resource.Spec.PropA = "updated" + resource, err = service.UpdateResource(t.Context(), resource) + require.NoError(t, err) + require.Equal(t, "updated", resource.Spec.PropA) + + // Try ConditionalUpdate with incorrect revision. + rev := resource.Metadata.Revision + resource.Metadata.Revision = "" + _, err = service.ConditionalUpdateResource(t.Context(), resource) + require.Error(t, err) + + // ConditionalUpdate with correct revision. + resource.Metadata.Revision = rev + resource.Spec.PropA = "conditional_updated" + resource, err = service.ConditionalUpdateResource(t.Context(), resource) + require.NoError(t, err) + require.Equal(t, "conditional_updated", resource.Spec.PropA) + + // Delete the resource and Get should fail. + err = service.DeleteResource(t.Context(), resourceName) + require.NoError(t, err) + _, err = service.GetResource(t.Context(), resourceName) + require.Error(t, err) + + // Upsert the resource. + resource.Spec.PropA = "upserted" + resource, err = service.UpsertResource(t.Context(), resource) + require.NoError(t, err) + require.Equal(t, "upserted", resource.Spec.PropA) + } + + // Make sure all the expected resources are still there. + resourceIter := clientutils.Resources(t.Context(), service.ListResources) + checkExpectedResources(t, expectedResourceNames, resourceIter) + + // Delete all resources and make sure they're gone. + err = service.DeleteAllResources(t.Context()) + require.NoError(t, err) + page, _, err := service.ListResources(t.Context(), 1, "") + require.NoError(t, err) + require.Empty(t, page) +}