diff --git a/apis/core/v1alpha1/annotations.go b/apis/core/v1alpha1/annotations.go index c2e8ea8..bf50ffd 100644 --- a/apis/core/v1alpha1/annotations.go +++ b/apis/core/v1alpha1/annotations.go @@ -35,6 +35,15 @@ const ( // TODO(jaypipes): Link to documentation on cross-account resource // management AnnotationOwnerAccountID = AnnotationPrefix + "owner-account-id" + // AnnotationTeamID is an annotation whose value is the identifier + // for the AWS team ID to manage the resources. If this annotation + // is set on a CR, the Kubernetes user is indicating that the ACK service + // controller should create/patch/delete the resource in the specified AWS + // role for this team ID. In order for this cross-account resource management + // to succeed, the AWS IAM Role that the ACK service controller runs as needs + // to have the ability to call the AWS STS::AssumeRole API call and assume an + // IAM Role in the target AWS Account. + AnnotationTeamID = AnnotationPrefix + "team-id" // AnnotationRegion is an annotation whose value is the identifier for the // the AWS region in which the resources should be created. If this annotation // is set on a CR metadata, that means the user is indicating to the ACK service diff --git a/apis/core/v1alpha1/common.go b/apis/core/v1alpha1/common.go index cbefbb7..b4519e4 100644 --- a/apis/core/v1alpha1/common.go +++ b/apis/core/v1alpha1/common.go @@ -19,6 +19,9 @@ type AWSRegion string // AWSAccountID represents an AWS account identifier type AWSAccountID string +// TeamID represents a team ID identifier. +type TeamID string + // AWSResourceName represents an AWS Resource Name (ARN) type AWSResourceName string diff --git a/mocks/pkg/types/aws_resource_identifiers.go b/mocks/pkg/types/aws_resource_identifiers.go index b093a52..fb9ef78 100644 --- a/mocks/pkg/types/aws_resource_identifiers.go +++ b/mocks/pkg/types/aws_resource_identifiers.go @@ -61,6 +61,22 @@ func (_m *AWSResourceIdentifiers) Region() *v1alpha1.AWSRegion { return r0 } +// TeamID provides a mock function with given fields: +func (_m *AWSResourceIdentifiers) TeamID() *v1alpha1.TeamID { + ret := _m.Called() + + var r0 *v1alpha1.TeamID + if rf, ok := ret.Get(0).(func() *v1alpha1.TeamID); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v1alpha1.TeamID) + } + } + + return r0 +} + // NewAWSResourceIdentifiers creates a new instance of AWSResourceIdentifiers. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewAWSResourceIdentifiers(t interface { diff --git a/pkg/runtime/adoption_reconciler.go b/pkg/runtime/adoption_reconciler.go index 4f0f1cb..6d898d3 100644 --- a/pkg/runtime/adoption_reconciler.go +++ b/pkg/runtime/adoption_reconciler.go @@ -17,6 +17,7 @@ import ( "context" "fmt" + "github.com/aws/aws-sdk-go/aws/arn" "github.com/go-logr/logr" "github.com/pkg/errors" corev1 "k8s.io/api/core/v1" @@ -108,6 +109,10 @@ func (r *adoptionReconciler) reconcile(ctx context.Context, req ctrlrt.Request) return ackerr.NotAdoptable } + // If a user specified a namespace with role ARN annotation, + // we need to get the role and set the accout ID to that role. + teamID := r.getTeamID(res) + // If a user has specified a namespace that is annotated with the // an owner account ID, we need an appropriate role ARN to assume // in order to perform the reconciliation. The roles ARN are typically @@ -115,20 +120,38 @@ func (r *adoptionReconciler) reconcile(ctx context.Context, req ctrlrt.Request) // If the ConfigMap is not created, or not populated with an // accountID to roleARN mapping, we need to properly requeue with a // helpful message to the user. - var roleARN ackv1alpha1.AWSResourceName acctID, needCARMLookup := r.getOwnerAccountID(res) + + var CARMLookupKey string + if teamID != "" { + CARMLookupKey = string(teamID) + needCARMLookup = true + } else { + CARMLookupKey = string(acctID) + } + + var roleARN ackv1alpha1.AWSResourceName if needCARMLookup { // This means that the user is specifying a namespace that is - // annotated with an owner account ID. We need to retrieve the + // annotated with an owner account ID or team ID. We need to retrieve the // roleARN from the ConfigMap and properly requeue if the roleARN // is not available. - roleARN, err = r.getRoleARN(acctID) + roleARN, err = r.getRoleARN(CARMLookupKey) if err != nil { ackrtlog.InfoAdoptedResource(r.log, res, fmt.Sprintf("Unable to start adoption reconcilliation %s: %v", acctID, err)) // r.getRoleARN errors are not terminal, we should requeue. return requeue.NeededAfter(err, roleARNNotAvailableRequeueDelay) } } + + if teamID != "" { + parsedARN, err := arn.Parse(string(roleARN)) + if err != nil { + return fmt.Errorf("failed to parsed role ARN %q from namespace annotation: %v", teamID, err) + } + acctID = ackv1alpha1.AWSAccountID(parsedARN.AccountID) + } + region := r.getRegion(res) targetDescriptor := rmf.ResourceDescriptor() endpointURL := r.getEndpointURL(res) @@ -460,6 +483,20 @@ func (r *adoptionReconciler) getOwnerAccountID( return ackv1alpha1.AWSAccountID(r.cfg.AccountID), false } +// getTeamID gets the team-id from the namespace +// annotation. +func (r *adoptionReconciler) getTeamID( + res *ackv1alpha1.AdoptedResource, +) ackv1alpha1.TeamID { + // look for team-id in the namespace annotations + namespace := res.GetNamespace() + teamID, ok := r.cache.Namespaces.GetTeamID(namespace) + if ok { + return ackv1alpha1.TeamID(teamID) + } + return ackv1alpha1.TeamID("") +} + // getEndpointURL returns the AWS account that owns the supplied resource. // We look for the namespace associated endpoint url, if that is set we use it. // Otherwise if none of these annotations are set we use the endpoint url specified @@ -481,11 +518,11 @@ func (r *adoptionReconciler) getEndpointURL( // getRoleARN return the Role ARN that should be assumed in order to manage // the resources. func (r *adoptionReconciler) getRoleARN( - acctID ackv1alpha1.AWSAccountID, + key string, ) (ackv1alpha1.AWSResourceName, error) { - roleARN, err := r.cache.Accounts.GetAccountRoleARN(string(acctID)) + roleARN, err := r.cache.Accounts.GetRoleARN(key) if err != nil { - return "", fmt.Errorf("unable to retrieve role ARN for account %s: %v", acctID, err) + return "", fmt.Errorf("unable to retrieve role ARN for annotation %q: %v", key, err) } return ackv1alpha1.AWSResourceName(roleARN), nil } diff --git a/pkg/runtime/cache/account.go b/pkg/runtime/cache/account.go index 8f275e2..41e2174 100644 --- a/pkg/runtime/cache/account.go +++ b/pkg/runtime/cache/account.go @@ -15,6 +15,7 @@ package cache import ( "errors" + "fmt" "sync" "github.com/go-logr/logr" @@ -28,49 +29,57 @@ var ( // ErrCARMConfigMapNotFound is an error that is returned when the CARM // configmap is not found. ErrCARMConfigMapNotFound = errors.New("CARM configmap not found") - // ErrAccountIDNotFound is an error that is returned when the account ID + // ErrKeyNotFound is an error that is returned when the key // is not found in the CARM configmap. - ErrAccountIDNotFound = errors.New("account ID not found in CARM configmap") + ErrKeyNotFound = errors.New("key not found in CARM configmap") // ErrEmptyRoleARN is an error that is returned when the role ARN is empty // in the CARM configmap. ErrEmptyRoleARN = errors.New("role ARN is empty in CARM configmap") ) +type CARMName string + const ( // ACKRoleAccountMap is the name of the configmap map object storing // all the AWS Account IDs associated with their AWS Role ARNs. - ACKRoleAccountMap = "ack-role-account-map" + ACKRoleAccountMap CARMName = "ack-role-account-map" + + // ACKRoleTeamMap is the name of the configmap map object storing + // all the AWS Team IDs associated with their AWS Role ARNs. + ACKRoleTeamMap CARMName = "ack-role-team-map" ) -// AccountCache is responsible for caching the CARM configmap +// CARMCache is responsible for caching the CARM configmap // data. It is listening to all the events related to the CARM map and // make the changes accordingly. -type AccountCache struct { +type CARMCache struct { sync.RWMutex + name CARMName log logr.Logger roleARNs map[string]string configMapCreated bool } -// NewAccountCache instanciate a new AccountCache. -func NewAccountCache(log logr.Logger) *AccountCache { - return &AccountCache{ - log: log.WithName("cache.account"), +// NewCARMCache instanciate a new CARMCache. +func NewCARMCache(name CARMName, log logr.Logger) *CARMCache { + return &CARMCache{ + log: log.WithName(fmt.Sprintf("cache.%s", name)), + name: name, roleARNs: make(map[string]string), configMapCreated: false, } } -// resourceMatchACKRoleAccountConfigMap verifies if a resource is +// resourceMatchACKRoleConfigMap verifies if a resource is // the CARM configmap. It verifies the name, namespace and object type. -func resourceMatchACKRoleAccountsConfigMap(raw interface{}) bool { +func resourceMatchACKRoleConfigMap(name CARMName, raw interface{}) bool { object, ok := raw.(*corev1.ConfigMap) - return ok && object.ObjectMeta.Name == ACKRoleAccountMap + return ok && object.ObjectMeta.Name == string(name) } // Run instantiate a new SharedInformer for ConfigMaps and runs it to begin processing items. -func (c *AccountCache) Run(clientSet kubernetes.Interface, stopCh <-chan struct{}) { - c.log.V(1).Info("Starting shared informer for accounts cache", "targetConfigMap", ACKRoleAccountMap) +func (c *CARMCache) Run(clientSet kubernetes.Interface, stopCh <-chan struct{}) { + c.log.V(1).Info("Starting shared informer for CARM cache", "targetConfigMap", c.name) informer := informersv1.NewConfigMapInformer( clientSet, ackSystemNamespace, @@ -79,56 +88,56 @@ func (c *AccountCache) Run(clientSet kubernetes.Interface, stopCh <-chan struct{ ) informer.AddEventHandler(k8scache.ResourceEventHandlerFuncs{ AddFunc: func(obj interface{}) { - if resourceMatchACKRoleAccountsConfigMap(obj) { + if resourceMatchACKRoleConfigMap(c.name, obj) { cm := obj.(*corev1.ConfigMap) object := cm.DeepCopy() // To avoid multiple mutex locks, we are updating the cache // and the configmap existence flag in the same function. configMapCreated := true - c.updateAccountRoleData(configMapCreated, object.Data) - c.log.V(1).Info("created account config map", "name", cm.ObjectMeta.Name) + c.updateRoleData(configMapCreated, object.Data) + c.log.V(1).Info("created config map", "name", cm.ObjectMeta.Name) } }, UpdateFunc: func(orig, desired interface{}) { - if resourceMatchACKRoleAccountsConfigMap(desired) { + if resourceMatchACKRoleConfigMap(c.name, desired) { cm := desired.(*corev1.ConfigMap) object := cm.DeepCopy() //TODO(a-hilaly): compare data checksum before updating the cache - c.updateAccountRoleData(true, object.Data) - c.log.V(1).Info("updated account config map", "name", cm.ObjectMeta.Name) + c.updateRoleData(true, object.Data) + c.log.V(1).Info("updated config map", "name", cm.ObjectMeta.Name) } }, DeleteFunc: func(obj interface{}) { - if resourceMatchACKRoleAccountsConfigMap(obj) { + if resourceMatchACKRoleConfigMap(c.name, obj) { cm := obj.(*corev1.ConfigMap) newMap := make(map[string]string) // To avoid multiple mutex locks, we are updating the cache // and the configmap existence flag in the same function. configMapCreated := false - c.updateAccountRoleData(configMapCreated, newMap) - c.log.V(1).Info("deleted account config map", "name", cm.ObjectMeta.Name) + c.updateRoleData(configMapCreated, newMap) + c.log.V(1).Info("deleted config map", "name", cm.ObjectMeta.Name) } }, }) go informer.Run(stopCh) } -// GetAccountRoleARN queries the AWS accountID associated Role ARN +// GetRoleARN queries the associated Role ARN // from the cached CARM configmap. It will return an error if the -// configmap is not found, the accountID is not found or the role ARN +// configmap is not found, the key is not found or the role ARN // is empty. // // This function is thread safe. -func (c *AccountCache) GetAccountRoleARN(accountID string) (string, error) { +func (c *CARMCache) GetRoleARN(key string) (string, error) { c.RLock() defer c.RUnlock() if !c.configMapCreated { return "", ErrCARMConfigMapNotFound } - roleARN, ok := c.roleARNs[accountID] + roleARN, ok := c.roleARNs[key] if !ok { - return "", ErrAccountIDNotFound + return "", ErrKeyNotFound } if roleARN == "" { return "", ErrEmptyRoleARN @@ -136,8 +145,8 @@ func (c *AccountCache) GetAccountRoleARN(accountID string) (string, error) { return roleARN, nil } -// updateAccountRoleData updates the CARM map. This function is thread safe. -func (c *AccountCache) updateAccountRoleData(exist bool, data map[string]string) { +// updateRoleData updates the CARM map. This function is thread safe. +func (c *CARMCache) updateRoleData(exist bool, data map[string]string) { c.Lock() defer c.Unlock() c.roleARNs = data diff --git a/pkg/runtime/cache/account_test.go b/pkg/runtime/cache/account_test.go index 9ae24cb..d40ea9e 100644 --- a/pkg/runtime/cache/account_test.go +++ b/pkg/runtime/cache/account_test.go @@ -27,6 +27,7 @@ import ( k8stesting "k8s.io/client-go/testing" ctrlrtzap "sigs.k8s.io/controller-runtime/pkg/log/zap" + "github.com/aws-controllers-k8s/runtime/pkg/runtime/cache" ackrtcache "github.com/aws-controllers-k8s/runtime/pkg/runtime/cache" ) @@ -64,13 +65,13 @@ func TestAccountCache(t *testing.T) { fakeLogger := ctrlrtzap.New(ctrlrtzap.UseFlagOptions(&zapOptions)) // initlizing account cache - accountCache := ackrtcache.NewAccountCache(fakeLogger) + accountCache := ackrtcache.NewCARMCache(cache.ACKRoleAccountMap, fakeLogger) stopCh := make(chan struct{}) accountCache.Run(k8sClient, stopCh) // Before creating the configmap, the accountCache should error for any // GetAccountRoleARN call. - _, err := accountCache.GetAccountRoleARN(testAccount1) + _, err := accountCache.GetRoleARN(testAccount1) require.NotNil(t, err) require.Equal(t, err, ackrtcache.ErrCARMConfigMapNotFound) @@ -90,12 +91,12 @@ func TestAccountCache(t *testing.T) { time.Sleep(time.Second) // Test with non existing account - _, err = accountCache.GetAccountRoleARN("random-account-not-exist") + _, err = accountCache.GetRoleARN("random-account-not-exist") require.NotNil(t, err) require.Equal(t, err, ackrtcache.ErrCARMConfigMapNotFound) // Test with existing account - _, err = accountCache.GetAccountRoleARN(testAccount1) + _, err = accountCache.GetRoleARN(testAccount1) require.NotNil(t, err) require.Equal(t, err, ackrtcache.ErrCARMConfigMapNotFound) @@ -103,7 +104,7 @@ func TestAccountCache(t *testing.T) { context.Background(), &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ - Name: ackrtcache.ACKRoleAccountMap, + Name: string(ackrtcache.ACKRoleAccountMap), Namespace: "ack-system", }, Data: accountsMap1, @@ -114,17 +115,17 @@ func TestAccountCache(t *testing.T) { time.Sleep(time.Second) // Test with non existing account - _, err = accountCache.GetAccountRoleARN("random-account-not-exist") + _, err = accountCache.GetRoleARN("random-account-not-exist") require.NotNil(t, err) - require.Equal(t, err, ackrtcache.ErrAccountIDNotFound) + require.Equal(t, err, ackrtcache.ErrKeyNotFound) // Test with existing account - but role ARN is empty - _, err = accountCache.GetAccountRoleARN(testAccount3) + _, err = accountCache.GetRoleARN(testAccount3) require.NotNil(t, err) require.Equal(t, err, ackrtcache.ErrEmptyRoleARN) // Test with existing account - roleARN, err := accountCache.GetAccountRoleARN(testAccount1) + roleARN, err := accountCache.GetRoleARN(testAccount1) require.Nil(t, err) require.Equal(t, roleARN, testAccountARN1) @@ -133,7 +134,7 @@ func TestAccountCache(t *testing.T) { context.Background(), &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ - Name: ackrtcache.ACKRoleAccountMap, + Name: string(ackrtcache.ACKRoleAccountMap), Namespace: "ack-system", }, Data: accountsMap2, @@ -144,43 +145,43 @@ func TestAccountCache(t *testing.T) { time.Sleep(time.Second) // Test with non existing account - _, err = accountCache.GetAccountRoleARN("random-account-not-exist") + _, err = accountCache.GetRoleARN("random-account-not-exist") require.NotNil(t, err) - require.Equal(t, err, ackrtcache.ErrAccountIDNotFound) + require.Equal(t, err, ackrtcache.ErrKeyNotFound) // Test that account was removed - _, err = accountCache.GetAccountRoleARN(testAccount3) + _, err = accountCache.GetRoleARN(testAccount3) require.NotNil(t, err) - require.Equal(t, err, ackrtcache.ErrAccountIDNotFound) + require.Equal(t, err, ackrtcache.ErrKeyNotFound) // Test with existing account - roleARN, err = accountCache.GetAccountRoleARN(testAccount1) + roleARN, err = accountCache.GetRoleARN(testAccount1) require.Nil(t, err) require.Equal(t, roleARN, testAccountARN1) - roleARN, err = accountCache.GetAccountRoleARN(testAccount2) + roleARN, err = accountCache.GetRoleARN(testAccount2) require.Nil(t, err) require.Equal(t, roleARN, testAccountARN2) // Test delete events k8sClient.CoreV1().ConfigMaps("ack-system").Delete( context.Background(), - ackrtcache.ACKRoleAccountMap, + string(ackrtcache.ACKRoleAccountMap), metav1.DeleteOptions{}, ) time.Sleep(time.Second) // Test that accounts ware removed - _, err = accountCache.GetAccountRoleARN(testAccount1) + _, err = accountCache.GetRoleARN(testAccount1) require.NotNil(t, err) require.Equal(t, err, ackrtcache.ErrCARMConfigMapNotFound) - _, err = accountCache.GetAccountRoleARN(testAccount2) + _, err = accountCache.GetRoleARN(testAccount2) require.NotNil(t, err) require.Equal(t, err, ackrtcache.ErrCARMConfigMapNotFound) - _, err = accountCache.GetAccountRoleARN(testAccount3) + _, err = accountCache.GetRoleARN(testAccount3) require.NotNil(t, err) require.Equal(t, err, ackrtcache.ErrCARMConfigMapNotFound) } diff --git a/pkg/runtime/cache/cache.go b/pkg/runtime/cache/cache.go index e77fae3..015bbd7 100644 --- a/pkg/runtime/cache/cache.go +++ b/pkg/runtime/cache/cache.go @@ -73,7 +73,10 @@ type Caches struct { stopCh chan struct{} // Accounts cache - Accounts *AccountCache + Accounts *CARMCache + + // Team-ID cache + Teams *CARMCache // Namespaces cache Namespaces *NamespaceCache @@ -82,7 +85,8 @@ type Caches struct { // New instantiate a new Caches object. func New(log logr.Logger, config Config) Caches { return Caches{ - Accounts: NewAccountCache(log), + Accounts: NewCARMCache(ACKRoleAccountMap, log), + Teams: NewCARMCache(ACKRoleTeamMap, log), Namespaces: NewNamespaceCache(log, config.WatchScope, config.Ignored), } } @@ -93,6 +97,9 @@ func (c Caches) Run(clientSet kubernetes.Interface) { if c.Accounts != nil { c.Accounts.Run(clientSet, stopCh) } + if c.Teams != nil { + c.Teams.Run(clientSet, stopCh) + } if c.Namespaces != nil { c.Namespaces.Run(clientSet, stopCh) } diff --git a/pkg/runtime/cache/namespace.go b/pkg/runtime/cache/namespace.go index d2e2326..4f9ccfa 100644 --- a/pkg/runtime/cache/namespace.go +++ b/pkg/runtime/cache/namespace.go @@ -32,6 +32,8 @@ type namespaceInfo struct { defaultRegion string // services.k8s.aws/owner-account-id Annotation ownerAccountID string + // services.k8s.aws/team-id Annotation + teamID string // services.k8s.aws/endpoint-url Annotation endpointURL string // {service}.services.k8s.aws/deletion-policy Annotations (keyed by service) @@ -54,6 +56,14 @@ func (n *namespaceInfo) getOwnerAccountID() string { return n.ownerAccountID } +// getTeamID returns the namespace team-id +func (n *namespaceInfo) getTeamID() string { + if n == nil { + return "" + } + return n.teamID +} + // getEndpointURL returns the namespace Endpoint URL func (n *namespaceInfo) getEndpointURL() string { if n == nil { @@ -182,6 +192,16 @@ func (c *NamespaceCache) GetOwnerAccountID(namespace string) (string, bool) { return "", false } +// GetTeamID returns the team-id if it exists +func (c *NamespaceCache) GetTeamID(namespace string) (string, bool) { + info, ok := c.getNamespaceInfo(namespace) + if ok { + a := info.getTeamID() + return a, a != "" + } + return "", false +} + // GetEndpointURL returns the endpoint URL if it exists func (c *NamespaceCache) GetEndpointURL(namespace string) (string, bool) { info, ok := c.getNamespaceInfo(namespace) @@ -225,6 +245,10 @@ func (c *NamespaceCache) setNamespaceInfoFromK8sObject(ns *corev1.Namespace) { if ok { nsInfo.ownerAccountID = OwnerAccountID } + TeamID, ok := nsa[ackv1alpha1.AnnotationTeamID] + if ok { + nsInfo.teamID = TeamID + } EndpointURL, ok := nsa[ackv1alpha1.AnnotationEndpointURL] if ok { nsInfo.endpointURL = EndpointURL diff --git a/pkg/runtime/cache/namespace_test.go b/pkg/runtime/cache/namespace_test.go index 86fb240..121878d 100644 --- a/pkg/runtime/cache/namespace_test.go +++ b/pkg/runtime/cache/namespace_test.go @@ -131,6 +131,101 @@ func TestNamespaceCache(t *testing.T) { require.False(t, ok) } +func TestNamespaceCacheWithRoleARN(t *testing.T) { + // create a fake k8s client and fake watcher + k8sClient := k8sfake.NewSimpleClientset() + watcher := watch.NewFake() + k8sClient.PrependWatchReactor("production", k8stesting.DefaultWatchReactor(watcher, nil)) + + // New logger writing to specific buffer + zapOptions := ctrlrtzap.Options{ + Development: true, + Level: zapcore.InfoLevel, + } + fakeLogger := ctrlrtzap.New(ctrlrtzap.UseFlagOptions(&zapOptions)) + + // initlizing account cache + namespaceCache := ackrtcache.NewNamespaceCache(fakeLogger, []string{}, []string{}) + stopCh := make(chan struct{}) + + namespaceCache.Run(k8sClient, stopCh) + + // Test create events + _, err := k8sClient.CoreV1().Namespaces().Create( + context.Background(), + &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "production", + Annotations: map[string]string{ + ackv1alpha1.AnnotationDefaultRegion: "us-west-2", + ackv1alpha1.AnnotationTeamID: "team-a", + ackv1alpha1.AnnotationEndpointURL: "https://amazon-service.region.amazonaws.com", + }, + }, + }, + metav1.CreateOptions{}, + ) + require.Nil(t, err) + + time.Sleep(time.Second) + + defaultRegion, ok := namespaceCache.GetDefaultRegion("production") + require.True(t, ok) + require.Equal(t, "us-west-2", defaultRegion) + + teamID, ok := namespaceCache.GetTeamID("production") + require.True(t, ok) + require.Equal(t, "team-a", teamID) + + endpointURL, ok := namespaceCache.GetEndpointURL("production") + require.True(t, ok) + require.Equal(t, "https://amazon-service.region.amazonaws.com", endpointURL) + + // Test update events + _, err = k8sClient.CoreV1().Namespaces().Update( + context.Background(), + &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "production", + Annotations: map[string]string{ + ackv1alpha1.AnnotationDefaultRegion: "us-est-1", + ackv1alpha1.AnnotationTeamID: "team-b", + ackv1alpha1.AnnotationEndpointURL: "https://amazon-other-service.region.amazonaws.com", + }, + }, + }, + metav1.UpdateOptions{}, + ) + require.Nil(t, err) + + time.Sleep(time.Second) + + defaultRegion, ok = namespaceCache.GetDefaultRegion("production") + require.True(t, ok) + require.Equal(t, "us-est-1", defaultRegion) + + teamID, ok = namespaceCache.GetTeamID("production") + require.True(t, ok) + require.Equal(t, "team-b", teamID) + + endpointURL, ok = namespaceCache.GetEndpointURL("production") + require.True(t, ok) + require.Equal(t, "https://amazon-other-service.region.amazonaws.com", endpointURL) + + // Test delete events + err = k8sClient.CoreV1().Namespaces().Delete( + context.Background(), + "production", + metav1.DeleteOptions{}, + ) + require.Nil(t, err) + + time.Sleep(time.Second) + + _, ok = namespaceCache.GetDefaultRegion(testNamespace1) + require.False(t, ok) +} + func TestScopedNamespaceCache(t *testing.T) { defaultConfig := ackrtcache.Config{ WatchScope: []string{"watch-scope", "watch-scope-2"}, diff --git a/pkg/runtime/reconciler.go b/pkg/runtime/reconciler.go index d5956d3..900e877 100644 --- a/pkg/runtime/reconciler.go +++ b/pkg/runtime/reconciler.go @@ -20,6 +20,7 @@ import ( "strings" "time" + "github.com/aws/aws-sdk-go/aws/arn" backoff "github.com/cenkalti/backoff/v4" "github.com/go-logr/logr" "github.com/pkg/errors" @@ -39,6 +40,7 @@ import ( ackerr "github.com/aws-controllers-k8s/runtime/pkg/errors" ackmetrics "github.com/aws-controllers-k8s/runtime/pkg/metrics" "github.com/aws-controllers-k8s/runtime/pkg/requeue" + "github.com/aws-controllers-k8s/runtime/pkg/runtime/cache" ackrtcache "github.com/aws-controllers-k8s/runtime/pkg/runtime/cache" ackrtlog "github.com/aws-controllers-k8s/runtime/pkg/runtime/log" acktypes "github.com/aws-controllers-k8s/runtime/pkg/types" @@ -176,6 +178,10 @@ func (r *resourceReconciler) Reconcile(ctx context.Context, req ctrlrt.Request) // will be reflected in the context. ctx = context.WithValue(ctx, ackrtlog.ContextKey, rlog) + // If a user specified a namespace with team-id annotation, + // we need to get the role and set the accout ID to that role. + teamID := r.getTeamID(desired) + // If a user has specified a namespace that is annotated with the // an owner account ID, we need an appropriate role ARN to assume // in order to perform the reconciliation. The roles ARN are typically @@ -183,14 +189,26 @@ func (r *resourceReconciler) Reconcile(ctx context.Context, req ctrlrt.Request) // If the ConfigMap is not created, or not populated with an // accountID to roleARN mapping, we need to properly requeue with a // helpful message to the user. - var roleARN ackv1alpha1.AWSResourceName acctID, needCARMLookup := r.getOwnerAccountID(desired) + + var cmName cache.CARMName + var CARMLookupKey string + if teamID != "" { + CARMLookupKey = string(teamID) + needCARMLookup = true + cmName = cache.ACKRoleTeamMap + } else { + CARMLookupKey = string(acctID) + cmName = cache.ACKRoleAccountMap + } + + var roleARN ackv1alpha1.AWSResourceName if needCARMLookup { // This means that the user is specifying a namespace that is - // annotated with an owner account ID. We need to retrieve the + // annotated with an owner account ID or team ID. We need to retrieve the // roleARN from the ConfigMap and properly requeue if the roleARN // is not available. - roleARN, err = r.getRoleARN(acctID) + roleARN, err = r.getRoleARN(CARMLookupKey, cmName) if err != nil { // TODO(a-hilaly): Refactor all the reconcile function to make it // easier to understand and maintain. @@ -201,6 +219,15 @@ func (r *resourceReconciler) Reconcile(ctx context.Context, req ctrlrt.Request) return r.HandleReconcileError(ctx, desired, latest, requeue.NeededAfter(err, roleARNNotAvailableRequeueDelay)) } } + + if teamID != "" { + parsedARN, err := arn.Parse(string(roleARN)) + if err != nil { + return ctrlrt.Result{}, fmt.Errorf("failed to parsed role ARN %q from namespace annotation: %v", roleARN, err) + } + acctID = ackv1alpha1.AWSAccountID(parsedARN.AccountID) + } + region := r.getRegion(desired) endpointURL := r.getEndpointURL(desired) gvk := r.rd.GroupVersionKind() @@ -1040,16 +1067,39 @@ func (r *resourceReconciler) getOwnerAccountID( return controllerAccountID, false } +// getTeamID gets the team-id from the namespace annotation. +func (r *resourceReconciler) getTeamID( + res acktypes.AWSResource, +) ackv1alpha1.TeamID { + + // look for role ARN in the namespace annotations + namespace := res.MetaObject().GetNamespace() + namespacedTeamID, ok := r.cache.Namespaces.GetTeamID(namespace) + if ok { + return ackv1alpha1.TeamID(namespacedTeamID) + } + return ackv1alpha1.TeamID("") +} + // getRoleARN return the Role ARN that should be assumed in order to manage // the resources. func (r *resourceReconciler) getRoleARN( - acctID ackv1alpha1.AWSAccountID, + key string, cmName cache.CARMName, ) (ackv1alpha1.AWSResourceName, error) { - roleARN, err := r.cache.Accounts.GetAccountRoleARN(string(acctID)) - if err != nil { - return "", fmt.Errorf("unable to retrieve role ARN for account %s: %v", acctID, err) + if cmName == cache.ACKRoleAccountMap { + roleARN, err := r.cache.Accounts.GetRoleARN(key) + if err != nil { + return "", fmt.Errorf("unable to retrieve role ARN for account %s: %v", key, err) + } + return ackv1alpha1.AWSResourceName(roleARN), nil + } else if cmName == cache.ACKRoleTeamMap { + roleARN, err := r.cache.Teams.GetRoleARN(key) + if err != nil { + return "", fmt.Errorf("unable to retrieve role ARN for team ID %s: %v", key, err) + } + return ackv1alpha1.AWSResourceName(roleARN), nil } - return ackv1alpha1.AWSResourceName(roleARN), nil + return "", fmt.Errorf("unexpected CARM name %q", cmName) } // getRegion returns the region the resource exists in, or if the resource diff --git a/pkg/types/aws_resource_identifiers.go b/pkg/types/aws_resource_identifiers.go index d30cf6c..07489b4 100644 --- a/pkg/types/aws_resource_identifiers.go +++ b/pkg/types/aws_resource_identifiers.go @@ -23,6 +23,9 @@ type AWSResourceIdentifiers interface { // OwnerAccountID returns the AWS account identifier in which the // backend AWS resource resides, or should reside in. OwnerAccountID() *ackv1alpha1.AWSAccountID + // TeamID returns the AWS team-id identifier in which the + // backend AWS resource resides, or should reside in. + TeamID() *ackv1alpha1.TeamID // ARN returns the AWS Resource Name for the backend AWS resource. If nil, // this means the resource has not yet been created in the backend AWS // service.