From cd39d65f05e6ddf2eb8e272ab1342ec155dbf178 Mon Sep 17 00:00:00 2001 From: Amine Date: Tue, 23 Sep 2025 23:02:25 -0700 Subject: [PATCH] feat: add `IAMRoleSelector` CRD/feature Implements https://github.com/aws-controllers-k8s/community/pull/2628 (mostly) Introduces a new IAMRoleSelector CRD that enables dynamic IAM role assignment based on namespace and resource type selectors. This feature provides an alternative to CARM for role selection and cannot be used simultaneously with CARM (enforced by validation). Key components: - New IAMRoleSelector CRD with namespace and resource type selectors - Selector matching logic with AND between selector types, OR within arrays - Dynamic informer-based cache for IAMRoleSelector resources - Integration into the reconciler to override CARM role selection - Alpha feature gate (IAMRoleSelector) defaulting to disabled Note: ResourceTypeSelector uses schema.GroupVersionKind in the API, which differs from the separate fields approach in the original types. This may need adjustment based on CRD generation requirements. --- apis/core/v1alpha1/iam_role_selector.go | 59 ++ apis/core/v1alpha1/zz_generated.deepcopy.go | 139 +++ .../services.k8s.aws_iamroleselectors.yaml | 79 ++ config/crd/kustomization.yaml | 1 + pkg/config/config.go | 5 + pkg/featuregate/features.go | 4 + pkg/runtime/adoption_reconciler.go | 14 +- pkg/runtime/field_export_reconciler.go | 4 +- pkg/runtime/iamroleselector/cache.go | 219 +++++ pkg/runtime/iamroleselector/cache_test.go | 300 +++++++ pkg/runtime/iamroleselector/matcher.go | 174 ++++ pkg/runtime/iamroleselector/matcher_test.go | 831 ++++++++++++++++++ pkg/runtime/reconciler.go | 69 +- pkg/runtime/reconciler_test.go | 5 +- pkg/runtime/service_controller.go | 38 +- 15 files changed, 1901 insertions(+), 40 deletions(-) create mode 100644 apis/core/v1alpha1/iam_role_selector.go create mode 100644 config/crd/bases/services.k8s.aws_iamroleselectors.yaml create mode 100644 pkg/runtime/iamroleselector/cache.go create mode 100644 pkg/runtime/iamroleselector/cache_test.go create mode 100644 pkg/runtime/iamroleselector/matcher.go create mode 100644 pkg/runtime/iamroleselector/matcher_test.go diff --git a/apis/core/v1alpha1/iam_role_selector.go b/apis/core/v1alpha1/iam_role_selector.go new file mode 100644 index 00000000..32b34c3c --- /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 357535cd..e6d5a238 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 00000000..5fa657a7 --- /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 96349f62..7f512621 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 c22ed448..7b4ff985 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 acfe1b14..a84c8d22 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 279de76f..d823dfe9 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 19e8ebd4..46ff270a 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 00000000..5f08ae4a --- /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 00000000..5a920701 --- /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 00000000..b5517e33 --- /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 00000000..13e2e535 --- /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 ddc94f46..b36155fa 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 fb051543..62f044a8 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 390a071d..3820dc0a 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), }