Skip to content

Commit

Permalink
Support role annotation
Browse files Browse the repository at this point in the history
  • Loading branch information
zicongmei committed Mar 11, 2024
1 parent 20e1c0d commit 4e36209
Show file tree
Hide file tree
Showing 8 changed files with 242 additions and 12 deletions.
9 changes: 9 additions & 0 deletions apis/core/v1alpha1/annotations.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions apis/core/v1alpha1/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 16 additions & 0 deletions mocks/pkg/types/aws_resource_identifiers.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

49 changes: 43 additions & 6 deletions pkg/runtime/adoption_reconciler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -108,27 +109,49 @@ 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
// stored in a ConfigMap in the ACK system namespace.
// 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)
Expand Down Expand Up @@ -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
Expand All @@ -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.GetAccountRoleARN(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
}
Expand Down
24 changes: 24 additions & 0 deletions pkg/runtime/cache/namespace.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
95 changes: 95 additions & 0 deletions pkg/runtime/cache/namespace_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
55 changes: 49 additions & 6 deletions pkg/runtime/reconciler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -176,21 +177,34 @@ 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
// stored in a ConfigMap in the ACK system namespace.
// 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 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 {
// TODO(a-hilaly): Refactor all the reconcile function to make it
// easier to understand and maintain.
Expand All @@ -201,6 +215,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()
Expand Down Expand Up @@ -1040,14 +1063,34 @@ 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 owner account id in the resource status
teamID := res.Identifiers().TeamID()
if teamID != nil {
return *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,
) (ackv1alpha1.AWSResourceName, error) {
roleARN, err := r.cache.Accounts.GetAccountRoleARN(string(acctID))
roleARN, err := r.cache.Accounts.GetAccountRoleARN(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
}
Expand Down
Loading

0 comments on commit 4e36209

Please sign in to comment.