diff --git a/cmd/kubectl-datadog/autoscaling/cluster/common/clients/clients.go b/cmd/kubectl-datadog/autoscaling/cluster/common/clients/clients.go index 4231e24707..330617c3e7 100644 --- a/cmd/kubectl-datadog/autoscaling/cluster/common/clients/clients.go +++ b/cmd/kubectl-datadog/autoscaling/cluster/common/clients/clients.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "log" + "strings" awssdk "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/aws/arn" @@ -56,6 +57,15 @@ func Build(ctx context.Context, configFlags *genericclioptions.ConfigFlags, k8sC return nil, fmt.Errorf("failed to load AWS config: %w", err) } + // Reconcile the AWS region with the target EKS cluster before building the + // service clients (so a missing AWS_REGION is derived from the kubeconfig or + // reported clearly rather than as an opaque STS failure, and a region + // pointing elsewhere than the cluster is rejected). + awsConfig, err = reconcileRegion(ctx, awsConfig, configFlags) + if err != nil { + return nil, err + } + sch := runtime.NewScheme() if err = scheme.AddToScheme(sch); err != nil { @@ -158,33 +168,108 @@ func ResolveClusterName(configFlags *genericclioptions.ConfigFlags, explicit str return name, nil } -// getAccountIDFromKubeconfig attempts to extract the AWS account ID from the -// kubeconfig context. Returns an empty string if the context is not an EKS ARN. -func getAccountIDFromKubeconfig(configFlags *genericclioptions.ConfigFlags) (string, error) { +// getClusterARNFromKubeconfig returns the EKS cluster ARN parsed from the +// kubeconfig context. ok is false when the context is absent or its cluster +// field is not an ARN (e.g. plain name, eksctl FQDN) — treated as a normal +// fallback, not an error. The parsed ARN carries both the AWS account ID and +// the region, which are independent of the AWS credentials and cannot be +// fooled by same-named clusters in other accounts or regions. +func getClusterARNFromKubeconfig(configFlags *genericclioptions.ConfigFlags) (arn.ARN, bool, error) { kubeRawConfig, kubeContext, err := resolveKubeContext(configFlags) if err != nil || kubeContext == "" { - return "", err + return arn.ARN{}, false, err } kubeCtx, exists := kubeRawConfig.Contexts[kubeContext] if !exists { - return "", fmt.Errorf("kube context %q doesn’t exist", kubeContext) + return arn.ARN{}, false, fmt.Errorf("kube context %q doesn’t exist", kubeContext) } - // The kubeconfig cluster field may not be an ARN (e.g. plain name, - // eksctl FQDN). Treat that as a normal fallback, not an error. if !arn.IsARN(kubeCtx.Cluster) { - return "", nil + return arn.ARN{}, false, nil } parsed, err := arn.Parse(kubeCtx.Cluster) if err != nil { - return "", fmt.Errorf("failed to parse EKS cluster ARN %q: %w", kubeCtx.Cluster, err) + return arn.ARN{}, false, fmt.Errorf("failed to parse EKS cluster ARN %q: %w", kubeCtx.Cluster, err) + } + + // Only an EKS cluster ARN carries the account/region we rely on. Any other + // ARN that happens to sit in the cluster field is treated as a normal + // non-ARN fallback rather than a misleading source of identity. + if parsed.Service != "eks" || !strings.HasPrefix(parsed.Resource, "cluster/") { + return arn.ARN{}, false, nil } + return parsed, true, nil +} + +// getAccountIDFromKubeconfig attempts to extract the AWS account ID from the +// kubeconfig context. Returns an empty string if the context is not an EKS ARN. +func getAccountIDFromKubeconfig(configFlags *genericclioptions.ConfigFlags) (string, error) { + parsed, ok, err := getClusterARNFromKubeconfig(configFlags) + if err != nil || !ok { + return "", err + } return parsed.AccountID, nil } +// resolveRegion reconciles the AWS region from the default credential chain +// (empty when unset) with the cluster's region derived from the kubeconfig +// context ARN (empty when the context is not an ARN): +// - both empty: error — the region cannot be determined. +// - configured region empty: derive it from the kubeconfig. +// - both set but different: RegionMismatchError. +// - otherwise: keep the configured region. +func resolveRegion(configRegion, kubeRegion, clusterName string) (string, error) { + switch { + case configRegion == "" && kubeRegion == "": + return "", errors.New("AWS region is not configured and could not be derived from the kubeconfig context; set the AWS_REGION environment variable or configure a region in your AWS profile") + case configRegion == "": + return kubeRegion, nil + case kubeRegion != "" && kubeRegion != configRegion: + return "", &RegionMismatchError{ + ConfigRegion: configRegion, + ClusterRegion: kubeRegion, + ClusterName: clusterName, + } + default: + return configRegion, nil + } +} + +// reconcileRegion ensures awsConfig targets the same AWS region as the EKS +// cluster from the kubeconfig context. When no region is configured it is +// derived from the kubeconfig and the config is reloaded with it, so credential +// providers built during config load (e.g. assume-role / web-identity STS +// clients) also use it rather than only the service clients created afterward. +// A configured region that differs from the cluster's is rejected with a +// RegionMismatchError. +func reconcileRegion(ctx context.Context, awsConfig awssdk.Config, configFlags *genericclioptions.ConfigFlags) (awssdk.Config, error) { + var kubeRegion, clusterName string + if parsed, ok, err := getClusterARNFromKubeconfig(configFlags); err != nil { + log.Printf("Warning: failed to read AWS region from kubeconfig: %v", err) + } else if ok { + kubeRegion = parsed.Region + clusterName = strings.TrimPrefix(parsed.Resource, "cluster/") + } + + region, err := resolveRegion(awsConfig.Region, kubeRegion, clusterName) + if err != nil { + return awssdk.Config{}, err + } + if region == awsConfig.Region { + return awsConfig, nil + } + + log.Printf("AWS region not set; using %q from the kubeconfig context.", region) + awsConfig, err = config.LoadDefaultConfig(ctx, config.WithRegion(region)) + if err != nil { + return awssdk.Config{}, fmt.Errorf("failed to load AWS config: %w", err) + } + return awsConfig, nil +} + // GetAWSAccountID returns the AWS account ID from the current credentials. func GetAWSAccountID(ctx context.Context, cli *Clients) (string, error) { callerIdentity, err := cli.STS.GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) @@ -266,6 +351,23 @@ func (e *AccountMismatchError) Error() string { ) } +// RegionMismatchError indicates that the configured AWS region and the EKS +// cluster's region (derived from the kubeconfig context ARN) differ. +type RegionMismatchError struct { + ConfigRegion string + ClusterRegion string + ClusterName string +} + +func (e *RegionMismatchError) Error() string { + return fmt.Sprintf( + "AWS region mismatch: the configured AWS region is %s, "+ + "but EKS cluster %q is in region %s; "+ + "set AWS_REGION to %s (or select a kubeconfig context for a cluster in %s)", + e.ConfigRegion, e.ClusterName, e.ClusterRegion, e.ClusterRegion, e.ConfigRegion, + ) +} + // ClusterLookupUnavailableError wraps EKS.DescribeCluster failures with a // ResourceNotFoundException — the cluster does not exist (e.g. already // deleted). Callers such as uninstall may choose to proceed on this error. diff --git a/cmd/kubectl-datadog/autoscaling/cluster/common/clients/clients_test.go b/cmd/kubectl-datadog/autoscaling/cluster/common/clients/clients_test.go index a92d52d1c4..db3ba8f85f 100644 --- a/cmd/kubectl-datadog/autoscaling/cluster/common/clients/clients_test.go +++ b/cmd/kubectl-datadog/autoscaling/cluster/common/clients/clients_test.go @@ -1,10 +1,13 @@ package clients import ( + "context" + "errors" "os" "path/filepath" "testing" + awssdk "github.com/aws/aws-sdk-go-v2/aws" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/cli-runtime/pkg/genericclioptions" @@ -149,3 +152,338 @@ users: }) } } + +func TestGetClusterARNFromKubeconfig(t *testing.T) { + for _, tt := range []struct { + name string + kubeconfig string + context string + wantOK bool + wantRegion string + }{ + { + name: "EKS ARN context extracts region", + kubeconfig: ` +apiVersion: v1 +kind: Config +current-context: eks-context +contexts: +- name: eks-context + context: + cluster: arn:aws:eks:us-east-2:123456789012:cluster/my-cluster + user: eks-user +clusters: +- name: arn:aws:eks:us-east-2:123456789012:cluster/my-cluster + cluster: + server: https://example.eks.amazonaws.com +users: +- name: eks-user + user: {} +`, + wantOK: true, + wantRegion: "us-east-2", + }, + { + name: "GovCloud ARN context extracts region", + kubeconfig: ` +apiVersion: v1 +kind: Config +current-context: gov-context +contexts: +- name: gov-context + context: + cluster: arn:aws-us-gov:eks:us-gov-west-1:987654321098:cluster/gov-cluster + user: gov-user +clusters: +- name: arn:aws-us-gov:eks:us-gov-west-1:987654321098:cluster/gov-cluster + cluster: + server: https://example.eks.amazonaws.com +users: +- name: gov-user + user: {} +`, + wantOK: true, + wantRegion: "us-gov-west-1", + }, + { + name: "plain cluster name is not an ARN", + kubeconfig: ` +apiVersion: v1 +kind: Config +current-context: plain-context +contexts: +- name: plain-context + context: + cluster: my-cluster + user: my-user +clusters: +- name: my-cluster + cluster: + server: https://example.eks.amazonaws.com +users: +- name: my-user + user: {} +`, + wantOK: false, + }, + { + name: "eksctl FQDN is not an ARN", + kubeconfig: ` +apiVersion: v1 +kind: Config +current-context: eksctl-context +contexts: +- name: eksctl-context + context: + cluster: my-cluster.us-east-1.eksctl.io + user: eksctl-user +clusters: +- name: my-cluster.us-east-1.eksctl.io + cluster: + server: https://example.eks.amazonaws.com +users: +- name: eksctl-user + user: {} +`, + wantOK: false, + }, + { + name: "non-EKS ARN is rejected", + kubeconfig: ` +apiVersion: v1 +kind: Config +current-context: iam-context +contexts: +- name: iam-context + context: + cluster: arn:aws:iam::123456789012:role/some-role + user: iam-user +clusters: +- name: arn:aws:iam::123456789012:role/some-role + cluster: + server: https://example.eks.amazonaws.com +users: +- name: iam-user + user: {} +`, + wantOK: false, + }, + } { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + kubeconfigPath := filepath.Join(dir, "kubeconfig") + require.NoError(t, os.WriteFile(kubeconfigPath, []byte(tt.kubeconfig), 0600)) + + flags := genericclioptions.NewConfigFlags(false) + flags.KubeConfig = &kubeconfigPath + if tt.context != "" { + flags.Context = &tt.context + } + + parsed, ok, err := getClusterARNFromKubeconfig(flags) + require.NoError(t, err) + assert.Equal(t, tt.wantOK, ok) + assert.Equal(t, tt.wantRegion, parsed.Region) + }) + } +} + +func TestResolveRegion(t *testing.T) { + for _, tt := range []struct { + name string + configRegion string + kubeRegion string + clusterName string + wantRegion string + wantMismatch bool + wantErr bool + }{ + { + name: "both empty is an error", + configRegion: "", + kubeRegion: "", + wantErr: true, + }, + { + name: "derives from kubeconfig when config is empty", + configRegion: "", + kubeRegion: "us-east-2", + wantRegion: "us-east-2", + }, + { + name: "keeps configured region when kubeconfig is unknown", + configRegion: "eu-west-3", + kubeRegion: "", + wantRegion: "eu-west-3", + }, + { + name: "keeps configured region when they match", + configRegion: "us-east-2", + kubeRegion: "us-east-2", + wantRegion: "us-east-2", + }, + { + name: "mismatch is an error", + configRegion: "us-west-2", + kubeRegion: "us-east-2", + clusterName: "my-cluster", + wantMismatch: true, + wantErr: true, + }, + { + name: "GovCloud mismatch is an error", + configRegion: "us-gov-east-1", + kubeRegion: "us-gov-west-1", + clusterName: "gov-cluster", + wantMismatch: true, + wantErr: true, + }, + } { + t.Run(tt.name, func(t *testing.T) { + got, err := resolveRegion(tt.configRegion, tt.kubeRegion, tt.clusterName) + if !tt.wantErr { + require.NoError(t, err) + assert.Equal(t, tt.wantRegion, got) + return + } + + require.Error(t, err) + var mismatch *RegionMismatchError + if tt.wantMismatch { + require.ErrorAs(t, err, &mismatch) + assert.Equal(t, tt.configRegion, mismatch.ConfigRegion) + assert.Equal(t, tt.kubeRegion, mismatch.ClusterRegion) + assert.Equal(t, tt.clusterName, mismatch.ClusterName) + } else { + assert.False(t, errors.As(err, &mismatch)) + } + }) + } +} + +func TestReconcileRegion(t *testing.T) { + const eksARN = ` +apiVersion: v1 +kind: Config +current-context: eks +contexts: +- name: eks + context: + cluster: arn:aws:eks:us-east-2:123456789012:cluster/my-cluster + user: u +clusters: +- name: arn:aws:eks:us-east-2:123456789012:cluster/my-cluster + cluster: + server: https://example.eks.amazonaws.com +users: +- name: u + user: {} +` + const plainName = ` +apiVersion: v1 +kind: Config +current-context: plain +contexts: +- name: plain + context: + cluster: my-cluster + user: u +clusters: +- name: my-cluster + cluster: + server: https://example.eks.amazonaws.com +users: +- name: u + user: {} +` + // current-context references a context that is not defined, so reading the + // cluster ARN fails — exercising the best-effort warning path. + const danglingContext = ` +apiVersion: v1 +kind: Config +current-context: missing +contexts: +- name: present + context: + cluster: my-cluster + user: u +clusters: +- name: my-cluster + cluster: + server: https://example.eks.amazonaws.com +users: +- name: u + user: {} +` + for _, tt := range []struct { + name string + kubeconfig string + configRegion string + wantRegion string + wantMismatch bool + wantErr bool + }{ + { + name: "matches the cluster region", + kubeconfig: eksARN, + configRegion: "us-east-2", + wantRegion: "us-east-2", + }, + { + name: "derives the region from the kubeconfig when unset", + kubeconfig: eksARN, + configRegion: "", + wantRegion: "us-east-2", + }, + { + name: "rejects a region that differs from the cluster", + kubeconfig: eksARN, + configRegion: "us-west-2", + wantMismatch: true, + wantErr: true, + }, + { + name: "errors when the region cannot be determined", + kubeconfig: plainName, + configRegion: "", + wantErr: true, + }, + { + name: "keeps the configured region when the kubeconfig is unreadable", + kubeconfig: danglingContext, + configRegion: "eu-west-3", + wantRegion: "eu-west-3", + }, + } { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + kubeconfigPath := filepath.Join(dir, "kubeconfig") + require.NoError(t, os.WriteFile(kubeconfigPath, []byte(tt.kubeconfig), 0600)) + + // Isolate the SDK's default config resolution (used by the reload on + // the derive path) from the host environment so the test is hermetic. + t.Setenv("AWS_CONFIG_FILE", filepath.Join(dir, "aws-config")) + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", filepath.Join(dir, "aws-credentials")) + t.Setenv("AWS_PROFILE", "") + t.Setenv("AWS_REGION", "") + t.Setenv("AWS_DEFAULT_REGION", "") + + flags := genericclioptions.NewConfigFlags(false) + flags.KubeConfig = &kubeconfigPath + + got, err := reconcileRegion(context.Background(), awssdk.Config{Region: tt.configRegion}, flags) + if tt.wantErr { + require.Error(t, err) + if tt.wantMismatch { + var mismatch *RegionMismatchError + require.ErrorAs(t, err, &mismatch) + assert.Contains(t, mismatch.Error(), "AWS region mismatch") + } + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantRegion, got.Region) + }) + } +}