Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions pkg/cli/cmd/cluster/info.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/devantler-tech/ksail/v7/pkg/client/kubectl"
"github.com/devantler-tech/ksail/v7/pkg/fsutil"
"github.com/devantler-tech/ksail/v7/pkg/notify"
"github.com/devantler-tech/ksail/v7/pkg/svc/credentials"
clusterdetector "github.com/devantler-tech/ksail/v7/pkg/svc/detector/cluster"
"github.com/devantler-tech/ksail/v7/pkg/svc/provider"
awsprovider "github.com/devantler-tech/ksail/v7/pkg/svc/provider/aws"
Expand Down Expand Up @@ -81,6 +82,7 @@ func runInfoCmd(
resolved.Provider,
resolved.ClusterName,
resolved.OmniOpts,
resolved.AWSOpts,
resolved.AWSRegion,
)

Expand Down Expand Up @@ -211,6 +213,7 @@ func getProviderStatus(
prov v1alpha1.Provider,
clusterName string,
omniOpts v1alpha1.OptionsOmni,
awsOpts v1alpha1.OptionsAWS,
awsRegion string,
) (*provider.ClusterStatus, error) {
switch prov {
Expand All @@ -221,7 +224,7 @@ func getProviderStatus(
case v1alpha1.ProviderOmni:
return getOmniProviderStatus(cmd.Context(), clusterName, omniOpts)
case v1alpha1.ProviderAWS:
return getAWSProviderStatus(cmd.Context(), clusterName, awsRegion)
return getAWSProviderStatus(cmd.Context(), clusterName, awsOpts, awsRegion)
case v1alpha1.ProviderGCP, v1alpha1.ProviderAzure:
// GCP/GKE and Azure/AKS status inspection is not yet implemented. Return
// a minimal stub so callers that rely on this helper do not fail for them.
Expand Down Expand Up @@ -335,9 +338,25 @@ func getOmniProviderStatus(
func getAWSProviderStatus(
ctx context.Context,
clusterName string,
awsOpts v1alpha1.OptionsAWS,
region string,
) (*provider.ClusterStatus, error) {
return awsProviderStatus(ctx, eksctlclient.NewClient(), clusterName, region)
_, eksctlOptions, providerOptions := credentials.ResolveAWSClientOptions(
credentials.NewAWSOptionsResolver(awsOpts),
os.Environ(),
eksctlclient.WithEnvironment,
eksctlclient.RequireCredentialValues,
awsprovider.WithCredentialValues,
awsprovider.RequireCredentialValues,
)

return awsProviderStatus(
ctx,
eksctlclient.NewClient(eksctlOptions...),
clusterName,
region,
providerOptions...,
)
}

// awsProviderStatus is the injectable core of getAWSProviderStatus: it accepts
Expand Down
110 changes: 110 additions & 0 deletions pkg/cli/cmd/cluster/info_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"io"
"os"
"path/filepath"
"testing"

awssdk "github.com/aws/aws-sdk-go-v2/aws"
Expand All @@ -25,6 +26,45 @@ var errEksctlStubEmptyArgs = errors.New("eksctl stub: empty args")
// the AWS status tests never resolve real AWS credentials.
const stubEndpoint = "https://ABCDEF.gr7.us-east-1.eks.amazonaws.com"

const mappedAWSEksctlFixture = `#!/bin/sh
[ "${AWS_PROFILE-}" = "selected-profile" ] || exit 41
[ "${AWS_ACCESS_KEY_ID-}" = "fixture-access" ] || exit 42
[ "${AWS_SECRET_ACCESS_KEY-}" = "fixture-secret" ] || exit 43
[ "${AWS_SESSION_TOKEN-}" = "fixture-session" ] || exit 44
[ -z "${KSAIL_PROFILE+x}" ] || exit 45
[ -z "${KSAIL_ACCESS+x}" ] || exit 46
[ -z "${KSAIL_SECRET+x}" ] || exit 47
[ -z "${KSAIL_SESSION+x}" ] || exit 48
printf mapped > "$KSAIL_EKSCTL_MARKER"
printf 'null\n'
`

const mappedAWSClusterFixture = `apiVersion: ksail.io/v1alpha1
kind: Cluster
metadata:
name: mapped-eks
spec:
cluster:
distribution: EKS
provider: AWS
distributionConfig: eks.yaml
connection:
kubeconfig: kubeconfig
provider:
aws:
profileEnvVar: KSAIL_PROFILE
accessKeyIdEnvVar: KSAIL_ACCESS
secretAccessKeyEnvVar: KSAIL_SECRET
sessionTokenEnvVar: KSAIL_SESSION
`

const mappedAWSEksConfigFixture = `apiVersion: eksctl.io/v1alpha5
kind: ClusterConfig
metadata:
name: mapped-eks
region: eu-west-1
`

// stubDescriber is a credential-free stand-in for the EKS DescribeCluster
// seam, returning a cluster carrying stubEndpoint.
type stubDescriber struct{}
Expand Down Expand Up @@ -179,3 +219,73 @@ func TestAWSProviderStatus_ForwardsRegion(t *testing.T) {
runner.gotArgs,
)
}

// TestInfoCommandMapsCustomAWSCredentialsIntoEksctl verifies custom aliases
// reach eksctl canonically without mutating ambient credentials.
func TestInfoCommandMapsCustomAWSCredentialsIntoEksctl(t *testing.T) {
workingDir := t.TempDir()
t.Chdir(workingDir)

binDir := t.TempDir()
markerPath := filepath.Join(t.TempDir(), "mapped")
eksctlPath := filepath.Join(binDir, "eksctl")
writeExecutableFixture(t, eksctlPath, mappedAWSEksctlFixture)
require.NoError(
t,
os.WriteFile(
filepath.Join(workingDir, "ksail.yaml"),
[]byte(mappedAWSClusterFixture),
0o600,
),
)
require.NoError(
t,
os.WriteFile(
filepath.Join(workingDir, "eks.yaml"),
[]byte(mappedAWSEksConfigFixture),
0o600,
),
)
require.NoError(
t,
os.WriteFile(
filepath.Join(workingDir, "kubeconfig"),
[]byte("apiVersion: v1\nkind: Config\n"),
0o600,
),
)

t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
t.Setenv("KSAIL_EKSCTL_MARKER", markerPath)
t.Setenv("KSAIL_PROFILE", "selected-profile")
t.Setenv("KSAIL_ACCESS", "fixture-access")
t.Setenv("KSAIL_SECRET", "fixture-secret")
t.Setenv("KSAIL_SESSION", "fixture-session")
t.Setenv("AWS_PROFILE", "stale-profile")
t.Setenv("AWS_ACCESS_KEY_ID", "stale-access")
t.Setenv("AWS_SECRET_ACCESS_KEY", "stale-secret")
t.Setenv("AWS_SESSION_TOKEN", "stale-session")

cmd := cluster.NewInfoCmd()
cmd.SetArgs([]string{})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
_ = cmd.Execute()

marker, err := os.ReadFile(markerPath) //nolint:gosec // path is test-private.
require.NoError(t, err)
assert.Equal(t, "mapped", string(marker))
assert.Equal(t, "stale-profile", os.Getenv("AWS_PROFILE"))
}

// writeExecutableFixture writes a private executable used to stand in for eksctl.
func writeExecutableFixture(t *testing.T, path, contents string) {
t.Helper()

require.NoError(t, os.WriteFile(path, []byte(contents), 0o600))
require.NoError(
t,
//nolint:gosec // owner execute is required for the fixture.
os.Chmod(path, 0o700),
)
}
60 changes: 60 additions & 0 deletions pkg/cli/lifecycle/awsregion_test.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
package lifecycle_test

import (
"os"
"path/filepath"
"testing"

"github.com/devantler-tech/ksail/v7/pkg/apis/cluster/v1alpha1"
"github.com/devantler-tech/ksail/v7/pkg/cli/lifecycle"
clusterprovisioner "github.com/devantler-tech/ksail/v7/pkg/svc/provisioner/cluster"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestResolveAWSRegion verifies the documented precedence: the env var named by
Expand Down Expand Up @@ -48,3 +51,60 @@ func TestResolveAWSRegion(t *testing.T) {
assert.Empty(t, got)
})
}

// TestResolveClusterInfoRetainsAWSCredentialMappings verifies lifecycle
// resolution preserves immutable AWS credential-name mappings.
func TestResolveClusterInfoRetainsAWSCredentialMappings(t *testing.T) {
workingDir := t.TempDir()
t.Chdir(workingDir)
t.Setenv("KSAIL_AWS_REGION", "ap-southeast-1")

require.NoError(
t,
os.WriteFile(filepath.Join(workingDir, "ksail.yaml"), []byte(`apiVersion: ksail.io/v1alpha1
kind: Cluster
metadata:
name: mapped-eks
spec:
cluster:
distribution: EKS
provider: AWS
distributionConfig: eks.yaml
connection:
kubeconfig: kubeconfig
provider:
aws:
profileEnvVar: KSAIL_PROFILE
regionEnvVar: KSAIL_AWS_REGION
accessKeyIdEnvVar: KSAIL_ACCESS
secretAccessKeyEnvVar: KSAIL_SECRET
sessionTokenEnvVar: KSAIL_SESSION
`), 0o600),
)
require.NoError(
t,
os.WriteFile(filepath.Join(workingDir, "eks.yaml"), []byte(`apiVersion: eksctl.io/v1alpha5
kind: ClusterConfig
metadata:
name: mapped-eks
region: eu-west-1
`), 0o600),
)
require.NoError(
t,
os.WriteFile(
filepath.Join(workingDir, "kubeconfig"),
[]byte("apiVersion: v1\nkind: Config\n"),
0o600,
),
)

resolved, err := lifecycle.ResolveClusterInfo(nil, "", "", "")
require.NoError(t, err)
assert.Equal(t, "mapped-eks", resolved.ClusterName)
assert.Equal(t, "KSAIL_PROFILE", resolved.AWSOpts.ProfileEnvVar)
assert.Equal(t, "KSAIL_ACCESS", resolved.AWSOpts.AccessKeyIDEnvVar)
assert.Equal(t, "KSAIL_SECRET", resolved.AWSOpts.SecretAccessKeyEnvVar)
assert.Equal(t, "KSAIL_SESSION", resolved.AWSOpts.SessionTokenEnvVar)
assert.Equal(t, "ap-southeast-1", resolved.AWSRegion)
}
83 changes: 38 additions & 45 deletions pkg/cli/lifecycle/simple.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@ type ResolvedClusterInfo struct {
// AWSRegion is the resolved AWS region for read-only EKS status lookups.
// Empty defers region resolution to eksctl (AWS_REGION env / active profile).
AWSRegion string
// AWSOpts retains the credential environment-variable mappings from the
// loaded cluster config for read-only EKS status lookups.
AWSOpts v1alpha1.OptionsAWS
}

// awsRegionEnvVarDefault is the fallback environment variable name for the AWS
Expand Down Expand Up @@ -163,48 +166,42 @@ func ResolveClusterInfo(
providerFlag v1alpha1.Provider,
kubeconfigFlag string,
) (*ResolvedClusterInfo, error) {
clusterName := nameFlag
provider := providerFlag
kubeconfigPath := kubeconfigFlag

// Always load config to fill missing fields and extract Omni/Kubernetes options.
// Even when --name is provided, we still need Omni endpoint from config.
var (
omniOpts v1alpha1.OptionsOmni
kubernetesOpts v1alpha1.OptionsKubernetes
awsRegion string
)
resolved := ResolvedClusterInfo{
ClusterName: nameFlag,
Provider: providerFlag,
KubeconfigPath: kubeconfigFlag,
}

resolveFromConfig(
cmd, &clusterName, &provider, &kubeconfigPath, &omniOpts, &kubernetesOpts, &awsRegion,
)
// Always load config to fill missing fields and extract provider options.
// Even when --name is provided, provider-specific settings are still needed.
resolveFromConfig(cmd, &resolved)

// Fall back to kubeconfig context detection
if clusterName == "" {
resolveFromKubecontext(commandContext(cmd), &clusterName, &provider, kubeconfigPath)
if resolved.ClusterName == "" {
resolveFromKubecontext(
commandContext(cmd),
&resolved.ClusterName,
&resolved.Provider,
resolved.KubeconfigPath,
)
}

if clusterName == "" {
if resolved.ClusterName == "" {
return nil, ErrClusterNameRequired
}

if provider == "" {
provider = v1alpha1.ProviderDocker
if resolved.Provider == "" {
resolved.Provider = v1alpha1.ProviderDocker
}

resolvedPath, err := clusterdetector.ResolveKubeconfigPath(kubeconfigPath)
resolvedPath, err := clusterdetector.ResolveKubeconfigPath(resolved.KubeconfigPath)
if err != nil {
return nil, fmt.Errorf("resolve kubeconfig path: %w", err)
}

return &ResolvedClusterInfo{
ClusterName: clusterName,
Provider: provider,
KubeconfigPath: resolvedPath,
OmniOpts: omniOpts,
KubernetesOpts: kubernetesOpts,
AWSRegion: awsRegion,
}, nil
resolved.KubeconfigPath = resolvedPath

return &resolved, nil
}

// loadConfig loads the ksail.yaml config, honoring the --config flag when cmd is non-nil.
Expand Down Expand Up @@ -234,39 +231,35 @@ func loadConfig(cmd *cobra.Command) (*v1alpha1.Cluster, *clusterprovisioner.Dist
// Fields that already have values (from flags) are not overwritten.
func resolveFromConfig(
cmd *cobra.Command,
clusterName *string,
provider *v1alpha1.Provider,
kubeconfigPath *string,
omniOpts *v1alpha1.OptionsOmni,
kubernetesOpts *v1alpha1.OptionsKubernetes,
awsRegion *string,
resolved *ResolvedClusterInfo,
) {
cfg, distCfg := loadConfig(cmd)
if cfg == nil {
return
}

if *clusterName == "" && cfg.Name != "" {
if resolved.ClusterName == "" && cfg.Name != "" {
if v1alpha1.ValidateClusterName(cfg.Name) == nil {
*clusterName = cfg.Name
resolved.ClusterName = cfg.Name
}
}

if *clusterName == "" {
*clusterName = ClusterNameFromDistributionConfig(distCfg)
if resolved.ClusterName == "" {
resolved.ClusterName = ClusterNameFromDistributionConfig(distCfg)
}

if *provider == "" && cfg.Spec.Cluster.Provider != "" {
*provider = cfg.Spec.Cluster.Provider
if resolved.Provider == "" && cfg.Spec.Cluster.Provider != "" {
resolved.Provider = cfg.Spec.Cluster.Provider
}

if *kubeconfigPath == "" && cfg.Spec.Cluster.Connection.Kubeconfig != "" {
*kubeconfigPath = cfg.Spec.Cluster.Connection.Kubeconfig
if resolved.KubeconfigPath == "" && cfg.Spec.Cluster.Connection.Kubeconfig != "" {
resolved.KubeconfigPath = cfg.Spec.Cluster.Connection.Kubeconfig
}

*omniOpts = cfg.Spec.Provider.Omni
*kubernetesOpts = cfg.Spec.Provider.Kubernetes
*awsRegion = ResolveAWSRegion(cfg.Spec.Provider.AWS, distCfg)
resolved.OmniOpts = cfg.Spec.Provider.Omni
resolved.KubernetesOpts = cfg.Spec.Provider.Kubernetes
resolved.AWSOpts = cfg.Spec.Provider.AWS
resolved.AWSRegion = ResolveAWSRegion(cfg.Spec.Provider.AWS, distCfg)
}

// commandContext returns cmd's context, falling back to context.Background()
Expand Down
Loading