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 new file mode 100644 index 0000000..0b37fb4 --- /dev/null +++ b/apis/core/v1alpha1/iam_role_selector.go @@ -0,0 +1,66 @@ +// 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" +) + +// 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:"names"` + LabelSelector LabelSelector `json:"labelSelector,omitempty"` +} + +type GroupVersionKind struct { + Group string `json:"group"` + Version string `json:"version"` + Kind string `json:"kind"` +} + +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"` +} + +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"` + 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/resource_metadata.go b/apis/core/v1alpha1/resource_metadata.go index 7b55d5c..1821796 100644 --- a/apis/core/v1alpha1/resource_metadata.go +++ b/apis/core/v1alpha1/resource_metadata.go @@ -32,4 +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 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 b1b53bf..a6d9381 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 @@ -263,6 +250,159 @@ 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 + 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([]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 @@ -359,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. @@ -407,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 new file mode 100644 index 0000000..9477c90 --- /dev/null +++ b/config/crd/bases/services.k8s.aws_iamroleselectors.yaml @@ -0,0 +1,90 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: iamroleselectors.services.k8s.aws +spec: + group: services.k8s.aws + names: + kind: IAMRoleSelector + listKind: IAMRoleSelectorList + plural: iamroleselectors + singular: iamroleselector + scope: Cluster + 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 + x-kubernetes-validations: + - message: Value is immutable once set + rule: self == oldSelf + 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 + names: + items: + type: string + type: array + required: + - names + type: object + resourceTypeSelector: + items: + properties: + group: + type: string + kind: + type: string + version: + type: string + required: + - group + - kind + - version + 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/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/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/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/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..1d6ce31 --- /dev/null +++ b/pkg/runtime/iamroleselector/cache.go @@ -0,0 +1,225 @@ +// 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/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 + 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), + namespaces: ackcache.NewNamespaceCache(log, nil, nil), + } +} + +// Run starts the cache and blocks until stopCh is closed +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 + 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) + + c.namespaces.Run(namespaceClient, 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) + } + + namespaceName := metaObj.GetNamespace() + namespaceLabels := c.namespaces.GetLabels(namespaceName) + // 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(namespaceName, namespaceLabels, gvk) +} diff --git a/pkg/runtime/iamroleselector/cache_test.go b/pkg/runtime/iamroleselector/cache_test.go new file mode 100644 index 0000000..c49473f --- /dev/null +++ b/pkg/runtime/iamroleselector/cache_test.go @@ -0,0 +1,304 @@ +// 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" + k8sfake "k8s.io/client-go/kubernetes/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)) + + 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, k8sClient, 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: []ackv1alpha1.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: []ackv1alpha1.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..5130228 --- /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 []ackv1alpha1.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 []ackv1alpha1.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..753fcee --- /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: []ackv1alpha1.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: []ackv1alpha1.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: []ackv1alpha1.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: []ackv1alpha1.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: []ackv1alpha1.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: []ackv1alpha1.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: []ackv1alpha1.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: []ackv1alpha1.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: []ackv1alpha1.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 []ackv1alpha1.GroupVersionKind + gvk schema.GroupVersionKind + want bool + }{ + { + name: "empty selector matches all", + rtSelectors: []ackv1alpha1.GroupVersionKind{}, + gvk: schema.GroupVersionKind{ + Group: "s3.services.k8s.aws", + Version: "v1alpha1", + Kind: "Bucket", + }, + want: true, + }, + { + name: "exact match", + rtSelectors: []ackv1alpha1.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: []ackv1alpha1.GroupVersionKind{ + { + Kind: "Bucket", + }, + }, + gvk: schema.GroupVersionKind{ + Group: "s3.services.k8s.aws", + Version: "v1alpha1", + Kind: "Bucket", + }, + want: true, + }, + { + name: "partial match - only group", + rtSelectors: []ackv1alpha1.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: []ackv1alpha1.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: []ackv1alpha1.GroupVersionKind{ + { + Kind: "DBInstance", + }, + }, + gvk: schema.GroupVersionKind{ + Group: "s3.services.k8s.aws", + Version: "v1alpha1", + Kind: "Bucket", + }, + want: false, + }, + { + name: "no match - wrong group", + rtSelectors: []ackv1alpha1.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: []ackv1alpha1.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: []ackv1alpha1.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..318a679 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 } @@ -214,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, @@ -251,7 +254,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 +268,35 @@ 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 + // override the roleARN from CARM (if any) with the one from the selector. + selectors, err := r.irsCache.Matches( + desired.RuntimeObject(), + ) + 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) + selector = selectors[0] + 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() @@ -320,10 +352,22 @@ 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) + 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: @@ -345,7 +389,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 } @@ -1196,6 +1240,9 @@ func (r *resourceReconciler) getAWSResource( if err := r.apiReader.Get(ctx, req.NamespacedName, ro); err != nil { return nil, err } + if ro != nil && ro.GetObjectKind() != nil { + ro.GetObjectKind().SetGroupVersionKind(r.rd.GroupVersionKind()) + } return r.rd.ResourceFromRuntimeObject(ro), nil } @@ -1311,7 +1358,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 +1380,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 +1393,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 +1442,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 +1471,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 +1490,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 +1556,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 +1571,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 +1582,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..e3c3dc7 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 } @@ -330,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{} @@ -1913,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, diff --git a/pkg/runtime/service_controller.go b/pkg/runtime/service_controller.go index de8d6c6..65d4746 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. @@ -210,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. @@ -217,17 +225,12 @@ 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. - 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() + dynamicClient, err := dynamic.NewForConfig(clusterConfig) + if err != nil { + return err + } + irsCache.Run(dynamicClient, 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 }