diff --git a/apis/core/v1alpha1/iam_role_selector.go b/apis/core/v1alpha1/iam_role_selector.go new file mode 100644 index 0000000..32b34c3 --- /dev/null +++ b/apis/core/v1alpha1/iam_role_selector.go @@ -0,0 +1,59 @@ +// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"). You may +// not use this file except in compliance with the License. A copy of the +// License is located at +// +// http://aws.amazon.com/apache2.0/ +// +// or in the "license" file accompanying this file. This file is distributed +// on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the License for the specific language governing +// permissions and limitations under the License. + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// LabelSelector is a label query over a set of resources. +type LabelSelector struct { + MatchLabels map[string]string `json:"matchLabels"` +} + +// IAMRoleSelectorSpec defines the desired state of IAMRoleSelector +type NamespaceSelector struct { + Names []string `json:"name"` + LabelSelector LabelSelector `json:"labelSelector,omitempty"` +} + +type IAMRoleSelectorSpec struct { + ARN string `json:"arn"` + NamespaceSelector NamespaceSelector `json:"namespaceSelector,omitempty"` + ResourceTypeSelector []schema.GroupVersionKind `json:"resourceTypeSelector,omitempty"` +} + +type IAMRoleSelectorStatus struct{} + +// IAMRoleSelector is the schema for the IAMRoleSelector API. +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +type IAMRoleSelector struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec IAMRoleSelectorSpec `json:"spec,omitempty"` + Status IAMRoleSelectorStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true +type IAMRoleSelectorList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []IAMRoleSelector `json:"items"` +} + +func init() { + SchemeBuilder.Register(&IAMRoleSelector{}, &IAMRoleSelectorList{}) +} diff --git a/apis/core/v1alpha1/zz_generated.deepcopy.go b/apis/core/v1alpha1/zz_generated.deepcopy.go index 357535c..e6d5a23 100644 --- a/apis/core/v1alpha1/zz_generated.deepcopy.go +++ b/apis/core/v1alpha1/zz_generated.deepcopy.go @@ -20,6 +20,7 @@ package v1alpha1 import ( "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" ) // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. @@ -373,6 +374,144 @@ func (in *FieldExportTarget) DeepCopy() *FieldExportTarget { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IAMRoleSelector) DeepCopyInto(out *IAMRoleSelector) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + out.Status = in.Status +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IAMRoleSelector. +func (in *IAMRoleSelector) DeepCopy() *IAMRoleSelector { + if in == nil { + return nil + } + out := new(IAMRoleSelector) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *IAMRoleSelector) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IAMRoleSelectorList) DeepCopyInto(out *IAMRoleSelectorList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]IAMRoleSelector, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IAMRoleSelectorList. +func (in *IAMRoleSelectorList) DeepCopy() *IAMRoleSelectorList { + if in == nil { + return nil + } + out := new(IAMRoleSelectorList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *IAMRoleSelectorList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IAMRoleSelectorSpec) DeepCopyInto(out *IAMRoleSelectorSpec) { + *out = *in + in.NamespaceSelector.DeepCopyInto(&out.NamespaceSelector) + if in.ResourceTypeSelector != nil { + in, out := &in.ResourceTypeSelector, &out.ResourceTypeSelector + *out = make([]schema.GroupVersionKind, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IAMRoleSelectorSpec. +func (in *IAMRoleSelectorSpec) DeepCopy() *IAMRoleSelectorSpec { + if in == nil { + return nil + } + out := new(IAMRoleSelectorSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IAMRoleSelectorStatus) DeepCopyInto(out *IAMRoleSelectorStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IAMRoleSelectorStatus. +func (in *IAMRoleSelectorStatus) DeepCopy() *IAMRoleSelectorStatus { + if in == nil { + return nil + } + out := new(IAMRoleSelectorStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LabelSelector) DeepCopyInto(out *LabelSelector) { + *out = *in + if in.MatchLabels != nil { + in, out := &in.MatchLabels, &out.MatchLabels + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LabelSelector. +func (in *LabelSelector) DeepCopy() *LabelSelector { + if in == nil { + return nil + } + out := new(LabelSelector) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NamespaceSelector) DeepCopyInto(out *NamespaceSelector) { + *out = *in + if in.Names != nil { + in, out := &in.Names, &out.Names + *out = make([]string, len(*in)) + copy(*out, *in) + } + in.LabelSelector.DeepCopyInto(&out.LabelSelector) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamespaceSelector. +func (in *NamespaceSelector) DeepCopy() *NamespaceSelector { + if in == nil { + return nil + } + out := new(NamespaceSelector) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *NamespacedResource) DeepCopyInto(out *NamespacedResource) { *out = *in diff --git a/config/crd/bases/services.k8s.aws_iamroleselectors.yaml b/config/crd/bases/services.k8s.aws_iamroleselectors.yaml new file mode 100644 index 0000000..5fa657a --- /dev/null +++ b/config/crd/bases/services.k8s.aws_iamroleselectors.yaml @@ -0,0 +1,79 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.2 + name: iamroleselectors.services.k8s.aws +spec: + group: services.k8s.aws + names: + kind: IAMRoleSelector + listKind: IAMRoleSelectorList + plural: iamroleselectors + singular: iamroleselector + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: IAMRoleSelector is the schema for the IAMRoleSelector API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + arn: + type: string + namespaceSelector: + description: IAMRoleSelectorSpec defines the desired state of IAMRoleSelector + properties: + labelSelector: + description: LabelSelector is a label query over a set of resources. + properties: + matchLabels: + additionalProperties: + type: string + type: object + required: + - matchLabels + type: object + name: + items: + type: string + type: array + required: + - name + type: object + resourceTypeSelector: + items: + description: |- + GroupVersionKind unambiguously identifies a kind. It doesn't anonymously include GroupVersion + to avoid automatic coercion. It doesn't use a GroupVersion to avoid custom marshalling + type: object + type: array + required: + - arn + type: object + status: + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 96349f6..7f51262 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -3,5 +3,6 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: + - bases/services.k8s.aws_iamroleselectors.yaml - bases/services.k8s.aws_adoptedresources.yaml - bases/services.k8s.aws_fieldexports.yaml diff --git a/pkg/config/config.go b/pkg/config/config.go index c22ed44..7b4ff98 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -380,6 +380,11 @@ func (cfg *Config) Validate(ctx context.Context, options ...Option) error { return fmt.Errorf("error overriding feature gates: %v", err) } + // IAMRolerSelector cannotbe used with enable-carm=true + if cfg.FeatureGates.IsEnabled(featuregate.IAMRoleSelector) && cfg.EnableCARM { + return fmt.Errorf("cannot enable feature gate '%s' when flag '%s' is set to true", featuregate.IAMRoleSelector, flagEnableCARM) + } + return nil } diff --git a/pkg/featuregate/features.go b/pkg/featuregate/features.go index acfe1b1..a84c8d2 100644 --- a/pkg/featuregate/features.go +++ b/pkg/featuregate/features.go @@ -31,6 +31,9 @@ const ( // ServiceLevelCARM is a feature gate for enabling CARM for service-level resources. ServiceLevelCARM = "ServiceLevelCARM" + + // IAMRoleSelector is a feature gate for enabling the IAMRoleSelector feature and reconciler. + IAMRoleSelector = "IAMRoleSelector" ) // defaultACKFeatureGates is a map of feature names to Feature structs @@ -40,6 +43,7 @@ var defaultACKFeatureGates = FeatureGates{ ReadOnlyResources: {Stage: Beta, Enabled: true}, TeamLevelCARM: {Stage: Alpha, Enabled: false}, ServiceLevelCARM: {Stage: Alpha, Enabled: false}, + IAMRoleSelector: {Stage: Alpha, Enabled: false}, } // FeatureStage represents the development stage of a feature. diff --git a/pkg/runtime/adoption_reconciler.go b/pkg/runtime/adoption_reconciler.go index 279de76..d823dfe 100644 --- a/pkg/runtime/adoption_reconciler.go +++ b/pkg/runtime/adoption_reconciler.go @@ -466,7 +466,7 @@ func (r *adoptionReconciler) getOwnerAccountID( ) (ackv1alpha1.AWSAccountID, bool) { // look for owner account id in the namespace annotations namespace := res.GetNamespace() - accID, ok := r.cache.Namespaces.GetOwnerAccountID(namespace) + accID, ok := r.carmCache.Namespaces.GetOwnerAccountID(namespace) if ok { return ackv1alpha1.AWSAccountID(accID), true } @@ -481,7 +481,7 @@ func (r *adoptionReconciler) getTeamID( ) ackv1alpha1.TeamID { // look for team id in the namespace annotations namespace := res.GetNamespace() - teamID, ok := r.cache.Namespaces.GetTeamID(namespace) + teamID, ok := r.carmCache.Namespaces.GetTeamID(namespace) if ok { return ackv1alpha1.TeamID(teamID) } @@ -497,7 +497,7 @@ func (r *adoptionReconciler) getEndpointURL( ) string { // look for endpoint url in the namespace annotations namespace := res.GetNamespace() - endpointURL, ok := r.cache.Namespaces.GetEndpointURL(namespace) + endpointURL, ok := r.carmCache.Namespaces.GetEndpointURL(namespace) if ok { return endpointURL } @@ -512,9 +512,9 @@ func (r *adoptionReconciler) getRoleARN(id string, cacheName string) (ackv1alpha var cache *ackrtcache.CARMMap switch cacheName { case ackrtcache.ACKRoleTeamMap: - cache = r.cache.Teams + cache = r.carmCache.Teams case ackrtcache.ACKRoleAccountMap: - cache = r.cache.Accounts + cache = r.carmCache.Accounts default: return "", fmt.Errorf("invalid cache name: %s", cacheName) } @@ -552,7 +552,7 @@ func (r *adoptionReconciler) getRegion( // look for default region in namespace metadata annotations ns := res.GetNamespace() - defaultRegion, ok := r.cache.Namespaces.GetDefaultRegion(ns) + defaultRegion, ok := r.carmCache.Namespaces.GetDefaultRegion(ns) if ok { return ackv1alpha1.AWSRegion(defaultRegion) } @@ -623,7 +623,7 @@ func NewAdoptionReconcilerWithClient( log: log.WithName("adopted-reconciler"), cfg: cfg, metrics: metrics, - cache: cache, + carmCache: cache, kc: kc, apiReader: apiReader, }, diff --git a/pkg/runtime/field_export_reconciler.go b/pkg/runtime/field_export_reconciler.go index 19e8ebd..46ff270 100644 --- a/pkg/runtime/field_export_reconciler.go +++ b/pkg/runtime/field_export_reconciler.go @@ -739,7 +739,7 @@ func NewFieldExportReconcilerWithClient( log: log.WithName("field-export-reconciler"), cfg: cfg, metrics: metrics, - cache: cache, + carmCache: cache, kc: kc, apiReader: apiReader, }, @@ -768,7 +768,7 @@ func NewFieldExportResourceReconcilerWithClient( log: log.WithName("field-export-reconciler"), cfg: cfg, metrics: metrics, - cache: cache, + carmCache: cache, kc: kc, apiReader: apiReader, }, diff --git a/pkg/runtime/iamroleselector/cache.go b/pkg/runtime/iamroleselector/cache.go new file mode 100644 index 0000000..5f08ae4 --- /dev/null +++ b/pkg/runtime/iamroleselector/cache.go @@ -0,0 +1,219 @@ +// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"). You may +// not use this file except in compliance with the License. A copy of the +// License is located at +// +// http://aws.amazon.com/apache2.0/ +// +// or in the "license" file accompanying this file. This file is distributed +// on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the License for the specific language governing +// permissions and limitations under the License. + +package iamroleselector + +import ( + "fmt" + "sync" + + "github.com/go-logr/logr" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/dynamic/dynamicinformer" + "k8s.io/client-go/tools/cache" + + ackv1alpha1 "github.com/aws-controllers-k8s/runtime/apis/core/v1alpha1" +) + +// Cache wraps the informer for IAMRoleSelector resources +type Cache struct { + sync.RWMutex + log logr.Logger + informer cache.SharedIndexInformer + selectors map[string]*ackv1alpha1.IAMRoleSelector // name -> selector +} + +// NewCache creates a new IAMRoleSelector cache +func NewCache(log logr.Logger) *Cache { + return &Cache{ + log: log.WithName("cache.iam-role-selector"), + selectors: make(map[string]*ackv1alpha1.IAMRoleSelector), + } +} + +// Run starts the cache and blocks until stopCh is closed +func (c *Cache) Run(client dynamic.Interface, stopCh <-chan struct{}) { + c.log.V(1).Info("Starting IAMRoleSelector cache") + + // Create dynamic informer factory + factory := dynamicinformer.NewDynamicSharedInformerFactory(client, 0) + + gvr := schema.GroupVersionResource{ + Group: "services.k8s.aws", + Version: "v1alpha1", + Resource: "iamroleselectors", + } + + c.informer = factory.ForResource(gvr).Informer() + + // Add event handlers that update our internal map + c.informer.AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { + c.handleAdd(obj) + }, + UpdateFunc: func(oldObj, newObj interface{}) { + c.handleUpdate(oldObj, newObj) + }, + DeleteFunc: func(obj interface{}) { + c.handleDelete(obj) + }, + }) + + factory.Start(stopCh) +} + +func (c *Cache) handleAdd(obj interface{}) { + u := obj.(*unstructured.Unstructured) + selector := &ackv1alpha1.IAMRoleSelector{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, selector); err != nil { + c.log.Error(err, "failed to convert object", "name", u.GetName()) + return + } + + // Validate before storing + if err := validateSelector(selector); err != nil { + c.log.Error(err, "invalid IAMRoleSelector, not caching", "name", selector.Name) + return + } + + c.Lock() + c.selectors[selector.Name] = selector + c.Unlock() + + c.log.V(1).Info("cached IAMRoleSelector", "name", selector.Name) +} + +func (c *Cache) handleUpdate(_, newObj interface{}) { + u := newObj.(*unstructured.Unstructured) + selector := &ackv1alpha1.IAMRoleSelector{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, selector); err != nil { + c.log.Error(err, "failed to convert object", "name", u.GetName()) + return + } + + // Validate before storing + if err := validateSelector(selector); err != nil { + c.log.Error(err, "invalid IAMRoleSelector, removing from cache", "name", selector.Name) + // Remove from cache if it becomes invalid + c.Lock() + delete(c.selectors, selector.Name) + c.Unlock() + return + } + + c.Lock() + c.selectors[selector.Name] = selector + c.Unlock() + + c.log.V(1).Info("updated IAMRoleSelector", "name", selector.Name) +} + +func (c *Cache) handleDelete(obj interface{}) { + u := obj.(*unstructured.Unstructured) + name := u.GetName() + + c.Lock() + delete(c.selectors, name) + c.Unlock() + + c.log.V(1).Info("removed IAMRoleSelector from cache", "name", name) +} + +// HasSynced returns true if the cache has synced +func (c *Cache) HasSynced() bool { + if c.informer == nil { + return false + } + return c.informer.HasSynced() +} + +// GetMatchingSelectors returns the list of IAMRoleSelectors that match the given context +func (c *Cache) GetMatchingSelectors( + namespace string, + namespaceLabels map[string]string, + gvk schema.GroupVersionKind, +) ([]*ackv1alpha1.IAMRoleSelector, error) { + if c.informer == nil { + return nil, fmt.Errorf("cache not initialized") + } + + ctx := MatchContext{ + Namespace: namespace, + NamespaceLabels: namespaceLabels, + GVK: gvk, + } + + c.RLock() + defer c.RUnlock() + + var matches []*ackv1alpha1.IAMRoleSelector + for _, selector := range c.selectors { + if Matches(selector, ctx) { + // Return a copy to avoid mutations + matches = append(matches, selector.DeepCopy()) + } + } + + return matches, nil +} + +// GetSelector returns a specific selector by name (useful for testing/debugging) +func (c *Cache) GetSelector(name string) (*ackv1alpha1.IAMRoleSelector, bool) { + c.RLock() + defer c.RUnlock() + + selector, ok := c.selectors[name] + if !ok { + return nil, false + } + return selector.DeepCopy(), true +} + +// ListSelectors returns all valid selectors in the cache +func (c *Cache) ListSelectors() []*ackv1alpha1.IAMRoleSelector { + c.RLock() + defer c.RUnlock() + + selectors := make([]*ackv1alpha1.IAMRoleSelector, 0, len(c.selectors)) + for _, selector := range c.selectors { + selectors = append(selectors, selector.DeepCopy()) + } + return selectors +} + +// Matches returns a list of IAMRoleSelectors that match the given resource. This function +// should only be called after the cache has been started and synced. +func (c *Cache) Matches(resource runtime.Object) ([]*ackv1alpha1.IAMRoleSelector, error) { + // Extract metadata from the resource + metaObj, err := meta.Accessor(resource) + if err != nil { + return nil, fmt.Errorf("failed to get metadata from resource: %w", err) + } + + namespace := metaObj.GetNamespace() + + // Get GVK - should be set on ACK resources + gvk := resource.GetObjectKind().GroupVersionKind() + if gvk.Empty() { + // maybe panic? + panic("GVK not set on resource") + } + + // TODO: get namespace labels from a namespace lister/cache + // For now, pass empty namespace labels + return c.GetMatchingSelectors(namespace, nil, gvk) +} diff --git a/pkg/runtime/iamroleselector/cache_test.go b/pkg/runtime/iamroleselector/cache_test.go new file mode 100644 index 0000000..5a92070 --- /dev/null +++ b/pkg/runtime/iamroleselector/cache_test.go @@ -0,0 +1,300 @@ +// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"). You may +// not use this file except in compliance with the License. A copy of the +// License is located at +// +// http://aws.amazon.com/apache2.0/ +// +// or in the "license" file accompanying this file. This file is distributed +// on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the License for the specific language governing +// permissions and limitations under the License. + +package iamroleselector + +import ( + "testing" + "time" + + "github.com/go-logr/zapr" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/dynamic/fake" + k8stesting "k8s.io/client-go/testing" + + ackv1alpha1 "github.com/aws-controllers-k8s/runtime/apis/core/v1alpha1" +) + +var ( + testGVR = schema.GroupVersionResource{ + Group: "services.k8s.aws", + Version: "v1alpha1", + Resource: "iamroleselectors", + } +) + +// TestCache_Matches tests the top-level Matches function +func TestCache_Matches(t *testing.T) { + // Setup with proper list kind mapping + scheme := runtime.NewScheme() + watcher := watch.NewFake() + + // Create fake client with list kind mapping + gvrToListKind := map[schema.GroupVersionResource]string{ + testGVR: "IAMRoleSelectorList", + } + client := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, gvrToListKind) + client.PrependWatchReactor("iamroleselectors", k8stesting.DefaultWatchReactor(watcher, nil)) + + logger := zapr.NewLogger(zap.NewNop()) + cache := NewCache(logger) + + stopCh := make(chan struct{}) + t.Cleanup(func() { close(stopCh) }) + + go cache.Run(client, stopCh) + + // Wait for cache to sync + require.Eventually(t, func() bool { + return cache.HasSynced() + }, 5*time.Second, 10*time.Millisecond) + + // Create test selectors + selector1 := createSelector("prod-s3", ackv1alpha1.IAMRoleSelector{ + ObjectMeta: metav1.ObjectMeta{Name: "prod-s3"}, + Spec: ackv1alpha1.IAMRoleSelectorSpec{ + ARN: "arn:aws:iam::123456789012:role/prod-s3-role", + NamespaceSelector: ackv1alpha1.NamespaceSelector{ + Names: []string{"production"}, + }, + ResourceTypeSelector: []schema.GroupVersionKind{ + {Kind: "Bucket"}, + }, + }, + }) + + selector2 := createSelector("all-rds", ackv1alpha1.IAMRoleSelector{ + ObjectMeta: metav1.ObjectMeta{Name: "all-rds"}, + Spec: ackv1alpha1.IAMRoleSelectorSpec{ + ARN: "arn:aws:iam::123456789012:role/rds-role", + ResourceTypeSelector: []schema.GroupVersionKind{ + { + Group: "rds.services.k8s.aws", + Kind: "DBInstance", + }, + }, + }, + }) + + selector3 := createSelector("label-based", ackv1alpha1.IAMRoleSelector{ + ObjectMeta: metav1.ObjectMeta{Name: "label-based"}, + Spec: ackv1alpha1.IAMRoleSelectorSpec{ + ARN: "arn:aws:iam::123456789012:role/team-role", + NamespaceSelector: ackv1alpha1.NamespaceSelector{ + LabelSelector: ackv1alpha1.LabelSelector{ + MatchLabels: map[string]string{ + "team": "platform", + }, + }, + }, + }, + }) + + // Simulate adding selectors via watcher + watcher.Add(selector1) + watcher.Add(selector2) + watcher.Add(selector3) + + // Wait for cache to process + time.Sleep(100 * time.Millisecond) + + // Test cases + tests := []struct { + name string + resource runtime.Object + wantCount int + wantARNs []string + }{ + { + name: "matches production S3 bucket", + resource: mockResource("production", "s3.services.k8s.aws", "v1alpha1", "Bucket"), + wantCount: 1, + wantARNs: []string{"arn:aws:iam::123456789012:role/prod-s3-role"}, + }, + { + name: "matches RDS in any namespace", + resource: mockResource("default", "rds.services.k8s.aws", "v1alpha1", "DBInstance"), + wantCount: 1, + wantARNs: []string{"arn:aws:iam::123456789012:role/rds-role"}, + }, + { + name: "no match for wrong namespace", + resource: mockResource("development", "s3.services.k8s.aws", "v1alpha1", "Bucket"), + wantCount: 0, + }, + { + name: "no match for wrong resource type", + resource: mockResource("production", "dynamodb.services.k8s.aws", "v1alpha1", "Table"), + wantCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + matches, err := cache.Matches(tt.resource) + require.NoError(t, err) + require.Len(t, matches, tt.wantCount) + + for i, wantARN := range tt.wantARNs { + require.Equal(t, wantARN, matches[i].Spec.ARN) + } + }) + } + + // Test invalid selector handling + t.Run("invalid selector not cached", func(t *testing.T) { + invalidSelector := createSelector("invalid", ackv1alpha1.IAMRoleSelector{ + ObjectMeta: metav1.ObjectMeta{Name: "invalid"}, + Spec: ackv1alpha1.IAMRoleSelectorSpec{ + ARN: "", // Invalid - empty ARN + }, + }) + + watcher.Add(invalidSelector) + time.Sleep(100 * time.Millisecond) + + // Should not be in cache + _, found := cache.GetSelector("invalid") + require.False(t, found) + }) + + // Test update to invalid + t.Run("update valid to invalid removes from cache", func(t *testing.T) { + validSelector := createSelector("update-test", ackv1alpha1.IAMRoleSelector{ + ObjectMeta: metav1.ObjectMeta{Name: "update-test"}, + Spec: ackv1alpha1.IAMRoleSelectorSpec{ + ARN: "arn:aws:iam::123456789012:role/test-role", + }, + }) + + watcher.Add(validSelector) + time.Sleep(100 * time.Millisecond) + + // Should be cached + _, found := cache.GetSelector("update-test") + require.True(t, found) + + // Update to invalid + invalidUpdate := createSelector("update-test", ackv1alpha1.IAMRoleSelector{ + ObjectMeta: metav1.ObjectMeta{Name: "update-test"}, + Spec: ackv1alpha1.IAMRoleSelectorSpec{ + ARN: "not-an-arn", // Invalid ARN format + }, + }) + + watcher.Modify(invalidUpdate) + time.Sleep(100 * time.Millisecond) + + // Should be removed + _, found = cache.GetSelector("update-test") + require.False(t, found) + }) + + // Test deletion + t.Run("delete removes from cache", func(t *testing.T) { + watcher.Delete(selector1) + time.Sleep(100 * time.Millisecond) + + _, found := cache.GetSelector("prod-s3") + require.False(t, found) + }) +} + +// Helper functions + +func createSelector(name string, selector ackv1alpha1.IAMRoleSelector) *unstructured.Unstructured { + obj, _ := runtime.DefaultUnstructuredConverter.ToUnstructured(&selector) + u := &unstructured.Unstructured{Object: obj} + u.SetAPIVersion("services.k8s.aws/v1alpha1") + u.SetKind("IAMRoleSelector") + u.SetName(name) + return u +} + +func mockResource(namespace, group, version, kind string) runtime.Object { + return &testResource{ + namespace: namespace, + gvk: schema.GroupVersionKind{ + Group: group, + Version: version, + Kind: kind, + }, + } +} + +// Minimal test resource implementation +type testResource struct { + namespace string + gvk schema.GroupVersionKind +} + +func (r *testResource) GetObjectKind() schema.ObjectKind { + return &testObjectKind{gvk: r.gvk} +} + +func (r *testResource) DeepCopyObject() runtime.Object { + return r +} + +func (r *testResource) GetNamespace() string { + return r.namespace +} + +func (r *testResource) SetNamespace(string) {} +func (r *testResource) GetName() string { return "test" } +func (r *testResource) SetName(string) {} +func (r *testResource) GetGenerateName() string { return "" } +func (r *testResource) SetGenerateName(string) {} +func (r *testResource) GetUID() types.UID { return "test-uid" } +func (r *testResource) SetUID(types.UID) {} +func (r *testResource) GetResourceVersion() string { return "1" } +func (r *testResource) SetResourceVersion(string) {} +func (r *testResource) GetGeneration() int64 { return 1 } +func (r *testResource) SetGeneration(int64) {} +func (r *testResource) GetSelfLink() string { return "" } +func (r *testResource) SetSelfLink(string) {} +func (r *testResource) GetCreationTimestamp() metav1.Time { return metav1.Time{} } +func (r *testResource) SetCreationTimestamp(metav1.Time) {} +func (r *testResource) GetDeletionTimestamp() *metav1.Time { return nil } +func (r *testResource) SetDeletionTimestamp(*metav1.Time) {} +func (r *testResource) GetDeletionGracePeriodSeconds() *int64 { return nil } +func (r *testResource) SetDeletionGracePeriodSeconds(*int64) {} +func (r *testResource) GetLabels() map[string]string { return nil } +func (r *testResource) SetLabels(map[string]string) {} +func (r *testResource) GetAnnotations() map[string]string { return nil } +func (r *testResource) SetAnnotations(map[string]string) {} +func (r *testResource) GetFinalizers() []string { return nil } +func (r *testResource) SetFinalizers([]string) {} +func (r *testResource) GetOwnerReferences() []metav1.OwnerReference { return nil } +func (r *testResource) SetOwnerReferences([]metav1.OwnerReference) {} +func (r *testResource) GetManagedFields() []metav1.ManagedFieldsEntry { return nil } +func (r *testResource) SetManagedFields([]metav1.ManagedFieldsEntry) {} + +type testObjectKind struct { + gvk schema.GroupVersionKind +} + +func (o *testObjectKind) SetGroupVersionKind(gvk schema.GroupVersionKind) { + o.gvk = gvk +} + +func (o *testObjectKind) GroupVersionKind() schema.GroupVersionKind { + return o.gvk +} diff --git a/pkg/runtime/iamroleselector/matcher.go b/pkg/runtime/iamroleselector/matcher.go new file mode 100644 index 0000000..b5517e3 --- /dev/null +++ b/pkg/runtime/iamroleselector/matcher.go @@ -0,0 +1,174 @@ +// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"). You may +// not use this file except in compliance with the License. A copy of the +// License is located at +// +// http://aws.amazon.com/apache2.0/ +// +// or in the "license" file accompanying this file. This file is distributed +// on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the License for the specific language governing +// permissions and limitations under the License. + +package iamroleselector + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime/schema" + + ackv1alpha1 "github.com/aws-controllers-k8s/runtime/apis/core/v1alpha1" + "github.com/aws/aws-sdk-go-v2/aws/arn" +) + +// MatchContext contains the attributes to match against an IAMRoleSelector +type MatchContext struct { + Namespace string + NamespaceLabels map[string]string + GVK schema.GroupVersionKind +} + +// Matches checks if a selector matches the given context +// Rules: AND between different field types, OR within arrays +func Matches(selector *ackv1alpha1.IAMRoleSelector, ctx MatchContext) bool { + // All conditions must match (AND logic between different selectors) + return matchesNamespace(selector.Spec.NamespaceSelector, ctx.Namespace, ctx.NamespaceLabels) && + matchesResourceType(selector.Spec.ResourceTypeSelector, ctx.GVK) +} + +// matchesNamespace checks if the namespace selector matches the given namespace and its labels +func matchesNamespace(nsSelector ackv1alpha1.NamespaceSelector, namespace string, namespaceLabels map[string]string) bool { + // If no namespace selector specified, matches all namespaces + if len(nsSelector.Names) == 0 && len(nsSelector.LabelSelector.MatchLabels) == 0 { + return true + } + + // Check if namespace name matches (OR within the names array) + nameMatches := false + if len(nsSelector.Names) > 0 { + for _, ns := range nsSelector.Names { + if ns == namespace { + nameMatches = true + break + } + } + // If names are specified but none match, and we have label selectors, + // the namespace must be in the names list + if !nameMatches { + return false + } + } + + // Check label selector (AND with name match) + if len(nsSelector.LabelSelector.MatchLabels) > 0 { + labelSelector := labels.SelectorFromSet(nsSelector.LabelSelector.MatchLabels) + if !labelSelector.Matches(labels.Set(namespaceLabels)) { + return false + } + } + + // If we get here: + // - Either no names were specified, or the namespace is in the names list + // - Either no labels were specified, or the labels match + return true +} + +func matchesResourceType(rtSelectors []schema.GroupVersionKind, gvk schema.GroupVersionKind) bool { + // If no resource type selector specified, matches all resources + if len(rtSelectors) == 0 { + return true + } + + // OR within the array - any selector can match + for _, rts := range rtSelectors { + groupMatches := rts.Group == "" || rts.Group == gvk.Group + versionMatches := rts.Version == "" || rts.Version == gvk.Version + kindMatches := rts.Kind == "" || rts.Kind == gvk.Kind + + // All specified fields must match (AND logic) + if groupMatches && versionMatches && kindMatches { + return true + } + } + + // If we get here, no selectors matched + return false +} + +// validateSelector checks if an IAMRoleSelector is valid +func validateSelector(selector *ackv1alpha1.IAMRoleSelector) error { + if selector == nil { + return fmt.Errorf("selector cannot be nil") + } + + if selector.Spec.ARN == "" { + return fmt.Errorf("ARN cannot be empty") + } + + // parse ARN to ensure it's valid + if _, err := arn.Parse(selector.Spec.ARN); err != nil { + return fmt.Errorf("invalid ARN: %w", err) + } + + // Validate namespace selector + if err := validateNamespaceSelector(selector.Spec.NamespaceSelector); err != nil { + return fmt.Errorf("invalid namespace selector: %w", err) + } + + // Validate resource type selectors + if err := validateResourceTypeSelectors(selector.Spec.ResourceTypeSelector); err != nil { + return fmt.Errorf("invalid resource type selector: %w", err) + } + + return nil +} + +func validateNamespaceSelector(nsSelector ackv1alpha1.NamespaceSelector) error { + // Check for duplicate namespace names + seen := make(map[string]bool) + for _, name := range nsSelector.Names { + if name == "" { + return fmt.Errorf("namespace name cannot be empty") + } + if seen[name] { + return fmt.Errorf("duplicate namespace name: %s", name) + } + seen[name] = true + } + + // Validate label selector + if len(nsSelector.LabelSelector.MatchLabels) > 0 { + for key := range nsSelector.LabelSelector.MatchLabels { + if key == "" { + return fmt.Errorf("label key cannot be empty") + } + // Kubernetes label values can be empty, so we don't validate value + } + } + + return nil +} + +// validateResourceTypeSelectors checks that each resource type selector has at least one field specified +// and that there are no duplicate selectors +func validateResourceTypeSelectors(rtSelectors []schema.GroupVersionKind) error { + seen := make(map[string]bool) + + for i, rts := range rtSelectors { + // at least one field must be specified + if rts.Group == "" && rts.Version == "" && rts.Kind == "" { + return fmt.Errorf("at least one of group, version, or kind must be specified at index %d", i) + } + + // check for duplicates + key := fmt.Sprintf("%s/%s/%s", rts.Group, rts.Version, rts.Kind) + if seen[key] { + return fmt.Errorf("duplicate resource type selector: %s", key) + } + seen[key] = true + } + + return nil +} diff --git a/pkg/runtime/iamroleselector/matcher_test.go b/pkg/runtime/iamroleselector/matcher_test.go new file mode 100644 index 0000000..13e2e53 --- /dev/null +++ b/pkg/runtime/iamroleselector/matcher_test.go @@ -0,0 +1,831 @@ +// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"). You may +// not use this file except in compliance with the License. A copy of the +// License is located at +// +// http://aws.amazon.com/apache2.0/ +// +// or in the "license" file accompanying this file. This file is distributed +// on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the License for the specific language governing +// permissions and limitations under the License. + +package iamroleselector + +import ( + "testing" + + "k8s.io/apimachinery/pkg/runtime/schema" + + ackv1alpha1 "github.com/aws-controllers-k8s/runtime/apis/core/v1alpha1" +) + +func TestMatches(t *testing.T) { + tests := []struct { + name string + selector *ackv1alpha1.IAMRoleSelector + ctx MatchContext + want bool + }{ + { + name: "empty selector matches everything", + selector: &ackv1alpha1.IAMRoleSelector{ + Spec: ackv1alpha1.IAMRoleSelectorSpec{ + ARN: "arn:aws:iam::123456789012:role/test-role", + }, + }, + ctx: MatchContext{ + Namespace: "default", + GVK: schema.GroupVersionKind{ + Group: "s3.services.k8s.aws", + Version: "v1alpha1", + Kind: "Bucket", + }, + }, + want: true, + }, + { + name: "matches specific namespace by name", + selector: &ackv1alpha1.IAMRoleSelector{ + Spec: ackv1alpha1.IAMRoleSelectorSpec{ + ARN: "arn:aws:iam::123456789012:role/test-role", + NamespaceSelector: ackv1alpha1.NamespaceSelector{ + Names: []string{"production", "staging"}, + }, + }, + }, + ctx: MatchContext{ + Namespace: "production", + GVK: schema.GroupVersionKind{ + Group: "s3.services.k8s.aws", + Version: "v1alpha1", + Kind: "Bucket", + }, + }, + want: true, + }, + { + name: "does not match wrong namespace", + selector: &ackv1alpha1.IAMRoleSelector{ + Spec: ackv1alpha1.IAMRoleSelectorSpec{ + ARN: "arn:aws:iam::123456789012:role/test-role", + NamespaceSelector: ackv1alpha1.NamespaceSelector{ + Names: []string{"production", "staging"}, + }, + }, + }, + ctx: MatchContext{ + Namespace: "development", + GVK: schema.GroupVersionKind{ + Group: "s3.services.k8s.aws", + Version: "v1alpha1", + Kind: "Bucket", + }, + }, + want: false, + }, + { + name: "matches namespace by labels", + selector: &ackv1alpha1.IAMRoleSelector{ + Spec: ackv1alpha1.IAMRoleSelectorSpec{ + ARN: "arn:aws:iam::123456789012:role/test-role", + NamespaceSelector: ackv1alpha1.NamespaceSelector{ + LabelSelector: ackv1alpha1.LabelSelector{ + MatchLabels: map[string]string{ + "env": "prod", + "team": "platform", + }, + }, + }, + }, + }, + ctx: MatchContext{ + Namespace: "any-namespace", + NamespaceLabels: map[string]string{ + "env": "prod", + "team": "platform", + "foo": "bar", // extra labels should be ignored + }, + }, + want: true, + }, + { + name: "does not match wrong namespace labels", + selector: &ackv1alpha1.IAMRoleSelector{ + Spec: ackv1alpha1.IAMRoleSelectorSpec{ + ARN: "arn:aws:iam::123456789012:role/test-role", + NamespaceSelector: ackv1alpha1.NamespaceSelector{ + LabelSelector: ackv1alpha1.LabelSelector{ + MatchLabels: map[string]string{ + "env": "prod", + }, + }, + }, + }, + }, + ctx: MatchContext{ + Namespace: "any-namespace", + NamespaceLabels: map[string]string{ + "env": "dev", + }, + }, + want: false, + }, + { + name: "matches namespace by name AND labels", + selector: &ackv1alpha1.IAMRoleSelector{ + Spec: ackv1alpha1.IAMRoleSelectorSpec{ + ARN: "arn:aws:iam::123456789012:role/test-role", + NamespaceSelector: ackv1alpha1.NamespaceSelector{ + Names: []string{"production"}, + LabelSelector: ackv1alpha1.LabelSelector{ + MatchLabels: map[string]string{ + "env": "prod", + }, + }, + }, + }, + }, + ctx: MatchContext{ + Namespace: "production", + NamespaceLabels: map[string]string{ + "env": "prod", + }, + }, + want: true, + }, + { + name: "does not match if namespace name matches but labels don't", + selector: &ackv1alpha1.IAMRoleSelector{ + Spec: ackv1alpha1.IAMRoleSelectorSpec{ + ARN: "arn:aws:iam::123456789012:role/test-role", + NamespaceSelector: ackv1alpha1.NamespaceSelector{ + Names: []string{"production"}, + LabelSelector: ackv1alpha1.LabelSelector{ + MatchLabels: map[string]string{ + "env": "prod", + }, + }, + }, + }, + }, + ctx: MatchContext{ + Namespace: "production", + NamespaceLabels: map[string]string{ + "env": "dev", // wrong label value + }, + }, + want: false, + }, + { + name: "matches resource type by exact GVK", + selector: &ackv1alpha1.IAMRoleSelector{ + Spec: ackv1alpha1.IAMRoleSelectorSpec{ + ARN: "arn:aws:iam::123456789012:role/test-role", + ResourceTypeSelector: []schema.GroupVersionKind{ + { + Group: "s3.services.k8s.aws", + Version: "v1alpha1", + Kind: "Bucket", + }, + }, + }, + }, + ctx: MatchContext{ + Namespace: "default", + GVK: schema.GroupVersionKind{ + Group: "s3.services.k8s.aws", + Version: "v1alpha1", + Kind: "Bucket", + }, + }, + want: true, + }, + { + name: "matches resource type by partial GVK (only kind)", + selector: &ackv1alpha1.IAMRoleSelector{ + Spec: ackv1alpha1.IAMRoleSelectorSpec{ + ARN: "arn:aws:iam::123456789012:role/test-role", + ResourceTypeSelector: []schema.GroupVersionKind{ + { + Kind: "Bucket", + }, + }, + }, + }, + ctx: MatchContext{ + Namespace: "default", + GVK: schema.GroupVersionKind{ + Group: "s3.services.k8s.aws", + Version: "v1alpha1", + Kind: "Bucket", + }, + }, + want: true, + }, + { + name: "matches resource type with OR logic (multiple selectors)", + selector: &ackv1alpha1.IAMRoleSelector{ + Spec: ackv1alpha1.IAMRoleSelectorSpec{ + ARN: "arn:aws:iam::123456789012:role/test-role", + ResourceTypeSelector: []schema.GroupVersionKind{ + { + Group: "rds.services.k8s.aws", + Version: "v1alpha1", + Kind: "DBInstance", + }, + { + Group: "s3.services.k8s.aws", + Version: "v1alpha1", + Kind: "Bucket", + }, + }, + }, + }, + ctx: MatchContext{ + Namespace: "default", + GVK: schema.GroupVersionKind{ + Group: "s3.services.k8s.aws", + Version: "v1alpha1", + Kind: "Bucket", + }, + }, + want: true, + }, + { + name: "does not match wrong resource type", + selector: &ackv1alpha1.IAMRoleSelector{ + Spec: ackv1alpha1.IAMRoleSelectorSpec{ + ARN: "arn:aws:iam::123456789012:role/test-role", + ResourceTypeSelector: []schema.GroupVersionKind{ + { + Group: "rds.services.k8s.aws", + Version: "v1alpha1", + Kind: "DBInstance", + }, + }, + }, + }, + ctx: MatchContext{ + Namespace: "default", + GVK: schema.GroupVersionKind{ + Group: "s3.services.k8s.aws", + Version: "v1alpha1", + Kind: "Bucket", + }, + }, + want: false, + }, + { + name: "matches both namespace and resource type", + selector: &ackv1alpha1.IAMRoleSelector{ + Spec: ackv1alpha1.IAMRoleSelectorSpec{ + ARN: "arn:aws:iam::123456789012:role/test-role", + NamespaceSelector: ackv1alpha1.NamespaceSelector{ + Names: []string{"production"}, + }, + ResourceTypeSelector: []schema.GroupVersionKind{ + { + Kind: "Bucket", + }, + }, + }, + }, + ctx: MatchContext{ + Namespace: "production", + GVK: schema.GroupVersionKind{ + Group: "s3.services.k8s.aws", + Version: "v1alpha1", + Kind: "Bucket", + }, + }, + want: true, + }, + { + name: "does not match if namespace matches but resource type doesn't", + selector: &ackv1alpha1.IAMRoleSelector{ + Spec: ackv1alpha1.IAMRoleSelectorSpec{ + ARN: "arn:aws:iam::123456789012:role/test-role", + NamespaceSelector: ackv1alpha1.NamespaceSelector{ + Names: []string{"production"}, + }, + ResourceTypeSelector: []schema.GroupVersionKind{ + { + Kind: "DBInstance", + }, + }, + }, + }, + ctx: MatchContext{ + Namespace: "production", + GVK: schema.GroupVersionKind{ + Group: "s3.services.k8s.aws", + Version: "v1alpha1", + Kind: "Bucket", + }, + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Matches(tt.selector, tt.ctx) + if got != tt.want { + t.Errorf("Matches() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestValidateSelector(t *testing.T) { + tests := []struct { + name string + selector *ackv1alpha1.IAMRoleSelector + wantErr bool + errMsg string + }{ + { + name: "nil selector", + selector: nil, + wantErr: true, + errMsg: "selector cannot be nil", + }, + { + name: "empty ARN", + selector: &ackv1alpha1.IAMRoleSelector{ + Spec: ackv1alpha1.IAMRoleSelectorSpec{ + ARN: "", + }, + }, + wantErr: true, + errMsg: "ARN cannot be empty", + }, + { + name: "invalid ARN format", + selector: &ackv1alpha1.IAMRoleSelector{ + Spec: ackv1alpha1.IAMRoleSelectorSpec{ + ARN: "not-an-arn", + }, + }, + wantErr: true, + errMsg: "invalid ARN", + }, + { + name: "valid minimal selector", + selector: &ackv1alpha1.IAMRoleSelector{ + Spec: ackv1alpha1.IAMRoleSelectorSpec{ + ARN: "arn:aws:iam::123456789012:role/test-role", + }, + }, + wantErr: false, + }, + { + name: "duplicate namespace names", + selector: &ackv1alpha1.IAMRoleSelector{ + Spec: ackv1alpha1.IAMRoleSelectorSpec{ + ARN: "arn:aws:iam::123456789012:role/test-role", + NamespaceSelector: ackv1alpha1.NamespaceSelector{ + Names: []string{"prod", "staging", "prod"}, + }, + }, + }, + wantErr: true, + errMsg: "duplicate namespace name: prod", + }, + { + name: "empty namespace name", + selector: &ackv1alpha1.IAMRoleSelector{ + Spec: ackv1alpha1.IAMRoleSelectorSpec{ + ARN: "arn:aws:iam::123456789012:role/test-role", + NamespaceSelector: ackv1alpha1.NamespaceSelector{ + Names: []string{"prod", ""}, + }, + }, + }, + wantErr: true, + errMsg: "namespace name cannot be empty", + }, + { + name: "empty label key", + selector: &ackv1alpha1.IAMRoleSelector{ + Spec: ackv1alpha1.IAMRoleSelectorSpec{ + ARN: "arn:aws:iam::123456789012:role/test-role", + NamespaceSelector: ackv1alpha1.NamespaceSelector{ + LabelSelector: ackv1alpha1.LabelSelector{ + MatchLabels: map[string]string{ + "": "value", + "env": "prod", + }, + }, + }, + }, + }, + wantErr: true, + errMsg: "label key cannot be empty", + }, + { + name: "empty resource type selector", + selector: &ackv1alpha1.IAMRoleSelector{ + Spec: ackv1alpha1.IAMRoleSelectorSpec{ + ARN: "arn:aws:iam::123456789012:role/test-role", + ResourceTypeSelector: []schema.GroupVersionKind{ + { + // all fields empty + }, + }, + }, + }, + wantErr: true, + errMsg: "at least one of group, version, or kind must be specified at index 0", + }, + { + name: "duplicate resource type selectors", + selector: &ackv1alpha1.IAMRoleSelector{ + Spec: ackv1alpha1.IAMRoleSelectorSpec{ + ARN: "arn:aws:iam::123456789012:role/test-role", + ResourceTypeSelector: []schema.GroupVersionKind{ + { + Group: "s3.services.k8s.aws", + Version: "v1alpha1", + Kind: "Bucket", + }, + { + Group: "s3.services.k8s.aws", + Version: "v1alpha1", + Kind: "Bucket", + }, + }, + }, + }, + wantErr: true, + errMsg: "duplicate resource type selector: s3.services.k8s.aws/v1alpha1/Bucket", + }, + { + name: "valid complex selector", + selector: &ackv1alpha1.IAMRoleSelector{ + Spec: ackv1alpha1.IAMRoleSelectorSpec{ + ARN: "arn:aws:iam::123456789012:role/test-role", + NamespaceSelector: ackv1alpha1.NamespaceSelector{ + Names: []string{"prod", "staging"}, + LabelSelector: ackv1alpha1.LabelSelector{ + MatchLabels: map[string]string{ + "env": "production", + }, + }, + }, + ResourceTypeSelector: []schema.GroupVersionKind{ + { + Kind: "Bucket", + }, + { + Group: "rds.services.k8s.aws", + Version: "v1alpha1", + Kind: "DBInstance", + }, + }, + }, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateSelector(tt.selector) + if (err != nil) != tt.wantErr { + t.Errorf("validateSelector() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr && tt.errMsg != "" && err.Error() != tt.errMsg { + if !contains(err.Error(), tt.errMsg) { + t.Errorf("validateSelector() error message = %v, want substring %v", err.Error(), tt.errMsg) + } + } + }) + } +} + +func TestMatchesNamespace(t *testing.T) { + tests := []struct { + name string + nsSelector ackv1alpha1.NamespaceSelector + namespace string + namespaceLabels map[string]string + want bool + }{ + { + name: "empty selector matches all", + nsSelector: ackv1alpha1.NamespaceSelector{}, + namespace: "any-namespace", + want: true, + }, + { + name: "matches by name - single", + nsSelector: ackv1alpha1.NamespaceSelector{ + Names: []string{"production"}, + }, + namespace: "production", + want: true, + }, + { + name: "matches by name - multiple", + nsSelector: ackv1alpha1.NamespaceSelector{ + Names: []string{"prod", "staging", "dev"}, + }, + namespace: "staging", + want: true, + }, + { + name: "does not match by name", + nsSelector: ackv1alpha1.NamespaceSelector{ + Names: []string{"prod", "staging"}, + }, + namespace: "development", + want: false, + }, + { + name: "matches by labels", + nsSelector: ackv1alpha1.NamespaceSelector{ + LabelSelector: ackv1alpha1.LabelSelector{ + MatchLabels: map[string]string{ + "env": "prod", + }, + }, + }, + namespace: "any-namespace", + namespaceLabels: map[string]string{ + "env": "prod", + }, + want: true, + }, + { + name: "matches by multiple labels", + nsSelector: ackv1alpha1.NamespaceSelector{ + LabelSelector: ackv1alpha1.LabelSelector{ + MatchLabels: map[string]string{ + "env": "prod", + "team": "platform", + }, + }, + }, + namespace: "any-namespace", + namespaceLabels: map[string]string{ + "env": "prod", + "team": "platform", + "region": "us-east-1", // extra labels are ok + }, + want: true, + }, + { + name: "does not match - missing label", + nsSelector: ackv1alpha1.NamespaceSelector{ + LabelSelector: ackv1alpha1.LabelSelector{ + MatchLabels: map[string]string{ + "env": "prod", + "team": "platform", + }, + }, + }, + namespace: "any-namespace", + namespaceLabels: map[string]string{ + "env": "prod", // missing "team" label + }, + want: false, + }, + { + name: "does not match - wrong label value", + nsSelector: ackv1alpha1.NamespaceSelector{ + LabelSelector: ackv1alpha1.LabelSelector{ + MatchLabels: map[string]string{ + "env": "prod", + }, + }, + }, + namespace: "any-namespace", + namespaceLabels: map[string]string{ + "env": "dev", + }, + want: false, + }, + { + name: "matches by name AND labels", + nsSelector: ackv1alpha1.NamespaceSelector{ + Names: []string{"production", "staging"}, + LabelSelector: ackv1alpha1.LabelSelector{ + MatchLabels: map[string]string{ + "env": "prod", + }, + }, + }, + namespace: "production", + namespaceLabels: map[string]string{ + "env": "prod", + }, + want: true, + }, + { + name: "does not match - correct name but wrong labels", + nsSelector: ackv1alpha1.NamespaceSelector{ + Names: []string{"production"}, + LabelSelector: ackv1alpha1.LabelSelector{ + MatchLabels: map[string]string{ + "env": "prod", + }, + }, + }, + namespace: "production", + namespaceLabels: map[string]string{ + "env": "dev", + }, + want: false, + }, + { + name: "does not match - wrong name but correct labels", + nsSelector: ackv1alpha1.NamespaceSelector{ + Names: []string{"production"}, + LabelSelector: ackv1alpha1.LabelSelector{ + MatchLabels: map[string]string{ + "env": "prod", + }, + }, + }, + namespace: "development", + namespaceLabels: map[string]string{ + "env": "prod", + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := matchesNamespace(tt.nsSelector, tt.namespace, tt.namespaceLabels) + if got != tt.want { + t.Errorf("matchesNamespace() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestMatchesResourceType(t *testing.T) { + tests := []struct { + name string + rtSelectors []schema.GroupVersionKind + gvk schema.GroupVersionKind + want bool + }{ + { + name: "empty selector matches all", + rtSelectors: []schema.GroupVersionKind{}, + gvk: schema.GroupVersionKind{ + Group: "s3.services.k8s.aws", + Version: "v1alpha1", + Kind: "Bucket", + }, + want: true, + }, + { + name: "exact match", + rtSelectors: []schema.GroupVersionKind{ + { + Group: "s3.services.k8s.aws", + Version: "v1alpha1", + Kind: "Bucket", + }, + }, + gvk: schema.GroupVersionKind{ + Group: "s3.services.k8s.aws", + Version: "v1alpha1", + Kind: "Bucket", + }, + want: true, + }, + { + name: "partial match - only kind", + rtSelectors: []schema.GroupVersionKind{ + { + Kind: "Bucket", + }, + }, + gvk: schema.GroupVersionKind{ + Group: "s3.services.k8s.aws", + Version: "v1alpha1", + Kind: "Bucket", + }, + want: true, + }, + { + name: "partial match - only group", + rtSelectors: []schema.GroupVersionKind{ + { + Group: "s3.services.k8s.aws", + }, + }, + gvk: schema.GroupVersionKind{ + Group: "s3.services.k8s.aws", + Version: "v1alpha1", + Kind: "Bucket", + }, + want: true, + }, + { + name: "partial match - group and version", + rtSelectors: []schema.GroupVersionKind{ + { + Group: "s3.services.k8s.aws", + Version: "v1alpha1", + }, + }, + gvk: schema.GroupVersionKind{ + Group: "s3.services.k8s.aws", + Version: "v1alpha1", + Kind: "Bucket", + }, + want: true, + }, + { + name: "no match - wrong kind", + rtSelectors: []schema.GroupVersionKind{ + { + Kind: "DBInstance", + }, + }, + gvk: schema.GroupVersionKind{ + Group: "s3.services.k8s.aws", + Version: "v1alpha1", + Kind: "Bucket", + }, + want: false, + }, + { + name: "no match - wrong group", + rtSelectors: []schema.GroupVersionKind{ + { + Group: "rds.services.k8s.aws", + Version: "v1alpha1", + Kind: "Bucket", + }, + }, + gvk: schema.GroupVersionKind{ + Group: "s3.services.k8s.aws", + Version: "v1alpha1", + Kind: "Bucket", + }, + want: false, + }, + { + name: "OR logic - multiple selectors", + rtSelectors: []schema.GroupVersionKind{ + { + Kind: "DBInstance", + }, + { + Kind: "Bucket", + }, + { + Kind: "Queue", + }, + }, + gvk: schema.GroupVersionKind{ + Group: "s3.services.k8s.aws", + Version: "v1alpha1", + Kind: "Bucket", + }, + want: true, + }, + { + name: "OR logic - no match", + rtSelectors: []schema.GroupVersionKind{ + { + Kind: "DBInstance", + }, + { + Kind: "Queue", + }, + }, + gvk: schema.GroupVersionKind{ + Group: "s3.services.k8s.aws", + Version: "v1alpha1", + Kind: "Bucket", + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := matchesResourceType(tt.rtSelectors, tt.gvk) + if got != tt.want { + t.Errorf("matchesResourceType() = %v, want %v", got, tt.want) + } + }) + } +} + +// Helper function to check if a string contains a substring +func contains(s, substr string) bool { + return len(substr) > 0 && len(s) >= len(substr) && s[:len(substr)] == substr || + len(s) > len(substr) && contains(s[1:], substr) +} diff --git a/pkg/runtime/reconciler.go b/pkg/runtime/reconciler.go index ddc94f4..b36155f 100644 --- a/pkg/runtime/reconciler.go +++ b/pkg/runtime/reconciler.go @@ -43,6 +43,7 @@ import ( ackmetrics "github.com/aws-controllers-k8s/runtime/pkg/metrics" "github.com/aws-controllers-k8s/runtime/pkg/requeue" ackrtcache "github.com/aws-controllers-k8s/runtime/pkg/runtime/cache" + "github.com/aws-controllers-k8s/runtime/pkg/runtime/iamroleselector" ackrtlog "github.com/aws-controllers-k8s/runtime/pkg/runtime/log" acktypes "github.com/aws-controllers-k8s/runtime/pkg/types" ) @@ -66,7 +67,8 @@ type reconciler struct { apiReader client.Reader log logr.Logger cfg ackcfg.Config - cache ackrtcache.Caches + carmCache ackrtcache.Caches + irsCache *iamroleselector.Cache metrics *ackmetrics.Metrics } @@ -250,7 +252,7 @@ func (r *resourceReconciler) Reconcile(ctx context.Context, req ctrlrt.Request) return ctrlrt.Result{}, fmt.Errorf("parsing role ARN %q from %q configmap: %v", roleARN, ackrtcache.ACKRoleTeamMap, err) } acctID = ackv1alpha1.AWSAccountID(parsedARN.AccountID) - } else if needCARMLookup { + } else if needCARMLookup && r.cfg.EnableCARM { // The user is specifying a namespace that is annotated with an owner account ID. // Requeue if the corresponding roleARN is not available in the Accounts configmap. roleARN, err = r.getRoleARN(string(acctID), ackrtcache.ACKRoleAccountMap) @@ -259,6 +261,34 @@ func (r *resourceReconciler) Reconcile(ctx context.Context, req ctrlrt.Request) } } + if r.cfg.FeatureGates.IsEnabled(featuregate.IAMRoleSelector) { + // If the IAMRoleSelector feature gate is enabled, we need to check if there + // are any matching IAMRoleSelectors for this resource. If there are, we + // override the roleARN from CARM (if any) with the one from the selector. + selectors, err := r.irsCache.GetMatchingSelectors( + req.Namespace, + nil, + r.rd.GroupVersionKind(), + ) + if err != nil { + return ctrlrt.Result{}, fmt.Errorf("checking for matching IAMRoleSelectors: %w", err) + } + if len(selectors) > 1 { + // We do not support multiple matching selectors for now. + return ctrlrt.Result{}, fmt.Errorf("multiple (%d) matching IAMRoleSelectors found", len(selectors)) + } + if len(selectors) == 1 { + rlog.WithValues("iam_role_selector", selectors[0].Name) + roleARN = ackv1alpha1.AWSResourceName(selectors[0].Spec.ARN) + rlog.Info("using role ARN from IAMRoleSelector") + parsedARN, err := arn.Parse(string(roleARN)) + if err != nil { + return ctrlrt.Result{}, fmt.Errorf("parsing role ARN %q from IAMRoleSelector %q: %v", roleARN, selectors[0].Name, err) + } + acctID = ackv1alpha1.AWSAccountID(parsedARN.AccountID) + } + } + region := r.getRegion(desired) endpointURL := r.getEndpointURL(desired) gvk := r.rd.GroupVersionKind() @@ -1220,7 +1250,7 @@ func (r *resourceReconciler) getOwnerAccountID( ) (ackv1alpha1.AWSAccountID, bool) { // look for owner account id in the namespace annotations namespace := res.MetaObject().GetNamespace() - accID, ok := r.cache.Namespaces.GetOwnerAccountID(namespace) + accID, ok := r.carmCache.Namespaces.GetOwnerAccountID(namespace) if ok { return ackv1alpha1.AWSAccountID(accID), true } @@ -1242,7 +1272,7 @@ func (r *resourceReconciler) getTeamID( ) ackv1alpha1.TeamID { // look for team ID in the namespace annotations namespace := res.MetaObject().GetNamespace() - namespacedTeamID, ok := r.cache.Namespaces.GetTeamID(namespace) + namespacedTeamID, ok := r.carmCache.Namespaces.GetTeamID(namespace) if ok { return ackv1alpha1.TeamID(namespacedTeamID) } @@ -1255,9 +1285,9 @@ func (r *resourceReconciler) getRoleARN(id string, cacheName string) (ackv1alpha var cache *ackrtcache.CARMMap switch cacheName { case ackrtcache.ACKRoleTeamMap: - cache = r.cache.Teams + cache = r.carmCache.Teams case ackrtcache.ACKRoleAccountMap: - cache = r.cache.Accounts + cache = r.carmCache.Accounts default: return "", fmt.Errorf("invalid cache name: %s", cacheName) } @@ -1304,7 +1334,7 @@ func (r *resourceReconciler) getRegion( // look for default region in namespace metadata annotations ns := res.MetaObject().GetNamespace() - defaultRegion, ok := r.cache.Namespaces.GetDefaultRegion(ns) + defaultRegion, ok := r.carmCache.Namespaces.GetDefaultRegion(ns) if ok { return ackv1alpha1.AWSRegion(defaultRegion) } @@ -1333,7 +1363,7 @@ func (r *resourceReconciler) getDeletionPolicy( // look for default deletion policy in namespace metadata annotations ns := res.MetaObject().GetNamespace() - deletionPolicy, ok = r.cache.Namespaces.GetDeletionPolicy(ns, r.sc.GetMetadata().ServiceAlias) + deletionPolicy, ok = r.carmCache.Namespaces.GetDeletionPolicy(ns, r.sc.GetMetadata().ServiceAlias) if ok { return ackv1alpha1.DeletionPolicy(deletionPolicy) } @@ -1352,7 +1382,7 @@ func (r *resourceReconciler) getEndpointURL( // look for endpoint url in the namespace annotations namespace := res.MetaObject().GetNamespace() - endpointURL, ok := r.cache.Namespaces.GetEndpointURL(namespace) + endpointURL, ok := r.carmCache.Namespaces.GetEndpointURL(namespace) if ok { return endpointURL } @@ -1418,9 +1448,10 @@ func NewReconciler( log logr.Logger, cfg ackcfg.Config, metrics *ackmetrics.Metrics, - cache ackrtcache.Caches, + carmCache ackrtcache.Caches, + irsCache *iamroleselector.Cache, ) acktypes.AWSResourceReconciler { - return NewReconcilerWithClient(sc, nil, rmf, log, cfg, metrics, cache) + return NewReconcilerWithClient(sc, nil, rmf, log, cfg, metrics, carmCache, irsCache) } // NewReconcilerWithClient returns a new reconciler object @@ -1432,7 +1463,8 @@ func NewReconcilerWithClient( log logr.Logger, cfg ackcfg.Config, metrics *ackmetrics.Metrics, - cache ackrtcache.Caches, + carmCache ackrtcache.Caches, + irsCache *iamroleselector.Cache, ) acktypes.AWSResourceReconciler { rtLog := log.WithName("ackrt") resyncPeriod := getResyncPeriod(rmf, cfg) @@ -1442,12 +1474,13 @@ func NewReconcilerWithClient( ) return &resourceReconciler{ reconciler: reconciler{ - sc: sc, - kc: kc, - log: rtLog, - cfg: cfg, - metrics: metrics, - cache: cache, + sc: sc, + kc: kc, + log: rtLog, + cfg: cfg, + metrics: metrics, + carmCache: carmCache, + irsCache: irsCache, }, rmf: rmf, rd: rmf.ResourceDescriptor(), diff --git a/pkg/runtime/reconciler_test.go b/pkg/runtime/reconciler_test.go index fb05154..62f044a 100644 --- a/pkg/runtime/reconciler_test.go +++ b/pkg/runtime/reconciler_test.go @@ -43,6 +43,7 @@ import ( "github.com/aws-controllers-k8s/runtime/pkg/requeue" ackrt "github.com/aws-controllers-k8s/runtime/pkg/runtime" ackrtcache "github.com/aws-controllers-k8s/runtime/pkg/runtime/cache" + "github.com/aws-controllers-k8s/runtime/pkg/runtime/iamroleselector" acktypes "github.com/aws-controllers-k8s/runtime/pkg/types" k8srtschemamocks "github.com/aws-controllers-k8s/runtime/mocks/apimachinery/pkg/runtime/schema" @@ -126,7 +127,7 @@ func reconcilerMocks( kc := &ctrlrtclientmock.Client{} return ackrt.NewReconcilerWithClient( - sc, kc, rmf, fakeLogger, cfg, metrics, ackrtcache.Caches{}, + sc, kc, rmf, fakeLogger, cfg, metrics, ackrtcache.Caches{}, &iamroleselector.Cache{}, ), kc, scmd } @@ -505,7 +506,7 @@ func TestReconcilerAdoptOrCreateResource_Adopt(t *testing.T) { latest, latestRTObj, latestMetaObj := resourceMocks() latest.On("Identifiers").Return(ids) latest.On("Conditions").Return([]*ackv1alpha1.Condition{}) - latest.On( + latest.On( "ReplaceConditions", mock.AnythingOfType("[]*v1alpha1.Condition"), ).Return().Run(func(args mock.Arguments) { diff --git a/pkg/runtime/service_controller.go b/pkg/runtime/service_controller.go index 390a071..3820dc0 100644 --- a/pkg/runtime/service_controller.go +++ b/pkg/runtime/service_controller.go @@ -24,6 +24,7 @@ import ( "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/discovery" + "k8s.io/client-go/dynamic" kubernetes "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" ctrlrt "sigs.k8s.io/controller-runtime" @@ -31,8 +32,10 @@ import ( ackv1alpha1 "github.com/aws-controllers-k8s/runtime/apis/core/v1alpha1" ackcfg "github.com/aws-controllers-k8s/runtime/pkg/config" + "github.com/aws-controllers-k8s/runtime/pkg/featuregate" ackmetrics "github.com/aws-controllers-k8s/runtime/pkg/metrics" ackrtcache "github.com/aws-controllers-k8s/runtime/pkg/runtime/cache" + "github.com/aws-controllers-k8s/runtime/pkg/runtime/iamroleselector" acktypes "github.com/aws-controllers-k8s/runtime/pkg/types" ackutil "github.com/aws-controllers-k8s/runtime/pkg/util" ) @@ -207,7 +210,7 @@ func (c *serviceController) BindControllerManager(mgr ctrlrt.Manager, cfg ackcfg return fmt.Errorf("unable to get watch namespaces: %v", err) } - cache := ackrtcache.New(c.log, ackrtcache.Config{ + carmCache := ackrtcache.New(c.log, ackrtcache.Config{ WatchScope: namespaces, // Default to ignoring the kube-system, kube-public, and // kube-node-lease namespaces. @@ -234,10 +237,10 @@ func (c *serviceController) BindControllerManager(mgr ctrlrt.Manager, cfg ackcfg } // Run the caches. This will not block as the caches are run in // separate goroutines. - cache.Run(clientSet) + carmCache.Run(clientSet) // Wait for the caches to sync ctx := context.TODO() - synced := cache.WaitForCachesToSync(ctx) + synced := carmCache.WaitForCachesToSync(ctx) c.log.Info("Waited for the caches to sync", "synced", synced) } } @@ -250,7 +253,7 @@ func (c *serviceController) BindControllerManager(mgr ctrlrt.Manager, cfg ackcfg } else if !adoptionInstalled { adoptionLogger.Info("AdoptedResource CRD not installed. The adoption reconciler will not be started") } else { - rec := NewAdoptionReconciler(c, adoptionLogger, cfg, c.metrics, cache) + rec := NewAdoptionReconciler(c, adoptionLogger, cfg, c.metrics, carmCache) if err := rec.BindControllerManager(mgr); err != nil { return err } @@ -260,7 +263,7 @@ func (c *serviceController) BindControllerManager(mgr ctrlrt.Manager, cfg ackcfg exporterInstalled := false exporterLogger := c.log.WithName("exporter") - + if cfg.EnableFieldExportReconciler { exporterInstalled, err := c.GetFieldExportInstalled(mgr) if err != nil { @@ -268,7 +271,7 @@ func (c *serviceController) BindControllerManager(mgr ctrlrt.Manager, cfg ackcfg } else if !exporterInstalled { exporterLogger.Info("FieldExport CRD not installed. The field export reconciler will not be started") } else { - rec := NewFieldExportReconcilerForFieldExport(c, exporterLogger, cfg, c.metrics, cache) + rec := NewFieldExportReconcilerForFieldExport(c, exporterLogger, cfg, c.metrics, carmCache) if err := rec.BindControllerManager(mgr); err != nil { return err } @@ -285,6 +288,19 @@ func (c *serviceController) BindControllerManager(mgr ctrlrt.Manager, cfg ackcfg if len(reconcileResources) == 0 { c.log.Info("No resources? Did they all go on vacation? Defaulting to reconciling all resources.") } + + irsCache := iamroleselector.NewCache(c.log) + // only run the IAMRoleSelector cache if the feature gate is enabled + if cfg.FeatureGates.IsEnabled(featuregate.IAMRoleSelector) { + // init dynamic client + clusterConfig := mgr.GetConfig() + clientSet, err := dynamic.NewForConfig(clusterConfig) + if err != nil { + return err + } + irsCache.Run(clientSet, context.TODO().Done()) + } + // Filter the resource manager factories filteredRMFs := c.rmFactories if len(reconcileResources) > 0 { @@ -303,7 +319,7 @@ func (c *serviceController) BindControllerManager(mgr ctrlrt.Manager, cfg ackcfg } for _, rmf := range filteredRMFs { - rec := NewReconciler(c, rmf, c.log, cfg, c.metrics, cache) + rec := NewReconciler(c, rmf, c.log, cfg, c.metrics, carmCache, irsCache) if err := rec.BindControllerManager(mgr); err != nil { return err } @@ -311,7 +327,7 @@ func (c *serviceController) BindControllerManager(mgr ctrlrt.Manager, cfg ackcfg if cfg.EnableFieldExportReconciler && exporterInstalled { rd := rmf.ResourceDescriptor() - feRec := NewFieldExportReconcilerForAWSResource(c, exporterLogger, cfg, c.metrics, cache, rd) + feRec := NewFieldExportReconcilerForAWSResource(c, exporterLogger, cfg, c.metrics, carmCache, rd) if err := feRec.BindControllerManager(mgr); err != nil { return err } @@ -335,9 +351,9 @@ func NewServiceController( ) acktypes.ServiceController { return &serviceController{ ServiceControllerMetadata: acktypes.ServiceControllerMetadata{ - VersionInfo: versionInfo, - ServiceAlias: svcAlias, - ServiceAPIGroup: svcAPIGroup, + VersionInfo: versionInfo, + ServiceAlias: svcAlias, + ServiceAPIGroup: svcAPIGroup, }, metrics: ackmetrics.NewMetrics(svcAlias), }