diff --git a/CLAUDE.md b/CLAUDE.md index 006cb91d..79527fdd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ Notes: - `internal/` - All business logic goes here - `api/` - LocalStack platform API client (auth, license) - `auth/` - Authentication (env var token or browser-based login), token storage/keyring - - `awscli/`, `azurecli/` - Exec wrappers behind the `lstk aws` / `lstk az` proxy commands + - `awscli/`, `azurecli/`, `eksctl/` - Exec wrappers behind the `lstk aws` / `lstk az` / `lstk eksctl` proxy commands - `awsconfig/` - AWS CLI profile management in `~/.aws/` (`lstk setup aws`) - `azureconfig/` - Azure CLI cloud registration and interception (`lstk setup azure`, `lstk az`) — see `internal/azureconfig/CLAUDE.md` - `caller/` - Classifies the invoking caller/harness (human vs agent) for telemetry @@ -75,7 +75,7 @@ Notes: - `version/` - Version info - `volume/` - `lstk volume` domain logic -Commands are registered in `cmd/root.go` in two Cobra groups: the `commands` group (start, stop, restart, login, logout, status, logs, setup, config, volume, update, docs, snapshot, reset, save, load) and the `tools` group of proxy commands (aws, terraform/tf, cdk, sam, az). Shared helpers: `cmd/root.go` (wiring, groups, `requireSubcommand`, `initConfig`), `cmd/help.go` (help template), `cmd/iac.go` (IaC command boundary), `cmd/extension.go` (extension dispatch). +Commands are registered in `cmd/root.go` in two Cobra groups: the `commands` group (start, stop, restart, login, logout, status, logs, setup, config, volume, update, docs, snapshot, reset, save, load) and the `tools` group of proxy commands (aws, terraform/tf, cdk, sam, az, eksctl). Shared helpers: `cmd/root.go` (wiring, groups, `requireSubcommand`, `initConfig`), `cmd/help.go` (help template), `cmd/iac.go` (IaC command boundary), `cmd/extension.go` (extension dispatch). # Commits, PRs, and Linear @@ -147,13 +147,17 @@ Environment variables: lstk proxies third-party IaC tools at the AWS emulator so they run against LocalStack with no `*local` wrapper installed. Each command forwards its args to the real tool after configuring the environment; domain logic lives under `internal/iac//cli/`, wiring in `cmd/.go`, with shared command-boundary helpers in `cmd/iac.go`. Siblings: `lstk terraform` (alias `tf`), `lstk cdk`, `lstk sam`. +# eksctl Proxy + +`lstk eksctl` proxies [eksctl](https://eksctl.io/) at the AWS emulator, replacing the manual `AWS_*_ENDPOINT` exports from the "Newer Versions" flow in the LocalStack eksctl docs. Domain logic is `internal/eksctl/` (an exec wrapper like `awscli`/`azurecli`, not an IaC tool), wiring in `cmd/eksctl.go`. It sets the CloudFormation, EC2, EKS, ELB, ELBv2, IAM, and STS service endpoints plus the generic `AWS_ENDPOINT_URL` (`internal/eksctl/env.go` — the generic var covers the clients eksctl builds without a per-service override: SSM, Outposts, the STS presigner) to the resolved LocalStack endpoint, honors a user-set `AWS_ENDPOINT_URL` as an override (same contract as the terraform/cdk/sam proxies), clears inherited service-specific endpoint overrides and `AWS_IGNORE_CONFIGURED_ENDPOINT_URLS`, strips ambient AWS profile/session config, and defaults credentials/region when unset or empty (one resolved region seeds both `AWS_REGION` and `AWS_DEFAULT_REGION` so the injected pair can't contradict a user-set one). It gates on eksctl >= 0.181.0 (`internal/eksctl/version.go`) — the boundary the LocalStack docs define for the env-var flow (0.181.0 moved endpoint resolution to per-client resolvers; 0.180.0 read the same variables via a deprecated SDK global resolver, so the gate is about supporting only the documented flow, not about older versions ignoring the variables). Offline subcommands (`version`, `info`, `completion`) and `-h`/`--help` run without Docker, a running emulator, or the version gate; a bare `help` counts only as the leading token (elsewhere it's a flag value — e.g. a cluster named "help" — and must not skip the gates). Everything else requires the AWS emulator via the shared `requireRunningAWSEmulator`/`resolveAWSContainer` helpers in `cmd/iac.go`. `LSTK_EKSCTL_CMD` overrides the binary name. + # Extensions lstk supports Git-style extensions: when `lstk ` is not a built-in command or alias, lstk resolves and execs an external `lstk-` executable, forwarding arguments verbatim and propagating the exit code. Built-ins always win. Resolution order is built-ins → bundled dir (the directory of the symlink-resolved lstk executable) → `PATH`; there is no manifest. Runtime context is conveyed via `LSTK_EXT_API_VERSION` and `LSTK_EXT_CONTEXT` (JSON: `configDir`, optional `authToken`, `nonInteractive`, `json`, `emulators` array) — see `extension.Context`/`Environ` in `internal/extension/context.go`; dispatch and help listing are in `cmd/extension.go`. Automated distribution/co-update of bundled extensions is deferred to the `add-bundled-extension-distribution` change. See [extensions-authoring.md](docs/extensions-authoring.md) for the author-facing contract. # Signal Forwarding to Wrapped Tools -Wrapped external tools (`aws`, `terraform`, `cdk`, `sam`, `az`, and extensions) are run through `proc.Run(cmd)` (in `internal/proc/run.go`) rather than `cmd.Run()`. These execs are created with `exec.CommandContext` using lstk's root context, which is cancelled on `SIGINT`/`SIGTERM`; `exec.CommandContext`'s default `Cancel` would then SIGKILL the child immediately, denying tools like `terraform apply` the chance to clean up (e.g. release the state lock). `proc.Run` disarms that (its `Cancel` returns `os.ErrProcessDone`, which both suppresses the kill and avoids injecting `context.Canceled` into the wait result, preserving the tool's real exit code) and instead lets the tool terminate from the signal it receives, waiting for it to finish its own shutdown. Forwarding is per-signal: `SIGTERM` is always relayed to the child (a terminal never generates it, so `kill ` / `timeout` / an IDE stop button would otherwise never reach the tool), while `SIGINT` is relayed only when none of lstk's std streams is a terminal — an attached terminal already delivers Ctrl-C to the child via the foreground process group, and a second near-simultaneous SIGINT makes tools like terraform abort immediately instead of cleaning up. The any-stream check matters: with only stdin redirected (`yes | lstk terraform apply`) lstk still sits in the terminal's foreground process group. This differs from `npm/launcher.js`, which forwards unconditionally — safe there because its child is lstk itself, which tolerates duplicate signals; wrapped tools do not. Short internal captured-output execs (version checks, schema discovery, backend provisioning) still use `cmd.Run()` directly. End-to-end signal tests live in `test/integration/signal_forwarding_test.go`, backed by the reference extension's `signal-wait` mode. +Wrapped external tools (`aws`, `terraform`, `cdk`, `sam`, `az`, `eksctl`, and extensions) are run through `proc.Run(cmd)` (in `internal/proc/run.go`) rather than `cmd.Run()`. These execs are created with `exec.CommandContext` using lstk's root context, which is cancelled on `SIGINT`/`SIGTERM`; `exec.CommandContext`'s default `Cancel` would then SIGKILL the child immediately, denying tools like `terraform apply` the chance to clean up (e.g. release the state lock). `proc.Run` disarms that (its `Cancel` returns `os.ErrProcessDone`, which both suppresses the kill and avoids injecting `context.Canceled` into the wait result, preserving the tool's real exit code) and instead lets the tool terminate from the signal it receives, waiting for it to finish its own shutdown. Forwarding is per-signal: `SIGTERM` is always relayed to the child (a terminal never generates it, so `kill ` / `timeout` / an IDE stop button would otherwise never reach the tool), while `SIGINT` is relayed only when none of lstk's std streams is a terminal — an attached terminal already delivers Ctrl-C to the child via the foreground process group, and a second near-simultaneous SIGINT makes tools like terraform abort immediately instead of cleaning up. The any-stream check matters: with only stdin redirected (`yes | lstk terraform apply`) lstk still sits in the terminal's foreground process group. This differs from `npm/launcher.js`, which forwards unconditionally — safe there because its child is lstk itself, which tolerates duplicate signals; wrapped tools do not. Short internal captured-output execs (version checks, schema discovery, backend provisioning) still use `cmd.Run()` directly. End-to-end signal tests live in `test/integration/signal_forwarding_test.go`, backed by the reference extension's `signal-wait` mode. # Snapshots diff --git a/cmd/eksctl.go b/cmd/eksctl.go new file mode 100644 index 00000000..6ed184a3 --- /dev/null +++ b/cmd/eksctl.go @@ -0,0 +1,87 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/localstack/lstk/internal/eksctl" + "github.com/localstack/lstk/internal/endpoint" + "github.com/localstack/lstk/internal/env" + "github.com/localstack/lstk/internal/log" + "github.com/localstack/lstk/internal/output" + "github.com/localstack/lstk/internal/runtime" + "github.com/spf13/cobra" +) + +func newEksctlCmd(cfg *env.Env, logger log.Logger) *cobra.Command { + // DisableFlagParsing means Cobra won't strip lstk's own flags; PreRunE does + // that and stashes the remaining args here for RunE to forward to eksctl. + var passthrough []string + return &cobra.Command{ + Use: "eksctl [args...]", + Short: "Run eksctl against LocalStack", + Long: `Proxy eksctl commands to the running LocalStack emulator. + +Requires eksctl version 0.181.0 or newer on your PATH. lstk points eksctl at LocalStack by setting the AWS service endpoint environment variables it reads (CloudFormation, EC2, EKS, ELB, ELBv2, IAM, STS, plus the generic AWS_ENDPOINT_URL), so cluster operations target the emulator instead of real AWS. This is the "Newer Versions" flow from the LocalStack docs; older eksctl releases are rejected since lstk supports only that flow. + +eksctl support in LocalStack is experimental and may not work in all cases. + +Supported environment variables: + LSTK_EKSCTL_CMD eksctl binary to invoke (default eksctl) + AWS_ENDPOINT_URL Overrides the auto-resolved LocalStack endpoint + AWS_REGION Deployment region (default us-east-1) + AWS_ACCESS_KEY_ID Access key LocalStack derives the account from (default test) + +Examples: + lstk eksctl create cluster --nodes 1 + lstk eksctl get clusters + lstk eksctl delete cluster --name my-cluster`, + DisableFlagParsing: true, + PreRunE: func(cmd *cobra.Command, args []string) error { + var gf globalFlags + passthrough, gf = stripGlobalFlags(args) + if gf.nonInteractive { + cfg.NonInteractive = true + } + if jsonPrecedesCommandName(cmd.CalledAs()) { + cfg.JSON = true + } + if gf.configPath != "" { + // initConfigDeferCreate reads the "config" flag, so feed the value back to it. + if err := cmd.Flags().Set("config", gf.configPath); err != nil { + return err + } + } + return initConfigDeferCreate(nil)(cmd, args) + }, + RunE: func(cmd *cobra.Command, _ []string) error { + sink := output.NewPlainSink(os.Stdout) + + // Offline subcommands (version/info/completion) and --help never + // contact AWS, so they run without Docker or a running emulator. + if eksctl.IsOffline(passthrough) { + return eksctl.Run(cmd.Context(), "", sink, logger, passthrough) + } + + rt, err := runtime.NewDockerRuntime(cfg.DockerHost) + if err != nil { + return err + } + + awsContainer := resolveAWSContainer() + + if err := rt.IsHealthy(cmd.Context()); err != nil { + rt.EmitUnhealthyError(sink, err) + return output.NewSilentError(fmt.Errorf("runtime not healthy: %w", err)) + } + + if err := requireRunningAWSEmulator(cmd.Context(), rt, sink, awsContainer, "eksctl"); err != nil { + return err + } + + host, _ := endpoint.ResolveHost(cmd.Context(), awsContainer.Port, cfg.LocalStackHost) + + return eksctl.Run(cmd.Context(), "http://"+host, sink, logger, passthrough) + }, + } +} diff --git a/cmd/help_test.go b/cmd/help_test.go index 416aada6..558e45a2 100644 --- a/cmd/help_test.go +++ b/cmd/help_test.go @@ -50,7 +50,7 @@ func TestRootHelpGroupsToolsSeparately(t *testing.T) { // The proxy commands must be listed under the Tools group, not among the // regular commands. toolsSection := out[strings.Index(out, "Tools:"):] - for _, tool := range []string{"aws", "az", "cdk", "sam", "terraform"} { + for _, tool := range []string{"aws", "az", "cdk", "eksctl", "sam", "terraform"} { assertContains(t, toolsSection, tool) } diff --git a/cmd/iac.go b/cmd/iac.go index 97706fd9..7f457cac 100644 --- a/cmd/iac.go +++ b/cmd/iac.go @@ -14,21 +14,22 @@ import ( "github.com/localstack/lstk/internal/runtime" ) -// Shared command-boundary helpers for the IaC proxy commands (terraform, cdk). -// These live here rather than in any one command's file because both commands -// depend on them equally; keeping them in cmd/ (not a domain package) is -// deliberate — they touch config.Get(), the output.Sink, and the raw CLI args, -// all of which are command-boundary concerns. +// Shared command-boundary helpers for the AWS-targeting proxy commands +// (terraform, cdk, sam, eksctl). These live here rather than in any one +// command's file because the commands depend on them equally; keeping them in +// cmd/ (not a domain package) is deliberate — they touch config.Get(), the +// output.Sink, and the raw CLI args, all of which are command-boundary +// concerns. var accountIDRe = regexp.MustCompile(`^\d{12}$`) -// requireRunningAWSEmulator verifies the AWS emulator is running before an IaC -// proxy command (terraform/cdk) that contacts AWS proceeds. When it is not -// running it emits an actionable error through the sink — an AWS-specific -// message naming the other emulator when a non-AWS one is up, otherwise the -// generic "not running" error — and returns a silent error. cmdLabel is the -// lstk command name used in the message (e.g. "terraform"/"cdk"). It returns nil -// when the AWS emulator is running. +// requireRunningAWSEmulator verifies the AWS emulator is running before an +// AWS-targeting proxy command (terraform/cdk/sam/eksctl) that contacts AWS +// proceeds. When it is not running it emits an actionable error through the +// sink — an AWS-specific message naming the other emulator when a non-AWS one +// is up, otherwise the generic "not running" error — and returns a silent +// error. cmdLabel is the lstk command name used in the message (e.g. +// "terraform"/"cdk"/"eksctl"). It returns nil when the AWS emulator is running. func requireRunningAWSEmulator(ctx context.Context, rt runtime.Runtime, sink output.Sink, awsContainer config.ContainerConfig, cmdLabel string) error { runningName, err := container.ResolveRunningContainerName(ctx, rt, awsContainer) if err != nil { diff --git a/cmd/root.go b/cmd/root.go index 9296c80b..8494ee13 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -181,13 +181,14 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C } // Proxy commands that forward to a wrapped tool (AWS/Azure CLI, Terraform, - // CDK, SAM) configured to target LocalStack. + // CDK, SAM, eksctl) configured to target LocalStack. tools := []*cobra.Command{ newAWSCmd(cfg), newTerraformCmd(cfg, logger), newCDKCmd(cfg, logger), newSamCmd(cfg, logger), newAzCmd(cfg), + newEksctlCmd(cfg, logger), } for _, c := range tools { c.GroupID = groupTools diff --git a/internal/eksctl/env.go b/internal/eksctl/env.go new file mode 100644 index 00000000..b20259f3 --- /dev/null +++ b/internal/eksctl/env.go @@ -0,0 +1,132 @@ +package eksctl + +import ( + "os" + "strings" +) + +// endpointEnvVars returns the AWS service endpoint variables set to the +// resolved LocalStack endpoint. The seven AWS__ENDPOINT names are the ones +// eksctl resolves itself per client; AWS_ENDPOINT_URL covers clients eksctl +// builds without one of those overrides, including SSM and Outposts. +func endpointEnvVars() []string { + return []string{ + "AWS_CLOUDFORMATION_ENDPOINT", + "AWS_EC2_ENDPOINT", + "AWS_EKS_ENDPOINT", + "AWS_ELB_ENDPOINT", + "AWS_ELBV2_ENDPOINT", + "AWS_IAM_ENDPOINT", + "AWS_STS_ENDPOINT", + "AWS_ENDPOINT_URL", + } +} + +// endpointURLOverride returns AWS_ENDPOINT_URL from the process environment, +// which takes precedence over the auto-resolved LocalStack endpoint (same +// contract as the terraform/cdk/sam proxies). +func endpointURLOverride() string { + return os.Getenv("AWS_ENDPOINT_URL") +} + +func isStrippedAWSConfigKey(key string) bool { + switch key { + case "AWS_PROFILE", "AWS_DEFAULT_PROFILE", "AWS_SESSION_TOKEN": + return true + default: + return false + } +} + +// isEndpointConfigKey reports whether key can override the LocalStack endpoint +// for an AWS service. Service-specific AWS_ENDPOINT_URL_ values take +// precedence over AWS_ENDPOINT_URL, while eksctl also supports legacy +// AWS__ENDPOINT variables for some clients. +func isEndpointConfigKey(key string) bool { + return key == "AWS_ENDPOINT_URL" || + key == "AWS_IGNORE_CONFIGURED_ENDPOINT_URLS" || + strings.HasPrefix(key, "AWS_ENDPOINT_URL_") || + (strings.HasPrefix(key, "AWS_") && strings.HasSuffix(key, "_ENDPOINT")) +} + +// BuildEnv returns the environment for the eksctl subprocess: base with ambient +// AWS profile/session config stripped, the LocalStack service endpoint variables +// set (overriding any pre-existing entries), and credential/region defaults +// filled in when absent or empty so a meaningful user-provided AWS_REGION or +// AWS_ACCESS_KEY_ID is respected. +// +// When endpointURL is empty (offline subcommands like `version`/`completion`), +// no endpoint variables are set or stripped — the invocation does not contact +// LocalStack. The profile/session keys are still stripped and the credential +// defaults still applied, keeping the subprocess environment predictable on +// every path. +func BuildEnv(base []string, endpointURL string) []string { + endpointKeys := endpointEnvVars() + + env := make([]string, 0, len(base)+len(endpointKeys)+5) + for _, e := range base { + key, _, ok := strings.Cut(e, "=") + if !ok { + env = append(env, e) + continue + } + if isStrippedAWSConfigKey(key) || (endpointURL != "" && isEndpointConfigKey(key)) { + continue + } + env = append(env, e) + } + + if endpointURL != "" { + for _, k := range endpointKeys { + env = append(env, k+"="+endpointURL) + } + // A caller or AWS profile can otherwise make SDK-managed clients ignore + // the generic AWS_ENDPOINT_URL and fall back to their real endpoints. + env = append(env, "AWS_IGNORE_CONFIGURED_ENDPOINT_URLS=false") + } + + // LocalStack derives the account id from the access key; "test" maps to the + // default account. Region defaults to us-east-1. Both are only defaults — + // a user-set value (or eksctl's own --region flag) takes precedence. + setDefault(&env, "AWS_ACCESS_KEY_ID", "test") + setDefault(&env, "AWS_SECRET_ACCESS_KEY", "test") + + // Resolve one region and default both variables to it. Defaulting them + // independently would let an injected AWS_REGION=us-east-1 shadow a + // user-set AWS_DEFAULT_REGION (the SDK resolves AWS_REGION first), moving + // the cluster to a region the user never asked for. + region := lookup(env, "AWS_REGION") + if region == "" { + region = lookup(env, "AWS_DEFAULT_REGION") + } + if region == "" { + region = "us-east-1" + } + setDefault(&env, "AWS_REGION", region) + setDefault(&env, "AWS_DEFAULT_REGION", region) + + return env +} + +func lookup(env []string, key string) string { + prefix := key + "=" + for _, e := range env { + if strings.HasPrefix(e, prefix) { + return strings.TrimPrefix(e, prefix) + } + } + return "" +} + +func setDefault(env *[]string, key, value string) { + prefix := key + "=" + for i, e := range *env { + if strings.HasPrefix(e, prefix) { + if len(e) == len(prefix) { + (*env)[i] = prefix + value + } + return + } + } + *env = append(*env, prefix+value) +} diff --git a/internal/eksctl/env_test.go b/internal/eksctl/env_test.go new file mode 100644 index 00000000..8f628be8 --- /dev/null +++ b/internal/eksctl/env_test.go @@ -0,0 +1,153 @@ +package eksctl + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +// envMap parses an env slice ("K=V") into a map for assertions. +func envMap(env []string) map[string]string { + m := make(map[string]string, len(env)) + for _, e := range env { + k, v, ok := strings.Cut(e, "=") + if ok { + m[k] = v + } + } + return m +} + +func TestBuildEnvSetsAllServiceEndpoints(t *testing.T) { + const url = "http://localhost.localstack.cloud:4566" + env := envMap(BuildEnv(nil, url)) + + for _, k := range endpointEnvVars() { + assert.Equalf(t, url, env[k], "expected %s to point at LocalStack", k) + } + assert.Equal(t, "false", env["AWS_IGNORE_CONFIGURED_ENDPOINT_URLS"]) + // Credential and region defaults are filled in. + assert.Equal(t, "test", env["AWS_ACCESS_KEY_ID"]) + assert.Equal(t, "test", env["AWS_SECRET_ACCESS_KEY"]) + assert.Equal(t, "us-east-1", env["AWS_REGION"]) + assert.Equal(t, "us-east-1", env["AWS_DEFAULT_REGION"]) +} + +func TestBuildEnvOverridesExistingEndpoints(t *testing.T) { + const url = "http://localhost.localstack.cloud:4566" + base := []string{"AWS_EKS_ENDPOINT=https://eks.eu-west-1.amazonaws.com"} + env := envMap(BuildEnv(base, url)) + + assert.Equal(t, url, env["AWS_EKS_ENDPOINT"], "a pre-existing endpoint must be overridden to LocalStack") +} + +func TestBuildEnvRemovesHigherPrecedenceEndpointConfig(t *testing.T) { + const url = "http://localhost.localstack.cloud:4566" + base := []string{ + "AWS_ENDPOINT_URL_SSM=https://ssm.us-east-1.amazonaws.com", + "AWS_CLOUDTRAIL_ENDPOINT=https://cloudtrail.us-east-1.amazonaws.com", + "AWS_IGNORE_CONFIGURED_ENDPOINT_URLS=true", + } + env := envMap(BuildEnv(base, url)) + + _, hasSSMEndpoint := env["AWS_ENDPOINT_URL_SSM"] + _, hasCloudTrailEndpoint := env["AWS_CLOUDTRAIL_ENDPOINT"] + assert.False(t, hasSSMEndpoint) + assert.False(t, hasCloudTrailEndpoint) + assert.Equal(t, "false", env["AWS_IGNORE_CONFIGURED_ENDPOINT_URLS"]) + assert.Equal(t, url, env["AWS_ENDPOINT_URL"]) +} + +func TestBuildEnvRespectsUserRegionAndAccount(t *testing.T) { + base := []string{"AWS_REGION=eu-west-1", "AWS_ACCESS_KEY_ID=111111111111"} + env := envMap(BuildEnv(base, "http://localhost.localstack.cloud:4566")) + + assert.Equal(t, "eu-west-1", env["AWS_REGION"]) + assert.Equal(t, "111111111111", env["AWS_ACCESS_KEY_ID"]) + // AWS_DEFAULT_REGION follows the user's region rather than the us-east-1 + // default, so the injected pair can never contradict the user's setting. + assert.Equal(t, "eu-west-1", env["AWS_DEFAULT_REGION"]) +} + +func TestBuildEnvDefaultRegionOnlySeedsAWSRegion(t *testing.T) { + // A user with only AWS_DEFAULT_REGION set must not have it shadowed by an + // injected AWS_REGION=us-east-1 (the SDK resolves AWS_REGION first). + base := []string{"AWS_DEFAULT_REGION=eu-central-1"} + env := envMap(BuildEnv(base, "http://localhost.localstack.cloud:4566")) + + assert.Equal(t, "eu-central-1", env["AWS_REGION"]) + assert.Equal(t, "eu-central-1", env["AWS_DEFAULT_REGION"]) +} + +func TestBuildEnvKeepsContradictoryUserRegionsVerbatim(t *testing.T) { + base := []string{"AWS_REGION=eu-west-1", "AWS_DEFAULT_REGION=us-west-2"} + env := envMap(BuildEnv(base, "http://localhost.localstack.cloud:4566")) + + assert.Equal(t, "eu-west-1", env["AWS_REGION"]) + assert.Equal(t, "us-west-2", env["AWS_DEFAULT_REGION"]) +} + +func TestBuildEnvDefaultsEmptyCredentialsAndRegion(t *testing.T) { + base := []string{ + "AWS_ACCESS_KEY_ID=", + "AWS_SECRET_ACCESS_KEY=", + "AWS_REGION=", + "AWS_DEFAULT_REGION=", + } + env := envMap(BuildEnv(base, "http://localhost.localstack.cloud:4566")) + + assert.Equal(t, "test", env["AWS_ACCESS_KEY_ID"]) + assert.Equal(t, "test", env["AWS_SECRET_ACCESS_KEY"]) + assert.Equal(t, "us-east-1", env["AWS_REGION"]) + assert.Equal(t, "us-east-1", env["AWS_DEFAULT_REGION"]) +} + +func TestBuildEnvStripsAmbientAWSConfig(t *testing.T) { + base := []string{ + "AWS_PROFILE=my-real-profile", + "AWS_DEFAULT_PROFILE=other", + "AWS_SESSION_TOKEN=realtoken", + "PATH=/usr/bin", + } + env := envMap(BuildEnv(base, "http://localhost.localstack.cloud:4566")) + + _, hasProfile := env["AWS_PROFILE"] + _, hasDefaultProfile := env["AWS_DEFAULT_PROFILE"] + _, hasSessionToken := env["AWS_SESSION_TOKEN"] + assert.False(t, hasProfile) + assert.False(t, hasDefaultProfile) + assert.False(t, hasSessionToken) + // Unrelated variables are preserved. + assert.Equal(t, "/usr/bin", env["PATH"]) +} + +func TestBuildEnvOfflineLeavesEndpointsUnset(t *testing.T) { + env := envMap(BuildEnv(nil, "")) + + for _, k := range endpointEnvVars() { + _, ok := env[k] + assert.Falsef(t, ok, "%s must not be set when endpointURL is empty", k) + } + // Credential defaults are still applied. + assert.Equal(t, "test", env["AWS_ACCESS_KEY_ID"]) +} + +func TestBuildEnvOfflineStillStripsAmbientConfig(t *testing.T) { + base := []string{"AWS_PROFILE=my-real-profile", "AWS_SESSION_TOKEN=realtoken"} + env := envMap(BuildEnv(base, "")) + + _, hasProfile := env["AWS_PROFILE"] + _, hasSessionToken := env["AWS_SESSION_TOKEN"] + assert.False(t, hasProfile) + assert.False(t, hasSessionToken) +} + +func TestBuildEnvDoesNotMutateInput(t *testing.T) { + base := []string{"PATH=/usr/bin", "AWS_PROFILE=real"} + original := append([]string(nil), base...) + + BuildEnv(base, "http://localhost.localstack.cloud:4566") + + assert.Equal(t, original, base) +} diff --git a/internal/eksctl/exec.go b/internal/eksctl/exec.go new file mode 100644 index 00000000..9d0bb110 --- /dev/null +++ b/internal/eksctl/exec.go @@ -0,0 +1,173 @@ +// Package eksctl is the exec wrapper behind the `lstk eksctl` proxy command. It +// runs the eksctl binary against the running LocalStack emulator by setting the +// AWS service endpoint environment variables it reads (see env.go), mirroring +// the "Newer Versions" flow documented at +// https://docs.localstack.cloud/aws/customization/kubernetes/eksctl/. +package eksctl + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + + "github.com/localstack/lstk/internal/log" + "github.com/localstack/lstk/internal/output" + "github.com/localstack/lstk/internal/proc" +) + +const installDocsURL = "https://eksctl.io/installation/" + +// ErrNotInstalled is returned when the eksctl binary cannot be found in PATH. +var ErrNotInstalled = errors.New("eksctl not found in PATH") + +// eksctlCmd returns the eksctl binary name to invoke, honoring LSTK_EKSCTL_CMD +// and defaulting to "eksctl". +func eksctlCmd() string { + if v := os.Getenv("LSTK_EKSCTL_CMD"); v != "" { + return v + } + return "eksctl" +} + +func isOfflineCommand(command string) bool { + switch command { + case "version", "info", "help", "completion": + return true + default: + return false + } +} + +// IsHelp reports whether args requests eksctl's help output. eksctl answers this +// without needing a running emulator. +func IsHelp(args []string) bool { + for _, a := range args { + if a == "-h" || a == "--help" { + return true + } + flag, value, hasValue := strings.Cut(a, "=") + if !hasValue || (flag != "-h" && flag != "--help") { + continue + } + enabled, err := strconv.ParseBool(value) + if err != nil || enabled { + return true + } + } + return false +} + +// IsOffline reports whether the eksctl invocation described by args is one of the +// subcommands that need no running emulator (or a help request). +func IsOffline(args []string) bool { + return IsHelp(args) || isOfflineCommand(subcommand(args)) +} + +func globalFlagTakesValue(flag string) bool { + switch flag { + case "-v", "--verbose", "-C", "--color": + return true + default: + return false + } +} + +// subcommand returns the first non-flag token in args that is not consumed as a +// global option's value, or "" if there is none. +func subcommand(args []string) string { + for i := 0; i < len(args); i++ { + a := args[i] + if len(a) == 0 { + continue + } + if a[0] == '-' { + if globalFlagTakesValue(a) && i+1 < len(args) { + i++ // skip this flag's value + } + continue + } + return a + } + return "" +} + +// Run proxies an eksctl invocation against LocalStack. It locates the eksctl +// binary, verifies its version (unless the subcommand is offline), builds a +// subprocess environment that points eksctl at the resolved LocalStack endpoint, +// then runs eksctl with stdio wired through. +// +// endpointURL is the resolved LocalStack endpoint (http://host:port), or "" for +// offline subcommands that do not contact AWS; a user-set AWS_ENDPOINT_URL +// takes precedence over the resolved endpoint (same contract as the +// terraform/cdk/sam proxies). eksctl output is streamed unobstructed (no +// spinner); a non-zero exit is wrapped as a silent error so lstk does not +// reprint it. +func Run(ctx context.Context, endpointURL string, sink output.Sink, logger log.Logger, args []string) error { + ctx, span := otel.Tracer("github.com/localstack/lstk/internal/eksctl").Start(ctx, "eksctl cli") + defer span.End() + + bin, err := exec.LookPath(eksctlCmd()) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + sink.Emit(output.ErrorEvent{ + Title: fmt.Sprintf("%s not found in PATH", eksctlCmd()), + Actions: []output.ErrorAction{{Label: "Install eksctl:", Value: installDocsURL}}, + }) + return output.NewSilentError(ErrNotInstalled) + } + + offline := IsOffline(args) + if !offline { + if err := CheckVersion(ctx, bin); err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + sink.Emit(output.ErrorEvent{ + Title: err.Error(), + Actions: []output.ErrorAction{{Label: "Upgrade eksctl:", Value: installDocsURL}}, + }) + return output.NewSilentError(err) + } + } + + if !offline { + if override := endpointURLOverride(); override != "" { + endpointURL = override + logger.Info("eksctl: using AWS_ENDPOINT_URL override %s", override) + } + } + + span.SetAttributes( + attribute.StringSlice("eksctl.args", args), + attribute.Bool("eksctl.offline", offline), + ) + + cmd := exec.CommandContext(ctx, bin, args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = BuildEnv(os.Environ(), endpointURL) + + logger.Info("eksctl: running %s (offline=%t)", bin, offline) + + if err := proc.Run(cmd); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + span.SetAttributes(attribute.Int("eksctl.exit_code", exitErr.ExitCode())) + span.SetStatus(codes.Error, "eksctl exited non-zero") + return output.NewSilentError(err) + } + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return err + } + return nil +} diff --git a/internal/eksctl/exec_test.go b/internal/eksctl/exec_test.go new file mode 100644 index 00000000..a4de020a --- /dev/null +++ b/internal/eksctl/exec_test.go @@ -0,0 +1,57 @@ +package eksctl + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsHelp(t *testing.T) { + for _, args := range [][]string{ + {"--help"}, {"-h"}, {"--help=true"}, {"-h=true"}, {"--help=invalid"}, + {"create", "cluster", "--help"}, {"get", "clusters", "-h"}, + } { + assert.Truef(t, IsHelp(args), "%v", args) + } + // A bare leading "help" is an offline command, not an IsHelp match; in any + // later position it is a flag value and must not be treated as help. + for _, args := range [][]string{{"--help=false"}, {"-h=false"}, {"create", "cluster"}, {"get", "clusters"}, {"help"}, {"delete", "cluster", "--name", "help"}, {}} { + assert.Falsef(t, IsHelp(args), "%v", args) + } +} + +func TestIsOffline(t *testing.T) { + offline := [][]string{ + {"version"}, + {"info"}, + {"completion", "bash"}, + {"help"}, + {"--help"}, + {"--help=true"}, + {"-h"}, + {"create", "cluster", "--help"}, + } + for _, args := range offline { + assert.Truef(t, IsOffline(args), "expected %v offline", args) + } + + awsContacting := [][]string{ + {"create", "cluster", "--nodes", "1"}, + {"get", "clusters"}, + {"delete", "cluster", "--name", "demo"}, + {"upgrade", "cluster"}, + {"create", "cluster", "--help=false"}, + {"delete", "cluster", "--name", "help"}, // "help" as a flag value is not a help request + {}, // no subcommand → not offline (gate on emulator) + } + for _, args := range awsContacting { + assert.Falsef(t, IsOffline(args), "expected %v not offline", args) + } +} + +func TestSubcommandSkipsLeadingFlags(t *testing.T) { + assert.Equal(t, "create", subcommand([]string{"-v", "4", "create", "cluster"})) + assert.Equal(t, "get", subcommand([]string{"--color=false", "get", "clusters"})) + assert.Equal(t, "", subcommand([]string{"-h"})) + assert.Equal(t, "", subcommand(nil)) +} diff --git a/internal/eksctl/version.go b/internal/eksctl/version.go new file mode 100644 index 00000000..885ced7d --- /dev/null +++ b/internal/eksctl/version.go @@ -0,0 +1,68 @@ +package eksctl + +import ( + "context" + "fmt" + "os/exec" + "regexp" + "strconv" +) + +// minEksctlVersion is the lowest eksctl version lstk supports. 0.181.0 is where +// eksctl moved to per-client resolution of the AWS_*_ENDPOINT variables lstk +// sets, and the boundary the LocalStack eksctl docs define for the env-var +// ("Newer Versions") flow. Older releases resolved the same variables through a +// deprecated SDK global-resolver path the docs route through `--profile +// localstack` instead; lstk supports only the documented env-var flow, so it +// refuses to run against them rather than risk requests escaping to real AWS. +const ( + minEksctlMajor = 0 + minEksctlMinor = 181 + minEksctlPatch = 0 +) + +// minEksctlVersionString is the human-facing form used in error messages. +const minEksctlVersionString = "0.181.0" + +// CheckVersion runs ` version` and returns an error if the reported version +// is below the minimum lstk supports, or if the output cannot be parsed. lstk +// points eksctl at LocalStack purely through environment variables — the flow +// LocalStack documents and tests from eksctl >= minEksctlVersionString; on an +// older (or unparseable) version lstk must refuse to run so requests cannot +// silently escape to real AWS. +func CheckVersion(ctx context.Context, bin string) error { + out, err := exec.CommandContext(ctx, bin, "version").Output() + if err != nil { + return fmt.Errorf("could not determine eksctl version (run `%s version`): %w", bin, err) + } + return checkVersionString(string(out)) +} + +func checkVersionString(out string) error { + versionRe := regexp.MustCompile(`^\s*v?(\d+)\.(\d+)\.(\d+)(-[^\s+]+)?(?:\+\S+)?\s*$`) + m := versionRe.FindStringSubmatch(out) + if m == nil { + return fmt.Errorf("could not parse eksctl version from %q; lstk requires eksctl %s or newer", out, minEksctlVersionString) + } + major, majorErr := strconv.Atoi(m[1]) + minor, minorErr := strconv.Atoi(m[2]) + patch, patchErr := strconv.Atoi(m[3]) + if majorErr != nil || minorErr != nil || patchErr != nil { + return fmt.Errorf("could not parse eksctl version from %q; lstk requires eksctl %s or newer", out, minEksctlVersionString) + } + if !atLeastMinVersion(major, minor, patch) || (major == minEksctlMajor && minor == minEksctlMinor && patch == minEksctlPatch && m[4] != "") { + return fmt.Errorf("eksctl %d.%d.%d is too old; lstk requires %s or newer (the earliest version lstk supports pointing at LocalStack via the AWS_*_ENDPOINT variables)", major, minor, patch, minEksctlVersionString) + } + return nil +} + +func atLeastMinVersion(major, minor, patch int) bool { + switch { + case major != minEksctlMajor: + return major > minEksctlMajor + case minor != minEksctlMinor: + return minor > minEksctlMinor + default: + return patch >= minEksctlPatch + } +} diff --git a/internal/eksctl/version_test.go b/internal/eksctl/version_test.go new file mode 100644 index 00000000..268ca494 --- /dev/null +++ b/internal/eksctl/version_test.go @@ -0,0 +1,53 @@ +package eksctl + +import ( + "context" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCheckVersionString(t *testing.T) { + tests := []struct { + name string + out string + wantErr bool + }{ + {"exact minimum", "0.181.0", false}, + {"newer patch", "0.181.5", false}, + {"newer minor", "0.211.0", false}, + {"much newer", "1.2.3", false}, + {"leading v", "v0.211.0", false}, + {"build metadata", "0.211.0+abcdef", false}, + {"newer prerelease", "0.182.0-rc.0", false}, + {"development build", "0.211.0-dev+abc1234.2026-07-22T12:34:56Z", false}, + {"too old patch", "0.180.9", true}, + {"too old minor", "0.167.0", true}, + {"minimum prerelease", "0.181.0-rc.0", true}, + {"unrelated version in warning", "warning: built with Go 1.25.0\n0.180.0", true}, + {"unparseable", "not a version", true}, + {"empty", "", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := checkVersionString(tt.out) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestCheckVersionStringMessageMentionsMinimum(t *testing.T) { + err := checkVersionString("0.180.0") + assert.ErrorContains(t, err, minEksctlVersionString) +} + +func TestCheckVersionFailsClosedWhenVersionCommandFails(t *testing.T) { + // A binary that cannot report its version must be rejected, not run. + err := CheckVersion(context.Background(), filepath.Join(t.TempDir(), "missing-eksctl")) + assert.ErrorContains(t, err, "could not determine eksctl version") +} diff --git a/test/integration/eksctl_cmd_test.go b/test/integration/eksctl_cmd_test.go new file mode 100644 index 00000000..f061bfdf --- /dev/null +++ b/test/integration/eksctl_cmd_test.go @@ -0,0 +1,267 @@ +package integration_test + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/localstack/lstk/test/integration/env" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// writeFakeEksctl creates a stub `eksctl` that answers `version` with the given +// version string and, for any other invocation, echoes its args and the AWS +// environment it was given so tests can assert what lstk injected/stripped. +func writeFakeEksctl(t *testing.T, version string) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("fake eksctl script not supported on Windows") + } + dir := t.TempDir() + script := fmt.Sprintf(`#!/bin/sh +if [ "$1" = "version" ]; then + echo "%s" + exit 0 +fi +echo "ARGS:$*" +echo "ENV_AWS_EKS_ENDPOINT=${AWS_EKS_ENDPOINT:-}" +echo "ENV_AWS_CLOUDFORMATION_ENDPOINT=${AWS_CLOUDFORMATION_ENDPOINT:-}" +echo "ENV_AWS_STS_ENDPOINT=${AWS_STS_ENDPOINT:-}" +echo "ENV_AWS_IAM_ENDPOINT=${AWS_IAM_ENDPOINT:-}" +echo "ENV_AWS_ENDPOINT_URL=${AWS_ENDPOINT_URL:-}" +echo "ENV_AWS_ENDPOINT_URL_SSM=${AWS_ENDPOINT_URL_SSM:-}" +echo "ENV_AWS_CLOUDTRAIL_ENDPOINT=${AWS_CLOUDTRAIL_ENDPOINT:-}" +echo "ENV_AWS_IGNORE_CONFIGURED_ENDPOINT_URLS=${AWS_IGNORE_CONFIGURED_ENDPOINT_URLS:-}" +echo "ENV_AWS_REGION=$AWS_REGION" +echo "ENV_AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID" +echo "ENV_AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY" +echo "ENV_AWS_PROFILE=${AWS_PROFILE:-}" +echo "ENV_AWS_SESSION_TOKEN=${AWS_SESSION_TOKEN:-}" +`, version) + require.NoError(t, os.WriteFile(filepath.Join(dir, "eksctl"), []byte(script), 0755)) + return dir +} + +func eksctlTestEnv(t *testing.T, path string) env.Environ { + t.Helper() + return env.Environ(testEnvWithHome(t.TempDir(), "")). + With(env.DisableEvents, "1"). + With(env.Path, path) +} + +// writeFakeEksctlExit creates a stub `eksctl` reporting a supported version but +// exiting with the given code for any real subcommand. +func writeFakeEksctlExit(t *testing.T, code int) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("fake eksctl script not supported on Windows") + } + dir := t.TempDir() + script := fmt.Sprintf(`#!/bin/sh +if [ "$1" = "version" ]; then echo "0.211.0"; exit 0; fi +echo "eksctl: simulated failure" >&2 +exit %d +`, code) + require.NoError(t, os.WriteFile(filepath.Join(dir, "eksctl"), []byte(script), 0755)) + return dir +} + +// offline subcommands (version) run without a running emulator or Docker, and +// without the minimum-version gate — a too-old eksctl can still report itself. +func TestEksctlVersionNoEmulator(t *testing.T) { + t.Parallel() + fakeDir := writeFakeEksctl(t, "0.150.0") + e := eksctlTestEnv(t, fakeDir) + + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "eksctl", "version") + require.NoError(t, err, "stderr: %s", stderr) + assert.Contains(t, stdout, "0.150.0") +} + +// --help (and -h) never require the emulator and are forwarded to eksctl. +func TestEksctlHelpNoEmulator(t *testing.T) { + t.Parallel() + for _, args := range [][]string{{"--help"}, {"--help=true"}, {"-h"}, {"create", "cluster", "--help"}} { + args := args + t.Run(strings.Join(args, "_"), func(t *testing.T) { + t.Parallel() + fakeDir := writeFakeEksctl(t, "0.211.0") + e := eksctlTestEnv(t, fakeDir) + + cmdArgs := append([]string{"eksctl"}, args...) + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, cmdArgs...) + require.NoError(t, err, "stderr: %s", stderr) + assert.Contains(t, stdout, "ARGS:"+strings.Join(args, " ")) + }) + } +} + +// an unsupported or ambiguous eksctl version fails before an AWS-contacting +// command runs. +func TestEksctlRejectsUnsupportedVersion(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + startTestContainer(t, ctx) + + for _, tc := range []struct { + name string + version string + }{ + {name: "too old", version: "0.180.0"}, + {name: "untrusted extra output", version: "warning: built with Go 1.25.0\n0.180.0"}, + } { + t.Run(tc.name, func(t *testing.T) { + fakeDir := writeFakeEksctl(t, tc.version) + e := eksctlTestEnv(t, fakeDir) + + stdout, stderr, err := runLstk(t, ctx, t.TempDir(), e, "eksctl", "get", "clusters") + require.Error(t, err) + assert.Contains(t, stderr+stdout, "0.181.0") + assert.NotContains(t, stdout, "ARGS:get") + }) + } +} + +// a missing eksctl binary yields the install error. +func TestEksctlMissingBinary(t *testing.T) { + t.Parallel() + e := eksctlTestEnv(t, t.TempDir()) + + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "eksctl", "version") + require.Error(t, err) + assert.Contains(t, stderr+stdout, "not found in PATH") +} + +// LSTK_EKSCTL_CMD selects the binary to invoke. +func TestEksctlHonorsLstkEksctlCmd(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("fake eksctl script not supported on Windows") + } + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "myeksctl"), + []byte("#!/bin/sh\nif [ \"$1\" = \"version\" ]; then echo \"0.211.0\"; exit 0; fi\necho \"MYEKSCTL:$*\"\n"), 0755)) + e := eksctlTestEnv(t, dir). + With(env.Key("LSTK_EKSCTL_CMD"), "myeksctl") + + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "eksctl", "info") + require.NoError(t, err, "stderr: %s", stderr) + assert.Contains(t, stdout, "MYEKSCTL:info") +} + +// an AWS-contacting command with no running emulator fails with "not running" +// and does not invoke eksctl. +func TestEksctlFailsWhenEmulatorNotRunning(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + fakeDir := writeFakeEksctl(t, "0.211.0") + e := eksctlTestEnv(t, fakeDir) + + stdout, _, err := runLstk(t, testContext(t), t.TempDir(), e, "eksctl", "get", "clusters") + require.Error(t, err) + assert.Contains(t, stdout, "is not running") + assert.Contains(t, stdout, "Start LocalStack:") + assert.NotContains(t, stdout, "ARGS:get") +} + +// an AWS-contacting command against a running AWS emulator forwards args and +// injects the LocalStack service endpoints, credential defaults, and strips +// ambient AWS config that could redirect at real AWS. +func TestEksctlInjectsCleanAWSEnv(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + startTestContainer(t, ctx) + + fakeDir := writeFakeEksctl(t, "0.211.0") + // Empty AWS defaults must behave like unset values, while ambient endpoint + // and profile settings must not escape to the subprocess. + e := eksctlTestEnv(t, fakeDir). + Without(env.Key("AWS_ENDPOINT_URL")). + With(env.AWSAccessKeyID, ""). + With(env.AWSSecretAccessKey, ""). + With(env.Key("AWS_REGION"), ""). + With(env.Key("AWS_DEFAULT_REGION"), ""). + With(env.Key("AWS_PROFILE"), "my-real-profile"). + With(env.Key("AWS_SESSION_TOKEN"), "realtoken"). + With(env.Key("AWS_ENDPOINT_URL_SSM"), "https://ssm.us-east-1.amazonaws.com"). + With(env.Key("AWS_CLOUDTRAIL_ENDPOINT"), "https://cloudtrail.us-east-1.amazonaws.com"). + With(env.Key("AWS_IGNORE_CONFIGURED_ENDPOINT_URLS"), "true") + + stdout, stderr, err := runLstk(t, ctx, t.TempDir(), e, "eksctl", "get", "clusters") + require.NoError(t, err, "stderr: %s", stderr) + + assert.Contains(t, stdout, "ARGS:get clusters") + // All service endpoints point at LocalStack. + assert.Contains(t, stdout, "ENV_AWS_EKS_ENDPOINT=http") + assert.Contains(t, stdout, "ENV_AWS_CLOUDFORMATION_ENDPOINT=http") + assert.Contains(t, stdout, "ENV_AWS_STS_ENDPOINT=http") + assert.Contains(t, stdout, "ENV_AWS_IAM_ENDPOINT=http") + assert.Contains(t, stdout, "ENV_AWS_ENDPOINT_URL=http") + assert.Contains(t, stdout, ":4566") + // Higher-precedence endpoint settings cannot bypass the generic LocalStack + // endpoint used by clients such as SSM and CloudTrail. + assert.Contains(t, stdout, "ENV_AWS_ENDPOINT_URL_SSM=") + assert.Contains(t, stdout, "ENV_AWS_CLOUDTRAIL_ENDPOINT=") + assert.Contains(t, stdout, "ENV_AWS_IGNORE_CONFIGURED_ENDPOINT_URLS=false") + // Credential defaults are applied. + assert.Contains(t, stdout, "ENV_AWS_ACCESS_KEY_ID=test") + assert.Contains(t, stdout, "ENV_AWS_SECRET_ACCESS_KEY=test") + assert.Contains(t, stdout, "ENV_AWS_REGION=us-east-1") + // Ambient AWS config is stripped. + assert.Contains(t, stdout, "ENV_AWS_PROFILE=") + assert.Contains(t, stdout, "ENV_AWS_SESSION_TOKEN=") + + const override = "http://eksctl-override.example.test:4567" + overrideEnv := e.With(env.Key("AWS_ENDPOINT_URL"), override) + overrideOut, overrideErrOut, err := runLstk(t, ctx, t.TempDir(), overrideEnv, "eksctl", "get", "clusters") + require.NoError(t, err, "stderr: %s", overrideErrOut) + assert.Contains(t, overrideOut, "ENV_AWS_EKS_ENDPOINT="+override) + assert.Contains(t, overrideOut, "ENV_AWS_ENDPOINT_URL="+override) +} + +// an AWS-contacting command fails with an AWS-specific error naming the running +// non-AWS emulator, and does not invoke eksctl. +func TestEksctlRequiresAWSEmulator(t *testing.T) { + requireDocker(t) + cleanup() + cleanupSnowflake() + t.Cleanup(cleanup) + t.Cleanup(cleanupSnowflake) + + ctx := testContext(t) + startTestSnowflakeContainer(t, ctx) + + fakeDir := writeFakeEksctl(t, "0.211.0") + e := eksctlTestEnv(t, fakeDir) + + stdout, _, err := runLstk(t, ctx, t.TempDir(), e, "eksctl", "get", "clusters") + require.Error(t, err) + assert.Contains(t, stdout, "requires the") + assert.Contains(t, stdout, "Snowflake") + assert.NotContains(t, stdout, "ARGS:get") +} + +// propagates the eksctl exit code. The offline `info` subcommand exercises the +// same eksctl.Run → proc.Run path without needing Docker or an emulator. +func TestEksctlPropagatesExitCode(t *testing.T) { + t.Parallel() + fakeDir := writeFakeEksctlExit(t, 7) + e := eksctlTestEnv(t, fakeDir) + + _, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "eksctl", "info") + require.Error(t, err) + assert.Contains(t, stderr, "simulated failure") + requireExitCode(t, 7, err) +} diff --git a/test/integration/json_flag_test.go b/test/integration/json_flag_test.go index 122708db..085b7cc6 100644 --- a/test/integration/json_flag_test.go +++ b/test/integration/json_flag_test.go @@ -62,14 +62,21 @@ type proxyCase struct { setup func(t *testing.T) (workDir string, environ []string) } +func proxyTestEnv(t *testing.T) env.Environ { + t.Helper() + return env.Environ(testEnvWithHome(t.TempDir(), "")). + With(env.DisableEvents, "1"). + With(env.Path, t.TempDir()) +} + func genericProxySetup(t *testing.T) (string, []string) { - return t.TempDir(), env.With(env.DisableEvents, "1").With("PATH", t.TempDir()).With(env.Home, t.TempDir()) + return t.TempDir(), proxyTestEnv(t) } func azProxySetup(t *testing.T) (string, []string) { workDir := azureWorkDir(t) writeAzureSetupMarker(t, workDir) - return workDir, env.With(env.DisableEvents, "1").With("PATH", t.TempDir()).With(env.Home, t.TempDir()) + return workDir, proxyTestEnv(t) } func proxyCases() []proxyCase { @@ -78,12 +85,13 @@ func proxyCases() []proxyCase { {name: "terraform", args: []string{"version"}, setup: genericProxySetup}, {name: "cdk", args: []string{"synth"}, setup: genericProxySetup}, {name: "sam", args: []string{"build"}, setup: genericProxySetup}, + {name: "eksctl", args: []string{"version"}, setup: genericProxySetup}, {name: "az", args: []string{"group", "list"}, setup: azProxySetup}, } } -// TestJSONFlagProxyCommandsForwardJSON covers all five proxy commands -// (aws/terraform/cdk/sam/az) with one parametrized test: --json is never +// TestJSONFlagProxyCommandsForwardJSON covers all six proxy commands +// (aws/terraform/cdk/sam/eksctl/az) with one parametrized test: --json is never // recognized or intercepted from the command name onward — it always reaches // the wrapped tool untouched, whether typed immediately after the command name // or after the wrapped tool's own action (see spec.md "Proxy commands forward @@ -124,7 +132,7 @@ func TestJSONFlagProxyCommandsForwardJSON(t *testing.T) { } } -// TestJSONFlagProxyCommandsRejectBeforeCommandName covers all five proxy +// TestJSONFlagProxyCommandsRejectBeforeCommandName covers all six proxy // commands with one parametrized test: --json typed before the proxy // command's own name sits in the same flag-namespace slot --non-interactive/ // --config already occupy there, so lstk rejects it exactly like an @@ -160,7 +168,7 @@ func TestJSONFlagBeforeCommandNameBooleanValues(t *testing.T) { t.Run("--json=true before the command name is rejected", func(t *testing.T) { t.Parallel() - stdout, _, err := runLstk(t, testContext(t), t.TempDir(), env.With(env.DisableEvents, "1").With("PATH", t.TempDir()).With(env.Home, t.TempDir()), "--json=true", "aws", "s3", "ls") + stdout, _, err := runLstk(t, testContext(t), t.TempDir(), proxyTestEnv(t), "--json=true", "aws", "s3", "ls") requireExitCode(t, 1, err) envelope := decodeEnvelope(t, stdout) assert.Equal(t, "aws", envelope.Command) @@ -170,7 +178,7 @@ func TestJSONFlagBeforeCommandNameBooleanValues(t *testing.T) { t.Run("--json=false before the command name is not rejected", func(t *testing.T) { t.Parallel() - stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), env.With(env.DisableEvents, "1").With("PATH", t.TempDir()).With(env.Home, t.TempDir()), "--json=false", "aws", "s3", "ls") + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), proxyTestEnv(t), "--json=false", "aws", "s3", "ls") require.Error(t, err) combined := stdout + stderr require.Contains(t, combined, "not found in PATH", "the wrapped tool should have run (and failed for its own, unrelated reason)") @@ -179,7 +187,7 @@ func TestJSONFlagBeforeCommandNameBooleanValues(t *testing.T) { t.Run("a malformed value before the command name is rejected", func(t *testing.T) { t.Parallel() - stdout, _, err := runLstk(t, testContext(t), t.TempDir(), env.With(env.DisableEvents, "1").With("PATH", t.TempDir()).With(env.Home, t.TempDir()), "--json=notabool", "aws", "s3", "ls") + stdout, _, err := runLstk(t, testContext(t), t.TempDir(), proxyTestEnv(t), "--json=notabool", "aws", "s3", "ls") requireExitCode(t, 1, err) envelope := decodeEnvelope(t, stdout) assert.Equal(t, "aws", envelope.Command)