From aea4fd1839f9ce09efcb61639a6998e31567538f Mon Sep 17 00:00:00 2001 From: Amine Date: Tue, 23 Sep 2025 23:02:25 -0700 Subject: [PATCH 1/5] 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/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 | 3 +- pkg/runtime/service_controller.go | 28 +- 14 files changed, 1888 insertions(+), 27 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 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 b1b53bf..f5e3344 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. @@ -263,6 +264,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 b2ede7b..8165534 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -3,4 +3,5 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: + - bases/services.k8s.aws_iamroleselectors.yaml - bases/services.k8s.aws_fieldexports.yaml diff --git a/pkg/config/config.go b/pkg/config/config.go index 30c1d9b..7ddef6f 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -373,6 +373,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/field_export_reconciler.go b/pkg/runtime/field_export_reconciler.go index 05832b0..c10461a 100644 --- a/pkg/runtime/field_export_reconciler.go +++ b/pkg/runtime/field_export_reconciler.go @@ -740,7 +740,7 @@ func NewFieldExportReconcilerWithClient( log: log.WithName("field-export-reconciler"), cfg: cfg, metrics: metrics, - cache: cache, + carmCache: cache, kc: kc, apiReader: apiReader, }, @@ -769,7 +769,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 ec3802d..c71944e 100644 --- a/pkg/runtime/reconciler.go +++ b/pkg/runtime/reconciler.go @@ -44,6 +44,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" ) @@ -67,7 +68,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 } @@ -251,7 +253,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) @@ -265,6 +267,34 @@ func (r *resourceReconciler) Reconcile(ctx context.Context, req ctrlrt.Request) acctID = ackv1alpha1.AWSAccountID(parsedARN.AccountID) } + 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() @@ -1311,7 +1341,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 } @@ -1333,7 +1363,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) } @@ -1346,9 +1376,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) } @@ -1395,7 +1425,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) } @@ -1424,7 +1454,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) } @@ -1443,7 +1473,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 } @@ -1509,9 +1539,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 @@ -1523,7 +1554,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) @@ -1533,12 +1565,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 139ab75..38912c9 100644 --- a/pkg/runtime/reconciler_test.go +++ b/pkg/runtime/reconciler_test.go @@ -49,6 +49,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" acktypes "github.com/aws-controllers-k8s/runtime/pkg/types" ) @@ -128,7 +129,7 @@ func reconcilerMocks( kc := &ctrlrtclientmock.Client{} return NewReconcilerWithClient( - sc, kc, rmf, fakeLogger, cfg, metrics, ackrtcache.Caches{}, + sc, kc, rmf, fakeLogger, cfg, metrics, ackrtcache.Caches{}, &iamroleselector.Cache{}, ), kc, scmd } diff --git a/pkg/runtime/service_controller.go b/pkg/runtime/service_controller.go index de8d6c6..431760c 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" ) @@ -197,7 +200,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. @@ -224,10 +227,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) } } @@ -242,7 +245,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 } @@ -259,6 +262,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 { @@ -277,7 +293,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 } @@ -285,7 +301,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 } From bb2ab971bf78b1f23cd1791be5ea3ae96251983a Mon Sep 17 00:00:00 2001 From: michaelhtm <98621731+michaelhtm@users.noreply.github.com> Date: Mon, 10 Nov 2025 16:45:33 -0800 Subject: [PATCH 2/5] add namespace watch --- pkg/runtime/cache/namespace.go | 14 ++++++++++++ pkg/runtime/iamroleselector/cache.go | 27 ++++++++++++++--------- pkg/runtime/iamroleselector/cache_test.go | 10 +++++++-- pkg/runtime/reconciler.go | 7 +++--- pkg/runtime/service_controller.go | 14 ++++++------ 5 files changed, 49 insertions(+), 23 deletions(-) diff --git a/pkg/runtime/cache/namespace.go b/pkg/runtime/cache/namespace.go index 68bbb65..474988e 100644 --- a/pkg/runtime/cache/namespace.go +++ b/pkg/runtime/cache/namespace.go @@ -38,6 +38,8 @@ type namespaceInfo struct { endpointURL string // {service}.services.k8s.aws/deletion-policy Annotations (keyed by service) deletionPolicies map[string]string + + labels map[string]string } // getDefaultRegion returns the default region value @@ -226,6 +228,16 @@ func (c *NamespaceCache) GetDeletionPolicy(namespace string, service string) (st return "", false } +// GetDeletionPolicy returns the deletion policy if it exists +func (c *NamespaceCache) GetLabels(namespace string) map[string]string { + info, ok := c.getNamespaceInfo(namespace) + if ok { + return info.labels + } + return nil + +} + // getNamespaceInfo reads a namespace cached annotations and // return a given namespace default aws region, owner account id and endpoint url. // This function is thread safe. @@ -268,6 +280,8 @@ func (c *NamespaceCache) setNamespaceInfoFromK8sObject(ns *corev1.Namespace) { nsInfo.deletionPolicies[service] = elem } + nsInfo.labels = ns.GetLabels() + c.Lock() defer c.Unlock() c.namespaceInfos[ns.ObjectMeta.Name] = nsInfo diff --git a/pkg/runtime/iamroleselector/cache.go b/pkg/runtime/iamroleselector/cache.go index 5f08ae4..39b8bad 100644 --- a/pkg/runtime/iamroleselector/cache.go +++ b/pkg/runtime/iamroleselector/cache.go @@ -14,6 +14,7 @@ package iamroleselector import ( + "context" "fmt" "sync" @@ -24,29 +25,33 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/dynamic" "k8s.io/client-go/dynamic/dynamicinformer" + "k8s.io/client-go/kubernetes" "k8s.io/client-go/tools/cache" ackv1alpha1 "github.com/aws-controllers-k8s/runtime/apis/core/v1alpha1" + ackcache "github.com/aws-controllers-k8s/runtime/pkg/runtime/cache" ) // 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 + namespaces *ackcache.NamespaceCache + 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), + log: log.WithName("cache.iam-role-selector"), + selectors: make(map[string]*ackv1alpha1.IAMRoleSelector), + namespaces: ackcache.NewNamespaceCache(log, nil, nil), } } // Run starts the cache and blocks until stopCh is closed -func (c *Cache) Run(client dynamic.Interface, stopCh <-chan struct{}) { +func (c *Cache) Run(client dynamic.Interface, namespaceClient kubernetes.Interface, stopCh <-chan struct{}) { c.log.V(1).Info("Starting IAMRoleSelector cache") // Create dynamic informer factory @@ -74,6 +79,8 @@ func (c *Cache) Run(client dynamic.Interface, stopCh <-chan struct{}) { }) factory.Start(stopCh) + + c.namespaces.Run(namespaceClient, stopCh) } func (c *Cache) handleAdd(obj interface{}) { @@ -197,15 +204,15 @@ func (c *Cache) ListSelectors() []*ackv1alpha1.IAMRoleSelector { // 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) { +func (c *Cache) Matches(ctx context.Context, 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() - + namespaceName := metaObj.GetNamespace() + namespaceLabels := c.namespaces.GetLabels(namespaceName) // Get GVK - should be set on ACK resources gvk := resource.GetObjectKind().GroupVersionKind() if gvk.Empty() { @@ -215,5 +222,5 @@ func (c *Cache) Matches(resource runtime.Object) ([]*ackv1alpha1.IAMRoleSelector // TODO: get namespace labels from a namespace lister/cache // For now, pass empty namespace labels - return c.GetMatchingSelectors(namespace, nil, gvk) + return c.GetMatchingSelectors(namespaceName, namespaceLabels, gvk) } diff --git a/pkg/runtime/iamroleselector/cache_test.go b/pkg/runtime/iamroleselector/cache_test.go index 5a92070..67d3397 100644 --- a/pkg/runtime/iamroleselector/cache_test.go +++ b/pkg/runtime/iamroleselector/cache_test.go @@ -14,6 +14,7 @@ package iamroleselector import ( + "context" "testing" "time" @@ -27,6 +28,7 @@ import ( "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/watch" "k8s.io/client-go/dynamic/fake" + k8sfake "k8s.io/client-go/kubernetes/fake" k8stesting "k8s.io/client-go/testing" ackv1alpha1 "github.com/aws-controllers-k8s/runtime/apis/core/v1alpha1" @@ -53,13 +55,16 @@ func TestCache_Matches(t *testing.T) { client := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, gvrToListKind) client.PrependWatchReactor("iamroleselectors", k8stesting.DefaultWatchReactor(watcher, nil)) + k8sClient := k8sfake.NewSimpleClientset() + k8sClient.PrependWatchReactor("production", 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) + go cache.Run(client, k8sClient, stopCh) // Wait for cache to sync require.Eventually(t, func() bool { @@ -145,10 +150,11 @@ func TestCache_Matches(t *testing.T) { wantCount: 0, }, } + ctx := context.TODO() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - matches, err := cache.Matches(tt.resource) + matches, err := cache.Matches(ctx, tt.resource) require.NoError(t, err) require.Len(t, matches, tt.wantCount) diff --git a/pkg/runtime/reconciler.go b/pkg/runtime/reconciler.go index c71944e..6c66986 100644 --- a/pkg/runtime/reconciler.go +++ b/pkg/runtime/reconciler.go @@ -271,10 +271,9 @@ func (r *resourceReconciler) Reconcile(ctx context.Context, req ctrlrt.Request) // 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(), + selectors, err := r.irsCache.Matches( + ctx, + desired.RuntimeObject(), ) if err != nil { return ctrlrt.Result{}, fmt.Errorf("checking for matching IAMRoleSelectors: %w", err) diff --git a/pkg/runtime/service_controller.go b/pkg/runtime/service_controller.go index 431760c..65d4746 100644 --- a/pkg/runtime/service_controller.go +++ b/pkg/runtime/service_controller.go @@ -213,6 +213,11 @@ func (c *serviceController) BindControllerManager(mgr ctrlrt.Manager, cfg ackcfg }}, cfg.FeatureGates, ) + clusterConfig := mgr.GetConfig() + clientSet, err := kubernetes.NewForConfig(clusterConfig) + if err != nil { + return err + } // The caches are only used for cross account resource management. We // want to run them only when --enable-carm is set to true and // --watch-namespace is set to zero or more than one namespaces. @@ -220,11 +225,6 @@ func (c *serviceController) BindControllerManager(mgr ctrlrt.Manager, cfg ackcfg if len(namespaces) == 1 { c.log.V(0).Info("--enable-carm is set to true but --watch-namespace is set to a single namespace. CARM will not be enabled.") } else { - clusterConfig := mgr.GetConfig() - clientSet, err := kubernetes.NewForConfig(clusterConfig) - if err != nil { - return err - } // Run the caches. This will not block as the caches are run in // separate goroutines. carmCache.Run(clientSet) @@ -268,11 +268,11 @@ func (c *serviceController) BindControllerManager(mgr ctrlrt.Manager, cfg ackcfg if cfg.FeatureGates.IsEnabled(featuregate.IAMRoleSelector) { // init dynamic client clusterConfig := mgr.GetConfig() - clientSet, err := dynamic.NewForConfig(clusterConfig) + dynamicClient, err := dynamic.NewForConfig(clusterConfig) if err != nil { return err } - irsCache.Run(clientSet, context.TODO().Done()) + irsCache.Run(dynamicClient, clientSet, context.TODO().Done()) } // Filter the resource manager factories From 8066e43597d579bec5faa58001065a45ea50cd48 Mon Sep 17 00:00:00 2001 From: michaelhtm <98621731+michaelhtm@users.noreply.github.com> Date: Tue, 11 Nov 2025 10:59:08 -0800 Subject: [PATCH 3/5] temp --- apis/core/v1alpha1/iam_role_selector.go | 3 ++- apis/core/v1alpha1/resource_metadata.go | 7 +++++++ apis/core/v1alpha1/zz_generated.deepcopy.go | 13 ------------- .../bases/services.k8s.aws_iamroleselectors.yaml | 8 ++++---- pkg/runtime/iamroleselector/cache.go | 3 +-- pkg/runtime/reconciler.go | 7 ++++--- pkg/runtime/reconciler_test.go | 14 +++++++------- 7 files changed, 25 insertions(+), 30 deletions(-) diff --git a/apis/core/v1alpha1/iam_role_selector.go b/apis/core/v1alpha1/iam_role_selector.go index 32b34c3..f72564e 100644 --- a/apis/core/v1alpha1/iam_role_selector.go +++ b/apis/core/v1alpha1/iam_role_selector.go @@ -25,7 +25,7 @@ type LabelSelector struct { // IAMRoleSelectorSpec defines the desired state of IAMRoleSelector type NamespaceSelector struct { - Names []string `json:"name"` + Names []string `json:"names"` LabelSelector LabelSelector `json:"labelSelector,omitempty"` } @@ -40,6 +40,7 @@ type IAMRoleSelectorStatus struct{} // IAMRoleSelector is the schema for the IAMRoleSelector API. // +kubebuilder:object:root=true // +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Cluster type IAMRoleSelector struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` diff --git a/apis/core/v1alpha1/resource_metadata.go b/apis/core/v1alpha1/resource_metadata.go index 7b55d5c..4180b46 100644 --- a/apis/core/v1alpha1/resource_metadata.go +++ b/apis/core/v1alpha1/resource_metadata.go @@ -32,4 +32,11 @@ type ResourceMetadata struct { OwnerAccountID *AWSAccountID `json:"ownerAccountID"` // Region is the AWS region in which the resource exists or will exist. Region *AWSRegion `json:"region"` + + IAMRoleSelector *SelectedIAMRole `json:"iamRoleSelector"` +} + +type SelectedIAMRole struct { + SelectorName string `json:"selectorName"` + ResourceVersion string `json:"resourceVersion"` } diff --git a/apis/core/v1alpha1/zz_generated.deepcopy.go b/apis/core/v1alpha1/zz_generated.deepcopy.go index f5e3344..1feea38 100644 --- a/apis/core/v1alpha1/zz_generated.deepcopy.go +++ b/apis/core/v1alpha1/zz_generated.deepcopy.go @@ -1,18 +1,5 @@ //go:build !ignore_autogenerated -// 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. - // Code generated by controller-gen. DO NOT EDIT. package v1alpha1 diff --git a/config/crd/bases/services.k8s.aws_iamroleselectors.yaml b/config/crd/bases/services.k8s.aws_iamroleselectors.yaml index 5fa657a..9e226e7 100644 --- a/config/crd/bases/services.k8s.aws_iamroleselectors.yaml +++ b/config/crd/bases/services.k8s.aws_iamroleselectors.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.16.2 + controller-gen.kubebuilder.io/version: v0.19.0 name: iamroleselectors.services.k8s.aws spec: group: services.k8s.aws @@ -12,7 +12,7 @@ spec: listKind: IAMRoleSelectorList plural: iamroleselectors singular: iamroleselector - scope: Namespaced + scope: Cluster versions: - name: v1alpha1 schema: @@ -53,12 +53,12 @@ spec: required: - matchLabels type: object - name: + names: items: type: string type: array required: - - name + - names type: object resourceTypeSelector: items: diff --git a/pkg/runtime/iamroleselector/cache.go b/pkg/runtime/iamroleselector/cache.go index 39b8bad..1d6ce31 100644 --- a/pkg/runtime/iamroleselector/cache.go +++ b/pkg/runtime/iamroleselector/cache.go @@ -14,7 +14,6 @@ package iamroleselector import ( - "context" "fmt" "sync" @@ -204,7 +203,7 @@ func (c *Cache) ListSelectors() []*ackv1alpha1.IAMRoleSelector { // 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(ctx context.Context, resource runtime.Object) ([]*ackv1alpha1.IAMRoleSelector, error) { +func (c *Cache) Matches(resource runtime.Object) ([]*ackv1alpha1.IAMRoleSelector, error) { // Extract metadata from the resource metaObj, err := meta.Accessor(resource) if err != nil { diff --git a/pkg/runtime/reconciler.go b/pkg/runtime/reconciler.go index 6c66986..53e5458 100644 --- a/pkg/runtime/reconciler.go +++ b/pkg/runtime/reconciler.go @@ -216,6 +216,7 @@ func (r *resourceReconciler) Reconcile(ctx context.Context, req ctrlrt.Request) } return ctrlrt.Result{}, err } + latest := desired.DeepCopy() rlog := ackrtlog.NewResourceLogger( r.log, desired, @@ -272,7 +273,6 @@ func (r *resourceReconciler) Reconcile(ctx context.Context, req ctrlrt.Request) // 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.Matches( - ctx, desired.RuntimeObject(), ) if err != nil { @@ -349,7 +349,7 @@ func (r *resourceReconciler) Reconcile(ctx context.Context, req ctrlrt.Request) if err != nil { return ctrlrt.Result{}, err } - latest, err := r.reconcile(ctx, rm, desired) + latest, err = r.reconcile(ctx, rm, desired) return r.HandleReconcileError(ctx, desired, latest, err) } @@ -374,7 +374,7 @@ func (r *resourceReconciler) regionDrifted(desired acktypes.AWSResource) bool { // look for default region in namespace metadata annotations ns := desired.MetaObject().GetNamespace() - nsRegion, ok := r.cache.Namespaces.GetDefaultRegion(ns) + nsRegion, ok := r.carmCache.Namespaces.GetDefaultRegion(ns) if ok { return ackv1alpha1.AWSRegion(nsRegion) != *currentRegion } @@ -1225,6 +1225,7 @@ func (r *resourceReconciler) getAWSResource( if err := r.apiReader.Get(ctx, req.NamespacedName, ro); err != nil { return nil, err } + ro.GetObjectKind().SetGroupVersionKind(r.rd.GroupVersionKind()) return r.rd.ResourceFromRuntimeObject(ro), nil } diff --git a/pkg/runtime/reconciler_test.go b/pkg/runtime/reconciler_test.go index 38912c9..e3c3dc7 100644 --- a/pkg/runtime/reconciler_test.go +++ b/pkg/runtime/reconciler_test.go @@ -331,7 +331,7 @@ func TestReconcilerReadOnlyResource(t *testing.T) { rm.On("IsSynced", ctx, latest).Return(true, nil) rmf, rd := managedResourceManagerFactoryMocks(desired, latest) rd.On("Delta", desired, latest).Return(ackcompare.NewDelta()) - + r, kc, scmd := reconcilerMocks(rmf) rm.On("EnsureTags", ctx, desired, scmd).Return(nil) statusWriter := &ctrlrtclientmock.SubResourceWriter{} @@ -1914,12 +1914,12 @@ func TestReconcile_AccountDrifted(t *testing.T) { // Create reconciler with namespace cache r := &resourceReconciler{ reconciler: reconciler{ - kc: kc, - sc: sc, - log: fakeLogger, - cfg: ackcfg.Config{AccountID: "333333333333"}, - cache: caches, - metrics: ackmetrics.NewMetrics("test"), + kc: kc, + sc: sc, + log: fakeLogger, + cfg: ackcfg.Config{AccountID: "333333333333"}, + carmCache: caches, + metrics: ackmetrics.NewMetrics("test"), }, rmf: rmf, rd: rd, From c55d9c0bd66acfe045cd82664b860f47e0be5a2e Mon Sep 17 00:00:00 2001 From: michaelhtm <98621731+michaelhtm@users.noreply.github.com> Date: Tue, 11 Nov 2025 14:06:24 -0800 Subject: [PATCH 4/5] use custom GroupVersionKind struct --- apis/core/v1alpha1/iam_role_selector.go | 13 +++++-- apis/core/v1alpha1/resource_metadata.go | 8 +++- apis/core/v1alpha1/zz_generated.deepcopy.go | 38 ++++++++++++++++++- .../services.k8s.aws_iamroleselectors.yaml | 14 +++++-- mocks/pkg/types/aws_resource.go | 5 +++ pkg/runtime/iamroleselector/cache_test.go | 8 ++-- pkg/runtime/iamroleselector/matcher.go | 4 +- pkg/runtime/iamroleselector/matcher_test.go | 38 +++++++++---------- pkg/runtime/reconciler.go | 10 ++++- pkg/types/aws_resource.go | 3 ++ 10 files changed, 103 insertions(+), 38 deletions(-) diff --git a/apis/core/v1alpha1/iam_role_selector.go b/apis/core/v1alpha1/iam_role_selector.go index f72564e..696ff38 100644 --- a/apis/core/v1alpha1/iam_role_selector.go +++ b/apis/core/v1alpha1/iam_role_selector.go @@ -15,7 +15,6 @@ 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. @@ -29,10 +28,16 @@ type NamespaceSelector struct { LabelSelector LabelSelector `json:"labelSelector,omitempty"` } +type GroupVersionKind struct { + Group string `json:"group"` + Version string `json:"version"` + Kind string `json:"kind"` +} + type IAMRoleSelectorSpec struct { - ARN string `json:"arn"` - NamespaceSelector NamespaceSelector `json:"namespaceSelector,omitempty"` - ResourceTypeSelector []schema.GroupVersionKind `json:"resourceTypeSelector,omitempty"` + ARN string `json:"arn"` + NamespaceSelector NamespaceSelector `json:"namespaceSelector,omitempty"` + ResourceTypeSelector []GroupVersionKind `json:"resourceTypeSelector,omitempty"` } type IAMRoleSelectorStatus struct{} diff --git a/apis/core/v1alpha1/resource_metadata.go b/apis/core/v1alpha1/resource_metadata.go index 4180b46..1821796 100644 --- a/apis/core/v1alpha1/resource_metadata.go +++ b/apis/core/v1alpha1/resource_metadata.go @@ -32,11 +32,15 @@ type ResourceMetadata struct { OwnerAccountID *AWSAccountID `json:"ownerAccountID"` // Region is the AWS region in which the resource exists or will exist. Region *AWSRegion `json:"region"` - - IAMRoleSelector *SelectedIAMRole `json:"iamRoleSelector"` + // IAMRoleSelector is the selected IAMRoleSelector that is used to manage + // the AWS resource. This will be nil if the default controller role is used. + IAMRoleSelector *SelectedIAMRole `json:"iamRoleSelector,omitempty"` } type SelectedIAMRole struct { + // SelectorName is the k8s resource name of the IAMRoleSelector object SelectorName string `json:"selectorName"` + // ResourceVersion is the metadata.resourceVersion of the selected + // IAMRoleSelector object ResourceVersion string `json:"resourceVersion"` } diff --git a/apis/core/v1alpha1/zz_generated.deepcopy.go b/apis/core/v1alpha1/zz_generated.deepcopy.go index 1feea38..a6d9381 100644 --- a/apis/core/v1alpha1/zz_generated.deepcopy.go +++ b/apis/core/v1alpha1/zz_generated.deepcopy.go @@ -7,7 +7,6 @@ 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. @@ -251,6 +250,21 @@ 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 *GroupVersionKind) DeepCopyInto(out *GroupVersionKind) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GroupVersionKind. +func (in *GroupVersionKind) DeepCopy() *GroupVersionKind { + if in == nil { + return nil + } + out := new(GroupVersionKind) + in.DeepCopyInto(out) + 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 @@ -316,7 +330,7 @@ func (in *IAMRoleSelectorSpec) DeepCopyInto(out *IAMRoleSelectorSpec) { in.NamespaceSelector.DeepCopyInto(&out.NamespaceSelector) if in.ResourceTypeSelector != nil { in, out := &in.ResourceTypeSelector, &out.ResourceTypeSelector - *out = make([]schema.GroupVersionKind, len(*in)) + *out = make([]GroupVersionKind, len(*in)) copy(*out, *in) } } @@ -485,6 +499,11 @@ func (in *ResourceMetadata) DeepCopyInto(out *ResourceMetadata) { *out = new(AWSRegion) **out = **in } + if in.IAMRoleSelector != nil { + in, out := &in.IAMRoleSelector, &out.IAMRoleSelector + *out = new(SelectedIAMRole) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResourceMetadata. @@ -533,3 +552,18 @@ func (in *SecretKeyReference) DeepCopy() *SecretKeyReference { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SelectedIAMRole) DeepCopyInto(out *SelectedIAMRole) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SelectedIAMRole. +func (in *SelectedIAMRole) DeepCopy() *SelectedIAMRole { + if in == nil { + return nil + } + out := new(SelectedIAMRole) + in.DeepCopyInto(out) + return out +} diff --git a/config/crd/bases/services.k8s.aws_iamroleselectors.yaml b/config/crd/bases/services.k8s.aws_iamroleselectors.yaml index 9e226e7..4d12553 100644 --- a/config/crd/bases/services.k8s.aws_iamroleselectors.yaml +++ b/config/crd/bases/services.k8s.aws_iamroleselectors.yaml @@ -62,9 +62,17 @@ spec: 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 + properties: + group: + type: string + kind: + type: string + version: + type: string + required: + - group + - kind + - version type: object type: array required: diff --git a/mocks/pkg/types/aws_resource.go b/mocks/pkg/types/aws_resource.go index f5602ae..434d6e9 100644 --- a/mocks/pkg/types/aws_resource.go +++ b/mocks/pkg/types/aws_resource.go @@ -159,6 +159,11 @@ func (_m *AWSResource) RuntimeObject() client.Object { return r0 } +// SetIAMRoleSelector provides a mock function with given fields: _a0 +func (_m *AWSResource) SetIAMRoleSelector(_a0 *v1alpha1.IAMRoleSelector) { + _m.Called(_a0) +} + // SetIdentifiers provides a mock function with given fields: _a0 func (_m *AWSResource) SetIdentifiers(_a0 *v1alpha1.AWSIdentifiers) error { ret := _m.Called(_a0) diff --git a/pkg/runtime/iamroleselector/cache_test.go b/pkg/runtime/iamroleselector/cache_test.go index 67d3397..c49473f 100644 --- a/pkg/runtime/iamroleselector/cache_test.go +++ b/pkg/runtime/iamroleselector/cache_test.go @@ -14,7 +14,6 @@ package iamroleselector import ( - "context" "testing" "time" @@ -79,7 +78,7 @@ func TestCache_Matches(t *testing.T) { NamespaceSelector: ackv1alpha1.NamespaceSelector{ Names: []string{"production"}, }, - ResourceTypeSelector: []schema.GroupVersionKind{ + ResourceTypeSelector: []ackv1alpha1.GroupVersionKind{ {Kind: "Bucket"}, }, }, @@ -89,7 +88,7 @@ func TestCache_Matches(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Name: "all-rds"}, Spec: ackv1alpha1.IAMRoleSelectorSpec{ ARN: "arn:aws:iam::123456789012:role/rds-role", - ResourceTypeSelector: []schema.GroupVersionKind{ + ResourceTypeSelector: []ackv1alpha1.GroupVersionKind{ { Group: "rds.services.k8s.aws", Kind: "DBInstance", @@ -150,11 +149,10 @@ func TestCache_Matches(t *testing.T) { wantCount: 0, }, } - ctx := context.TODO() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - matches, err := cache.Matches(ctx, tt.resource) + matches, err := cache.Matches(tt.resource) require.NoError(t, err) require.Len(t, matches, tt.wantCount) diff --git a/pkg/runtime/iamroleselector/matcher.go b/pkg/runtime/iamroleselector/matcher.go index b5517e3..5130228 100644 --- a/pkg/runtime/iamroleselector/matcher.go +++ b/pkg/runtime/iamroleselector/matcher.go @@ -75,7 +75,7 @@ func matchesNamespace(nsSelector ackv1alpha1.NamespaceSelector, namespace string return true } -func matchesResourceType(rtSelectors []schema.GroupVersionKind, gvk schema.GroupVersionKind) bool { +func matchesResourceType(rtSelectors []ackv1alpha1.GroupVersionKind, gvk schema.GroupVersionKind) bool { // If no resource type selector specified, matches all resources if len(rtSelectors) == 0 { return true @@ -153,7 +153,7 @@ func validateNamespaceSelector(nsSelector ackv1alpha1.NamespaceSelector) error { // 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 { +func validateResourceTypeSelectors(rtSelectors []ackv1alpha1.GroupVersionKind) error { seen := make(map[string]bool) for i, rts := range rtSelectors { diff --git a/pkg/runtime/iamroleselector/matcher_test.go b/pkg/runtime/iamroleselector/matcher_test.go index 13e2e53..753fcee 100644 --- a/pkg/runtime/iamroleselector/matcher_test.go +++ b/pkg/runtime/iamroleselector/matcher_test.go @@ -183,7 +183,7 @@ func TestMatches(t *testing.T) { selector: &ackv1alpha1.IAMRoleSelector{ Spec: ackv1alpha1.IAMRoleSelectorSpec{ ARN: "arn:aws:iam::123456789012:role/test-role", - ResourceTypeSelector: []schema.GroupVersionKind{ + ResourceTypeSelector: []ackv1alpha1.GroupVersionKind{ { Group: "s3.services.k8s.aws", Version: "v1alpha1", @@ -207,7 +207,7 @@ func TestMatches(t *testing.T) { selector: &ackv1alpha1.IAMRoleSelector{ Spec: ackv1alpha1.IAMRoleSelectorSpec{ ARN: "arn:aws:iam::123456789012:role/test-role", - ResourceTypeSelector: []schema.GroupVersionKind{ + ResourceTypeSelector: []ackv1alpha1.GroupVersionKind{ { Kind: "Bucket", }, @@ -229,7 +229,7 @@ func TestMatches(t *testing.T) { selector: &ackv1alpha1.IAMRoleSelector{ Spec: ackv1alpha1.IAMRoleSelectorSpec{ ARN: "arn:aws:iam::123456789012:role/test-role", - ResourceTypeSelector: []schema.GroupVersionKind{ + ResourceTypeSelector: []ackv1alpha1.GroupVersionKind{ { Group: "rds.services.k8s.aws", Version: "v1alpha1", @@ -258,7 +258,7 @@ func TestMatches(t *testing.T) { selector: &ackv1alpha1.IAMRoleSelector{ Spec: ackv1alpha1.IAMRoleSelectorSpec{ ARN: "arn:aws:iam::123456789012:role/test-role", - ResourceTypeSelector: []schema.GroupVersionKind{ + ResourceTypeSelector: []ackv1alpha1.GroupVersionKind{ { Group: "rds.services.k8s.aws", Version: "v1alpha1", @@ -285,7 +285,7 @@ func TestMatches(t *testing.T) { NamespaceSelector: ackv1alpha1.NamespaceSelector{ Names: []string{"production"}, }, - ResourceTypeSelector: []schema.GroupVersionKind{ + ResourceTypeSelector: []ackv1alpha1.GroupVersionKind{ { Kind: "Bucket", }, @@ -310,7 +310,7 @@ func TestMatches(t *testing.T) { NamespaceSelector: ackv1alpha1.NamespaceSelector{ Names: []string{"production"}, }, - ResourceTypeSelector: []schema.GroupVersionKind{ + ResourceTypeSelector: []ackv1alpha1.GroupVersionKind{ { Kind: "DBInstance", }, @@ -430,7 +430,7 @@ func TestValidateSelector(t *testing.T) { selector: &ackv1alpha1.IAMRoleSelector{ Spec: ackv1alpha1.IAMRoleSelectorSpec{ ARN: "arn:aws:iam::123456789012:role/test-role", - ResourceTypeSelector: []schema.GroupVersionKind{ + ResourceTypeSelector: []ackv1alpha1.GroupVersionKind{ { // all fields empty }, @@ -445,7 +445,7 @@ func TestValidateSelector(t *testing.T) { selector: &ackv1alpha1.IAMRoleSelector{ Spec: ackv1alpha1.IAMRoleSelectorSpec{ ARN: "arn:aws:iam::123456789012:role/test-role", - ResourceTypeSelector: []schema.GroupVersionKind{ + ResourceTypeSelector: []ackv1alpha1.GroupVersionKind{ { Group: "s3.services.k8s.aws", Version: "v1alpha1", @@ -475,7 +475,7 @@ func TestValidateSelector(t *testing.T) { }, }, }, - ResourceTypeSelector: []schema.GroupVersionKind{ + ResourceTypeSelector: []ackv1alpha1.GroupVersionKind{ { Kind: "Bucket", }, @@ -672,13 +672,13 @@ func TestMatchesNamespace(t *testing.T) { func TestMatchesResourceType(t *testing.T) { tests := []struct { name string - rtSelectors []schema.GroupVersionKind + rtSelectors []ackv1alpha1.GroupVersionKind gvk schema.GroupVersionKind want bool }{ { name: "empty selector matches all", - rtSelectors: []schema.GroupVersionKind{}, + rtSelectors: []ackv1alpha1.GroupVersionKind{}, gvk: schema.GroupVersionKind{ Group: "s3.services.k8s.aws", Version: "v1alpha1", @@ -688,7 +688,7 @@ func TestMatchesResourceType(t *testing.T) { }, { name: "exact match", - rtSelectors: []schema.GroupVersionKind{ + rtSelectors: []ackv1alpha1.GroupVersionKind{ { Group: "s3.services.k8s.aws", Version: "v1alpha1", @@ -704,7 +704,7 @@ func TestMatchesResourceType(t *testing.T) { }, { name: "partial match - only kind", - rtSelectors: []schema.GroupVersionKind{ + rtSelectors: []ackv1alpha1.GroupVersionKind{ { Kind: "Bucket", }, @@ -718,7 +718,7 @@ func TestMatchesResourceType(t *testing.T) { }, { name: "partial match - only group", - rtSelectors: []schema.GroupVersionKind{ + rtSelectors: []ackv1alpha1.GroupVersionKind{ { Group: "s3.services.k8s.aws", }, @@ -732,7 +732,7 @@ func TestMatchesResourceType(t *testing.T) { }, { name: "partial match - group and version", - rtSelectors: []schema.GroupVersionKind{ + rtSelectors: []ackv1alpha1.GroupVersionKind{ { Group: "s3.services.k8s.aws", Version: "v1alpha1", @@ -747,7 +747,7 @@ func TestMatchesResourceType(t *testing.T) { }, { name: "no match - wrong kind", - rtSelectors: []schema.GroupVersionKind{ + rtSelectors: []ackv1alpha1.GroupVersionKind{ { Kind: "DBInstance", }, @@ -761,7 +761,7 @@ func TestMatchesResourceType(t *testing.T) { }, { name: "no match - wrong group", - rtSelectors: []schema.GroupVersionKind{ + rtSelectors: []ackv1alpha1.GroupVersionKind{ { Group: "rds.services.k8s.aws", Version: "v1alpha1", @@ -777,7 +777,7 @@ func TestMatchesResourceType(t *testing.T) { }, { name: "OR logic - multiple selectors", - rtSelectors: []schema.GroupVersionKind{ + rtSelectors: []ackv1alpha1.GroupVersionKind{ { Kind: "DBInstance", }, @@ -797,7 +797,7 @@ func TestMatchesResourceType(t *testing.T) { }, { name: "OR logic - no match", - rtSelectors: []schema.GroupVersionKind{ + rtSelectors: []ackv1alpha1.GroupVersionKind{ { Kind: "DBInstance", }, diff --git a/pkg/runtime/reconciler.go b/pkg/runtime/reconciler.go index 53e5458..2a7384a 100644 --- a/pkg/runtime/reconciler.go +++ b/pkg/runtime/reconciler.go @@ -268,6 +268,8 @@ func (r *resourceReconciler) Reconcile(ctx context.Context, req ctrlrt.Request) acctID = ackv1alpha1.AWSAccountID(parsedARN.AccountID) } + var selector *ackv1alpha1.IAMRoleSelector + 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 @@ -285,6 +287,7 @@ func (r *resourceReconciler) Reconcile(ctx context.Context, req ctrlrt.Request) if len(selectors) == 1 { rlog.WithValues("iam_role_selector", selectors[0].Name) roleARN = ackv1alpha1.AWSResourceName(selectors[0].Spec.ARN) + selector = selectors[0] rlog.Info("using role ARN from IAMRoleSelector") parsedARN, err := arn.Parse(string(roleARN)) if err != nil { @@ -350,6 +353,9 @@ func (r *resourceReconciler) Reconcile(ctx context.Context, req ctrlrt.Request) return ctrlrt.Result{}, err } latest, err = r.reconcile(ctx, rm, desired) + if latest != nil && selector != nil { + latest.SetIAMRoleSelector(selector) + } return r.HandleReconcileError(ctx, desired, latest, err) } @@ -1225,7 +1231,9 @@ func (r *resourceReconciler) getAWSResource( if err := r.apiReader.Get(ctx, req.NamespacedName, ro); err != nil { return nil, err } - ro.GetObjectKind().SetGroupVersionKind(r.rd.GroupVersionKind()) + if ro != nil && ro.GetObjectKind() != nil { + ro.GetObjectKind().SetGroupVersionKind(r.rd.GroupVersionKind()) + } return r.rd.ResourceFromRuntimeObject(ro), nil } diff --git a/pkg/types/aws_resource.go b/pkg/types/aws_resource.go index dd2673e..c4b66e2 100644 --- a/pkg/types/aws_resource.go +++ b/pkg/types/aws_resource.go @@ -51,4 +51,7 @@ type AWSResource interface { // PopulateResourceFromAnnotation will set the Spec or Status field that user // provided from annotations PopulateResourceFromAnnotation(fields map[string]string) error + // Set the selected IAMRoleSelector information used to manage this resource + // in the resource status + SetIAMRoleSelector(*ackv1alpha1.IAMRoleSelector) } From 87ccfa6f1595ceceed9317d17fff2dfac937cceb Mon Sep 17 00:00:00 2001 From: michaelhtm <98621731+michaelhtm@users.noreply.github.com> Date: Tue, 11 Nov 2025 16:37:09 -0800 Subject: [PATCH 5/5] add IAMRoleSelected condition --- apis/core/v1alpha1/conditions.go | 4 +++ apis/core/v1alpha1/iam_role_selector.go | 1 + .../services.k8s.aws_iamroleselectors.yaml | 3 ++ mocks/pkg/types/aws_resource.go | 5 --- pkg/condition/condition.go | 36 ++++++++++++++++++- pkg/condition/condition_test.go | 33 +++++++++++++++++ pkg/runtime/reconciler.go | 13 +++++-- pkg/types/aws_resource.go | 3 -- 8 files changed, 87 insertions(+), 11 deletions(-) diff --git a/apis/core/v1alpha1/conditions.go b/apis/core/v1alpha1/conditions.go index 3c622f1..5a9a5ee 100644 --- a/apis/core/v1alpha1/conditions.go +++ b/apis/core/v1alpha1/conditions.go @@ -66,6 +66,10 @@ const ( // "False" status indicates that the resource references failed to resolve. // For Ex: When referenced resource is in terminal condition ConditionTypeReferencesResolved ConditionType = "ACK.ReferencesResolved" + // ConditionTypeIAMRoleSelected indicates whether an IAMRoleSelector has been selected + // to manage the AWSResource. If none are selected, this condition will be removed + // and we'll use the custom role to manage the AWSResource + ConditionTypeIAMRoleSelected ConditionType = "ACK.IAMRoleSelected" ) // Condition is the common struct used by all CRDs managed by ACK service diff --git a/apis/core/v1alpha1/iam_role_selector.go b/apis/core/v1alpha1/iam_role_selector.go index 696ff38..0b37fb4 100644 --- a/apis/core/v1alpha1/iam_role_selector.go +++ b/apis/core/v1alpha1/iam_role_selector.go @@ -35,6 +35,7 @@ type GroupVersionKind struct { } type IAMRoleSelectorSpec struct { + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable once set" ARN string `json:"arn"` NamespaceSelector NamespaceSelector `json:"namespaceSelector,omitempty"` ResourceTypeSelector []GroupVersionKind `json:"resourceTypeSelector,omitempty"` diff --git a/config/crd/bases/services.k8s.aws_iamroleselectors.yaml b/config/crd/bases/services.k8s.aws_iamroleselectors.yaml index 4d12553..9477c90 100644 --- a/config/crd/bases/services.k8s.aws_iamroleselectors.yaml +++ b/config/crd/bases/services.k8s.aws_iamroleselectors.yaml @@ -40,6 +40,9 @@ spec: properties: arn: type: string + x-kubernetes-validations: + - message: Value is immutable once set + rule: self == oldSelf namespaceSelector: description: IAMRoleSelectorSpec defines the desired state of IAMRoleSelector properties: diff --git a/mocks/pkg/types/aws_resource.go b/mocks/pkg/types/aws_resource.go index 434d6e9..f5602ae 100644 --- a/mocks/pkg/types/aws_resource.go +++ b/mocks/pkg/types/aws_resource.go @@ -159,11 +159,6 @@ func (_m *AWSResource) RuntimeObject() client.Object { return r0 } -// SetIAMRoleSelector provides a mock function with given fields: _a0 -func (_m *AWSResource) SetIAMRoleSelector(_a0 *v1alpha1.IAMRoleSelector) { - _m.Called(_a0) -} - // SetIdentifiers provides a mock function with given fields: _a0 func (_m *AWSResource) SetIdentifiers(_a0 *v1alpha1.AWSIdentifiers) error { ret := _m.Called(_a0) diff --git a/pkg/condition/condition.go b/pkg/condition/condition.go index a8481ec..8e9c449 100644 --- a/pkg/condition/condition.go +++ b/pkg/condition/condition.go @@ -33,10 +33,13 @@ var ( "annotations." UnknownSyncedMessage = "Unable to determine if desired resource state matches latest observed state" NotSyncedMessage = "Resource not synced" - TerminalMessage = "Resource is " + TerminalMessage = "Resource is " SyncedMessage = "Resource synced successfully" FailedReferenceResolutionMessage = "Reference resolution failed" UnavailableIAMRoleMessage = "IAM Role is not available" + + IAMRoleSelectedReason = "Selected" + IAMRoleSelectedMessage = "roleARN: %s, selectorName: %s, selectorResourceVersion: %s" ) // Ready returns the Condition in the resource's Conditions collection that is @@ -81,6 +84,13 @@ func ReferencesResolved(subject acktypes.ConditionManager) *ackv1alpha1.Conditio return FirstOfType(subject, ackv1alpha1.ConditionTypeReferencesResolved) } +// IAMRoleSelected returns the Condition in the resource's Conditions collection +// that is of type ConditionTypeIAMRoleSelected. If no such condition is found, +// returns nil. +func IAMRoleSelected(subject acktypes.ConditionManager) *ackv1alpha1.Condition { + return FirstOfType(subject, ackv1alpha1.ConditionTypeIAMRoleSelected) +} + // FirstOfType returns the first Condition in the resource's Conditions // collection of the supplied type. If no such condition is found, returns nil. func FirstOfType( @@ -252,6 +262,30 @@ func SetReferencesResolved( subject.ReplaceConditions(allConds) } +// SetIAMRoleSelected sets the resource's Condition of type ConditionTypeIAMRoleSelected +// to the supplied status, optional message and reason. +func SetIAMRoleSelected( + subject acktypes.ConditionManager, + status corev1.ConditionStatus, + message *string, + reason *string, +) { + allConds := subject.Conditions() + var c *ackv1alpha1.Condition + if c = ReferencesResolved(subject); c == nil { + c = &ackv1alpha1.Condition{ + Type: ackv1alpha1.ConditionTypeIAMRoleSelected, + } + allConds = append(allConds, c) + } + now := metav1.Now() + c.LastTransitionTime = &now + c.Status = status + c.Message = message + c.Reason = reason + subject.ReplaceConditions(allConds) +} + // RemoveReferencesResolved removes the condition of type ConditionTypeReferencesResolved // from the resource's conditions func RemoveReferencesResolved( diff --git a/pkg/condition/condition_test.go b/pkg/condition/condition_test.go index 4dc8dcf..7abaaeb 100644 --- a/pkg/condition/condition_test.go +++ b/pkg/condition/condition_test.go @@ -49,6 +49,9 @@ func TestConditionGetters(t *testing.T) { got = ackcond.Ready(r) assert.Nil(got) + got = ackcond.IAMRoleSelected(r) + assert.Nil(got) + conds = append(conds, &ackv1alpha1.Condition{ Type: ackv1alpha1.ConditionTypeResourceSynced, Status: corev1.ConditionFalse, @@ -66,6 +69,9 @@ func TestConditionGetters(t *testing.T) { got = ackcond.Ready(r) assert.Nil(got) + got = ackcond.IAMRoleSelected(r) + assert.Nil(got) + conds = append(conds, &ackv1alpha1.Condition{ Type: ackv1alpha1.ConditionTypeTerminal, Status: corev1.ConditionFalse, @@ -83,6 +89,9 @@ func TestConditionGetters(t *testing.T) { got = ackcond.Ready(r) assert.Nil(got) + got = ackcond.IAMRoleSelected(r) + assert.Nil(got) + gotAll := ackcond.AllOfType(r, ackv1alpha1.ConditionTypeAdvisory) assert.Empty(gotAll) @@ -124,6 +133,15 @@ func TestConditionGetters(t *testing.T) { r.On("Conditions").Return(conds) got = ackcond.Ready(r) assert.NotNil(got) + + conds = append(conds, &ackv1alpha1.Condition{ + Type: ackv1alpha1.ConditionTypeIAMRoleSelected, + Status: corev1.ConditionTrue, + }) + r = &ackmocks.AWSResource{} + r.On("Conditions").Return(conds) + got = ackcond.IAMRoleSelected(r) + assert.NotNil(got) } func TestConditionSetters(t *testing.T) { @@ -329,4 +347,19 @@ func TestConditionSetters(t *testing.T) { }), ) ackcond.SetReady(r, corev1.ConditionTrue, nil, nil) + + // IAMRoleSelected condition + r = &ackmocks.AWSResource{} + r.On("Conditions").Return([]*ackv1alpha1.Condition{}) + r.On( + "ReplaceConditions", + mock.MatchedBy(func(subject []*ackv1alpha1.Condition) bool { + if len(subject) != 1 { + return false + } + return (subject[0].Type == ackv1alpha1.ConditionTypeIAMRoleSelected && + subject[0].Status == corev1.ConditionTrue) + }), + ) + ackcond.SetIAMRoleSelected(r, corev1.ConditionTrue, nil, nil) } diff --git a/pkg/runtime/reconciler.go b/pkg/runtime/reconciler.go index 2a7384a..318a679 100644 --- a/pkg/runtime/reconciler.go +++ b/pkg/runtime/reconciler.go @@ -353,12 +353,21 @@ func (r *resourceReconciler) Reconcile(ctx context.Context, req ctrlrt.Request) return ctrlrt.Result{}, err } latest, err = r.reconcile(ctx, rm, desired) - if latest != nil && selector != nil { - latest.SetIAMRoleSelector(selector) + if latest != nil { + setIAMRoleSelectorCondition(latest, selector) } return r.HandleReconcileError(ctx, desired, latest, err) } +func setIAMRoleSelectorCondition(r acktypes.ConditionManager, selector *ackv1alpha1.IAMRoleSelector) { + if selector == nil { + return + } + + message := fmt.Sprintf(condition.IAMRoleSelectedMessage, selector.Spec.ARN, selector.GetName(), selector.GetResourceVersion()) + condition.SetIAMRoleSelected(r, corev1.ConditionTrue, &condition.IAMRoleSelectedReason, &message) +} + // regionDrifted return true if the desired resource region is different // from the target region. Target region can be derived from the two places // in the following order: diff --git a/pkg/types/aws_resource.go b/pkg/types/aws_resource.go index c4b66e2..dd2673e 100644 --- a/pkg/types/aws_resource.go +++ b/pkg/types/aws_resource.go @@ -51,7 +51,4 @@ type AWSResource interface { // PopulateResourceFromAnnotation will set the Spec or Status field that user // provided from annotations PopulateResourceFromAnnotation(fields map[string]string) error - // Set the selected IAMRoleSelector information used to manage this resource - // in the resource status - SetIAMRoleSelector(*ackv1alpha1.IAMRoleSelector) }