From e72c1d03984eaaf757a49fe0da826ac2cfc42fd9 Mon Sep 17 00:00:00 2001 From: George Tsiolis <120486+gtsiolis@users.noreply.github.com> Date: Wed, 22 Jul 2026 08:12:24 +0000 Subject: [PATCH 1/3] Add eksctl proxy command for LocalStack Co-authored-by: linear-code[bot] <222613912+linear-code[bot]@users.noreply.github.com> --- CLAUDE.md | 10 +- cmd/eksctl.go | 84 ++++++++++ cmd/help_test.go | 2 +- cmd/iac.go | 14 +- cmd/root.go | 3 +- internal/eksctl/env.go | 88 +++++++++++ internal/eksctl/env_test.go | 91 +++++++++++ internal/eksctl/exec.go | 158 +++++++++++++++++++ internal/eksctl/exec_test.go | 51 +++++++ internal/eksctl/version.go | 65 ++++++++ internal/eksctl/version_test.go | 39 +++++ test/integration/eksctl_cmd_test.go | 227 ++++++++++++++++++++++++++++ 12 files changed, 820 insertions(+), 12 deletions(-) create mode 100644 cmd/eksctl.go create mode 100644 internal/eksctl/env.go create mode 100644 internal/eksctl/env_test.go create mode 100644 internal/eksctl/exec.go create mode 100644 internal/eksctl/exec_test.go create mode 100644 internal/eksctl/version.go create mode 100644 internal/eksctl/version_test.go create mode 100644 test/integration/eksctl_cmd_test.go diff --git a/CLAUDE.md b/CLAUDE.md index dba1270a..07298570 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). ## Container runtime discovery @@ -159,13 +159,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 (`internal/eksctl/env.go`) to the resolved LocalStack endpoint, strips ambient AWS profile/session config, and defaults credentials/region only when absent. It gates on eksctl >= 0.181.0 (`internal/eksctl/version.go`), the version from which eksctl honors those endpoint variables — older versions are rejected rather than silently targeting real AWS (same rationale as the sam version gate). Offline subcommands (`version`, `info`, `completion`) and `--help` run without Docker or a running emulator; 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`, optional `sessionId` — lstk's telemetry session id, omitted when telemetry is disabled, so an extension's own telemetry can join lstk's `ext:` event — and an `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. When lstk's stdout and stderr are both terminals, `lstk aws` runs the child via `proc.RunInPTY` (in `internal/proc/pty.go`) instead: the spinner path wraps the child's output in an `io.Writer`, which makes os/exec hand the child a pipe, and the frozen Python aws CLI then block-buffers stdout (8 KB, ignoring `PYTHONUNBUFFERED`) — streaming commands like `aws logs tail --follow` showed nothing until exit (DEVX-1026). The PTY makes the child see a terminal (line-buffered, colored output; stdout/stderr merged, no new session so Ctrl-C still reaches it via the process group) while the master side is copied through the spinner's `StopOnWriteWriter`. Falls back to plain `proc.Run` when no PTY can be allocated (Windows), and stays on pipes whenever stdout is redirected so pipelines never receive colors/CRLF. diff --git a/cmd/eksctl.go b/cmd/eksctl.go new file mode 100644 index 00000000..445aabf9 --- /dev/null +++ b/cmd/eksctl.go @@ -0,0 +1,84 @@ +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), so cluster operations target the emulator instead of real AWS. This mirrors the "Newer Versions" flow from the LocalStack docs; older eksctl releases ignore these variables and are rejected. + +Supported environment variables: + LSTK_EKSCTL_CMD eksctl binary to invoke (default eksctl) + 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..1b60aa7a 100644 --- a/cmd/iac.go +++ b/cmd/iac.go @@ -22,13 +22,13 @@ import ( 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 54430688..751407b6 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -188,13 +188,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..21c06e03 --- /dev/null +++ b/internal/eksctl/env.go @@ -0,0 +1,88 @@ +package eksctl + +import "strings" + +// endpointEnvVars are the AWS service endpoint variables eksctl (via aws-sdk-go) +// reads to route each service at a custom endpoint. lstk sets them all to the +// resolved LocalStack endpoint so `eksctl create cluster` and friends target the +// emulator. This is the "Newer Versions" flow from the LocalStack eksctl docs; +// older eksctl releases ignore these variables (see version.go for the gate). +var endpointEnvVars = []string{ + "AWS_CLOUDFORMATION_ENDPOINT", + "AWS_EC2_ENDPOINT", + "AWS_EKS_ENDPOINT", + "AWS_ELB_ENDPOINT", + "AWS_ELBV2_ENDPOINT", + "AWS_IAM_ENDPOINT", + "AWS_STS_ENDPOINT", +} + +// strippedKeys are ambient AWS configuration variables removed from the eksctl +// subprocess environment. A named profile, default profile, or stale session +// token could otherwise resolve real credentials and silently redirect a +// cluster operation at real AWS. The endpoint variables above pin the service +// endpoints at LocalStack regardless, but stripping these keeps credentials and +// account resolution predictable. +var strippedKeys = map[string]bool{ + "AWS_PROFILE": true, + "AWS_DEFAULT_PROFILE": true, + "AWS_SESSION_TOKEN": true, +} + +// 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 only when absent so a 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 and none are stripped — the invocation does not +// contact LocalStack, so the caller's environment is left as-is apart from the +// credential defaults. +func BuildEnv(base []string, endpointURL string) []string { + managed := make(map[string]bool, len(endpointEnvVars)) + if endpointURL != "" { + for _, k := range endpointEnvVars { + managed[k] = true + } + } + + env := make([]string, 0, len(base)+len(endpointEnvVars)+4) + for _, e := range base { + key, _, ok := strings.Cut(e, "=") + if !ok { + env = append(env, e) + continue + } + if strippedKeys[key] || managed[key] { + continue + } + env = append(env, e) + } + + if endpointURL != "" { + for _, k := range endpointEnvVars { + env = append(env, k+"="+endpointURL) + } + } + + // 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. + setIfAbsent(&env, "AWS_ACCESS_KEY_ID", "test") + setIfAbsent(&env, "AWS_SECRET_ACCESS_KEY", "test") + setIfAbsent(&env, "AWS_DEFAULT_REGION", "us-east-1") + setIfAbsent(&env, "AWS_REGION", "us-east-1") + + return env +} + +func setIfAbsent(env *[]string, key, value string) { + prefix := key + "=" + for _, e := range *env { + if strings.HasPrefix(e, prefix) { + 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..7967cc1b --- /dev/null +++ b/internal/eksctl/env_test.go @@ -0,0 +1,91 @@ +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) + } + // 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 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 is still defaulted since only AWS_REGION was set. + 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 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..58c21272 --- /dev/null +++ b/internal/eksctl/exec.go @@ -0,0 +1,158 @@ +// 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" + + "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" +} + +// offlineCommands are the eksctl subcommands that never contact AWS APIs and so +// do not require a running emulator (nor the minimum-version gate). Everything +// else (create, get, delete, upgrade, scale, …) is treated as AWS-contacting. +var offlineCommands = map[string]bool{ + "version": true, + "info": true, + "help": true, + "completion": true, +} + +// helpFlags are the flags/tokens eksctl recognizes as a help request. +var helpFlags = map[string]bool{"-h": true, "--help": true, "help": true} + +// 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 helpFlags[a] { + 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) || offlineCommands[subcommand(args)] +} + +// valueFlags are eksctl global options that consume the following token as +// their value (space-separated form), so the subcommand scan must skip both the +// flag and its value. The `--flag=value` form needs no entry here — it is a +// single token skipped as an ordinary flag. +var valueFlags = map[string]bool{ + "-v": true, "--verbose": true, + "-C": true, "--color": true, +} + +// 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 valueFlags[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. 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") + 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) + } + } + + 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..0d7c3abe --- /dev/null +++ b/internal/eksctl/exec_test.go @@ -0,0 +1,51 @@ +package eksctl + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsHelp(t *testing.T) { + for _, args := range [][]string{ + {"--help"}, {"-h"}, {"help"}, {"create", "cluster", "--help"}, {"get", "clusters", "-h"}, + } { + assert.Truef(t, IsHelp(args), "%v", args) + } + for _, args := range [][]string{{"create", "cluster"}, {"get", "clusters"}, {}} { + assert.Falsef(t, IsHelp(args), "%v", args) + } +} + +func TestIsOffline(t *testing.T) { + offline := [][]string{ + {"version"}, + {"info"}, + {"completion", "bash"}, + {"help"}, + {"--help"}, + {"-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"}, + {}, // 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..8bb40705 --- /dev/null +++ b/internal/eksctl/version.go @@ -0,0 +1,65 @@ +package eksctl + +import ( + "context" + "fmt" + "os/exec" + "regexp" + "strconv" +) + +// minEksctlVersion is the lowest eksctl version lstk supports. From this version +// (the "Newer Versions" flow in the LocalStack eksctl docs) eksctl routes AWS +// services through the AWS_*_ENDPOINT environment variables lstk sets. Older +// releases ignore them and would silently target real AWS, so lstk refuses to +// run against them. +const ( + minEksctlMajor = 0 + minEksctlMinor = 181 + minEksctlPatch = 0 +) + +// minEksctlVersionString is the human-facing form used in error messages. +const minEksctlVersionString = "0.181.0" + +// versionRe matches the leading MAJOR.MINOR.PATCH of `eksctl version` output +// (e.g. "0.211.0"). +var versionRe = regexp.MustCompile(`(\d+)\.(\d+)\.(\d+)`) + +// 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, which only +// eksctl >= minEksctlVersionString honors; on an older (or unparseable) version +// lstk must refuse to run so it cannot silently target 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 { + 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, _ := strconv.Atoi(m[1]) + minor, _ := strconv.Atoi(m[2]) + patch, _ := strconv.Atoi(m[3]) + if !atLeastMinVersion(major, minor, patch) { + return fmt.Errorf("eksctl %d.%d.%d is too old; lstk requires %s or newer (it points eksctl at LocalStack via the AWS_*_ENDPOINT variables, which older versions ignore)", 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..c224c93e --- /dev/null +++ b/internal/eksctl/version_test.go @@ -0,0 +1,39 @@ +package eksctl + +import ( + "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}, + {"too old patch", "0.180.9", true}, + {"too old minor", "0.167.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) +} diff --git a/test/integration/eksctl_cmd_test.go b/test/integration/eksctl_cmd_test.go new file mode 100644 index 00000000..180b2231 --- /dev/null +++ b/test/integration/eksctl_cmd_test.go @@ -0,0 +1,227 @@ +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_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 +} + +// 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. +func TestEksctlVersionNoEmulator(t *testing.T) { + t.Parallel() + fakeDir := writeFakeEksctl(t, "0.211.0") + e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()) + + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "eksctl", "version") + require.NoError(t, err, "stderr: %s", stderr) + assert.Contains(t, stdout, "0.211.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"}, {"-h"}, {"create", "cluster", "--help"}} { + args := args + t.Run(strings.Join(args, "_"), func(t *testing.T) { + t.Parallel() + fakeDir := writeFakeEksctl(t, "0.211.0") + e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()) + + 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, " ")) + }) + } +} + +// a too-old eksctl fails before an AWS-contacting command runs. +func TestEksctlVersionTooOld(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + startTestContainer(t, ctx) + + fakeDir := writeFakeEksctl(t, "0.180.0") + e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()) + + stdout, stderr, err := runLstk(t, ctx, t.TempDir(), e, "eksctl", "get", "clusters") + require.Error(t, err) + assert.Contains(t, stderr+stdout, "0.181.0") + // eksctl was never run for real. + assert.NotContains(t, stdout, "ARGS:get") +} + +// a missing eksctl binary yields the install error. +func TestEksctlMissingBinary(t *testing.T) { + t.Parallel() + e := env.With(env.DisableEvents, "1").With("PATH", t.TempDir()).With(env.Home, 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 := env.With(env.DisableEvents, "1").With("PATH", dir).With(env.Home, t.TempDir()). + 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 := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()) + + 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") + e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()). + With(env.Key("AWS_PROFILE"), "my-real-profile"). + With(env.Key("AWS_SESSION_TOKEN"), "realtoken") + + 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, ":4566") + // 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=") +} + +// 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 := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()) + + 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. +func TestEksctlPropagatesExitCode(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + startTestContainer(t, ctx) + + fakeDir := writeFakeEksctlExit(t, 7) + e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()) + + _, stderr, err := runLstk(t, ctx, t.TempDir(), e, "eksctl", "get", "clusters") + require.Error(t, err) + assert.Contains(t, stderr, "simulated failure") + requireExitCode(t, 7, err) +} From a103890f70d6da5cacc237f3942bf34240c315e5 Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Wed, 22 Jul 2026 12:30:29 +0300 Subject: [PATCH 2/3] Fix eksctl help-token gate bypass, region defaults, and endpoint coverage Co-Authored-By: Claude --- CLAUDE.md | 2 +- cmd/eksctl.go | 5 ++- cmd/iac.go | 11 +++--- internal/eksctl/env.go | 59 +++++++++++++++++++++++------ internal/eksctl/env_test.go | 33 +++++++++++++++- internal/eksctl/exec.go | 26 ++++++++++--- internal/eksctl/exec_test.go | 9 +++-- internal/eksctl/version.go | 21 +++++----- internal/eksctl/version_test.go | 8 ++++ test/integration/eksctl_cmd_test.go | 26 +++++++------ 10 files changed, 150 insertions(+), 50 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 07298570..7f207f66 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -161,7 +161,7 @@ lstk proxies third-party IaC tools at the AWS emulator so they run against Local # 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 (`internal/eksctl/env.go`) to the resolved LocalStack endpoint, strips ambient AWS profile/session config, and defaults credentials/region only when absent. It gates on eksctl >= 0.181.0 (`internal/eksctl/version.go`), the version from which eksctl honors those endpoint variables — older versions are rejected rather than silently targeting real AWS (same rationale as the sam version gate). Offline subcommands (`version`, `info`, `completion`) and `--help` run without Docker or a running emulator; everything else requires the AWS emulator via the shared `requireRunningAWSEmulator`/`resolveAWSContainer` helpers in `cmd/iac.go`. `LSTK_EKSCTL_CMD` overrides the binary name. +`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), strips ambient AWS profile/session config, and defaults credentials/region only when absent (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 diff --git a/cmd/eksctl.go b/cmd/eksctl.go index 445aabf9..6ed184a3 100644 --- a/cmd/eksctl.go +++ b/cmd/eksctl.go @@ -22,10 +22,13 @@ func newEksctlCmd(cfg *env.Env, logger log.Logger) *cobra.Command { 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), so cluster operations target the emulator instead of real AWS. This mirrors the "Newer Versions" flow from the LocalStack docs; older eksctl releases ignore these variables and are rejected. +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) diff --git a/cmd/iac.go b/cmd/iac.go index 1b60aa7a..7f457cac 100644 --- a/cmd/iac.go +++ b/cmd/iac.go @@ -14,11 +14,12 @@ 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}$`) diff --git a/internal/eksctl/env.go b/internal/eksctl/env.go index 21c06e03..924063a4 100644 --- a/internal/eksctl/env.go +++ b/internal/eksctl/env.go @@ -1,12 +1,18 @@ package eksctl -import "strings" +import ( + "os" + "strings" +) -// endpointEnvVars are the AWS service endpoint variables eksctl (via aws-sdk-go) -// reads to route each service at a custom endpoint. lstk sets them all to the -// resolved LocalStack endpoint so `eksctl create cluster` and friends target the -// emulator. This is the "Newer Versions" flow from the LocalStack eksctl docs; -// older eksctl releases ignore these variables (see version.go for the gate). +// endpointEnvVars are the AWS service endpoint variables set to the resolved +// LocalStack endpoint so `eksctl create cluster` and friends target the +// emulator. The seven AWS__ENDPOINT names are the ones eksctl resolves +// itself per client (the "Newer Versions" flow from the LocalStack eksctl docs; +// see version.go for the gate). AWS_ENDPOINT_URL is the aws-sdk-go-v2 generic +// fallback: eksctl builds a few clients with no per-service override (SSM for +// AMI resolution, Outposts, the STS presigner), and without it those would +// resolve to real AWS endpoints. var endpointEnvVars = []string{ "AWS_CLOUDFORMATION_ENDPOINT", "AWS_EC2_ENDPOINT", @@ -15,6 +21,14 @@ var endpointEnvVars = []string{ "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") } // strippedKeys are ambient AWS configuration variables removed from the eksctl @@ -36,9 +50,10 @@ var strippedKeys = map[string]bool{ // is respected. // // When endpointURL is empty (offline subcommands like `version`/`completion`), -// no endpoint variables are set and none are stripped — the invocation does not -// contact LocalStack, so the caller's environment is left as-is apart from the -// credential defaults. +// 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 { managed := make(map[string]bool, len(endpointEnvVars)) if endpointURL != "" { @@ -71,12 +86,34 @@ func BuildEnv(base []string, endpointURL string) []string { // a user-set value (or eksctl's own --region flag) takes precedence. setIfAbsent(&env, "AWS_ACCESS_KEY_ID", "test") setIfAbsent(&env, "AWS_SECRET_ACCESS_KEY", "test") - setIfAbsent(&env, "AWS_DEFAULT_REGION", "us-east-1") - setIfAbsent(&env, "AWS_REGION", "us-east-1") + + // 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" + } + setIfAbsent(&env, "AWS_REGION", region) + setIfAbsent(&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 setIfAbsent(env *[]string, key, value string) { prefix := key + "=" for _, e := range *env { diff --git a/internal/eksctl/env_test.go b/internal/eksctl/env_test.go index 7967cc1b..e77714a9 100644 --- a/internal/eksctl/env_test.go +++ b/internal/eksctl/env_test.go @@ -47,8 +47,27 @@ func TestBuildEnvRespectsUserRegionAndAccount(t *testing.T) { assert.Equal(t, "eu-west-1", env["AWS_REGION"]) assert.Equal(t, "111111111111", env["AWS_ACCESS_KEY_ID"]) - // AWS_DEFAULT_REGION is still defaulted since only AWS_REGION was set. - assert.Equal(t, "us-east-1", env["AWS_DEFAULT_REGION"]) + // 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 TestBuildEnvStripsAmbientAWSConfig(t *testing.T) { @@ -81,6 +100,16 @@ func TestBuildEnvOfflineLeavesEndpointsUnset(t *testing.T) { 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...) diff --git a/internal/eksctl/exec.go b/internal/eksctl/exec.go index 58c21272..e6ee22cd 100644 --- a/internal/eksctl/exec.go +++ b/internal/eksctl/exec.go @@ -45,8 +45,13 @@ var offlineCommands = map[string]bool{ "completion": true, } -// helpFlags are the flags/tokens eksctl recognizes as a help request. -var helpFlags = map[string]bool{"-h": true, "--help": true, "help": true} +// helpFlags are the flags eksctl recognizes as a help request. Unlike the aws +// CLI, eksctl (Cobra-based) accepts a bare `help` only as the leading token — +// that case is covered by offlineCommands — so `help` is deliberately not +// matched here: in any later position it is a flag value (e.g. a cluster +// literally named "help"), and treating it as a help request would skip the +// emulator and version gates for an AWS-contacting command. +var helpFlags = map[string]bool{"-h": true, "--help": true} // IsHelp reports whether args requests eksctl's help output. eksctl answers this // without needing a running emulator. @@ -99,11 +104,13 @@ func subcommand(args []string) string { // 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. eksctl output is streamed -// unobstructed (no spinner); a non-zero exit is wrapped as a silent error so -// lstk does not reprint it. +// 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") + ctx, span := otel.Tracer("github.com/localstack/lstk/internal/eksctl").Start(ctx, "eksctl cli") defer span.End() bin, err := exec.LookPath(eksctlCmd()) @@ -130,6 +137,13 @@ func Run(ctx context.Context, endpointURL string, sink output.Sink, logger log.L } } + 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), diff --git a/internal/eksctl/exec_test.go b/internal/eksctl/exec_test.go index 0d7c3abe..11dbc35e 100644 --- a/internal/eksctl/exec_test.go +++ b/internal/eksctl/exec_test.go @@ -8,11 +8,13 @@ import ( func TestIsHelp(t *testing.T) { for _, args := range [][]string{ - {"--help"}, {"-h"}, {"help"}, {"create", "cluster", "--help"}, {"get", "clusters", "-h"}, + {"--help"}, {"-h"}, {"create", "cluster", "--help"}, {"get", "clusters", "-h"}, } { assert.Truef(t, IsHelp(args), "%v", args) } - for _, args := range [][]string{{"create", "cluster"}, {"get", "clusters"}, {}} { + // A bare leading "help" is offline via offlineCommands, not IsHelp; in any + // later position it is a flag value and must not be treated as help. + for _, args := range [][]string{{"create", "cluster"}, {"get", "clusters"}, {"help"}, {"delete", "cluster", "--name", "help"}, {}} { assert.Falsef(t, IsHelp(args), "%v", args) } } @@ -36,7 +38,8 @@ func TestIsOffline(t *testing.T) { {"get", "clusters"}, {"delete", "cluster", "--name", "demo"}, {"upgrade", "cluster"}, - {}, // no subcommand → not offline (gate on emulator) + {"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) diff --git a/internal/eksctl/version.go b/internal/eksctl/version.go index 8bb40705..4610a19b 100644 --- a/internal/eksctl/version.go +++ b/internal/eksctl/version.go @@ -8,11 +8,13 @@ import ( "strconv" ) -// minEksctlVersion is the lowest eksctl version lstk supports. From this version -// (the "Newer Versions" flow in the LocalStack eksctl docs) eksctl routes AWS -// services through the AWS_*_ENDPOINT environment variables lstk sets. Older -// releases ignore them and would silently target real AWS, so lstk refuses to -// run against them. +// 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 @@ -28,9 +30,10 @@ var versionRe = regexp.MustCompile(`(\d+)\.(\d+)\.(\d+)`) // 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, which only -// eksctl >= minEksctlVersionString honors; on an older (or unparseable) version -// lstk must refuse to run so it cannot silently target real AWS. +// 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 { @@ -48,7 +51,7 @@ func checkVersionString(out string) error { minor, _ := strconv.Atoi(m[2]) patch, _ := strconv.Atoi(m[3]) if !atLeastMinVersion(major, minor, patch) { - return fmt.Errorf("eksctl %d.%d.%d is too old; lstk requires %s or newer (it points eksctl at LocalStack via the AWS_*_ENDPOINT variables, which older versions ignore)", major, minor, patch, minEksctlVersionString) + 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 } diff --git a/internal/eksctl/version_test.go b/internal/eksctl/version_test.go index c224c93e..9d59539a 100644 --- a/internal/eksctl/version_test.go +++ b/internal/eksctl/version_test.go @@ -1,6 +1,8 @@ package eksctl import ( + "context" + "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -37,3 +39,9 @@ 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 index 180b2231..6726b816 100644 --- a/test/integration/eksctl_cmd_test.go +++ b/test/integration/eksctl_cmd_test.go @@ -32,6 +32,7 @@ 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_REGION=$AWS_REGION" echo "ENV_AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID" echo "ENV_AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY" @@ -59,15 +60,16 @@ exit %d return dir } -// offline subcommands (version) run without a running emulator or Docker. +// 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.211.0") + fakeDir := writeFakeEksctl(t, "0.150.0") e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()) stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "eksctl", "version") require.NoError(t, err, "stderr: %s", stderr) - assert.Contains(t, stdout, "0.211.0") + assert.Contains(t, stdout, "0.150.0") } // --help (and -h) never require the emulator and are forwarded to eksctl. @@ -163,7 +165,11 @@ func TestEksctlInjectsCleanAWSEnv(t *testing.T) { startTestContainer(t, ctx) fakeDir := writeFakeEksctl(t, "0.211.0") + // Strip ambient values the set-if-absent assertions below depend on, so a + // developer shell exporting real AWS config cannot fail the test. e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()). + Without(env.AWSAccessKeyID, env.AWSSecretAccessKey, + env.Key("AWS_REGION"), env.Key("AWS_DEFAULT_REGION"), env.Key("AWS_ENDPOINT_URL")). With(env.Key("AWS_PROFILE"), "my-real-profile"). With(env.Key("AWS_SESSION_TOKEN"), "realtoken") @@ -176,6 +182,7 @@ func TestEksctlInjectsCleanAWSEnv(t *testing.T) { 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") // Credential defaults are applied. assert.Contains(t, stdout, "ENV_AWS_ACCESS_KEY_ID=test") @@ -208,19 +215,14 @@ func TestEksctlRequiresAWSEmulator(t *testing.T) { assert.NotContains(t, stdout, "ARGS:get") } -// propagates the eksctl exit code. +// 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) { - requireDocker(t) - cleanup() - t.Cleanup(cleanup) - - ctx := testContext(t) - startTestContainer(t, ctx) - + t.Parallel() fakeDir := writeFakeEksctlExit(t, 7) e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()) - _, stderr, err := runLstk(t, ctx, t.TempDir(), e, "eksctl", "get", "clusters") + _, 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) From b7fc637fcf1dc38431a0b9e88dedba54d12b6adb Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Wed, 22 Jul 2026 12:59:47 +0300 Subject: [PATCH 3/3] Harden eksctl proxy endpoint isolation --- CLAUDE.md | 2 +- internal/eksctl/env.go | 95 ++++++++++++++++------------- internal/eksctl/env_test.go | 37 ++++++++++- internal/eksctl/exec.go | 53 ++++++++-------- internal/eksctl/exec_test.go | 9 ++- internal/eksctl/version.go | 16 ++--- internal/eksctl/version_test.go | 6 ++ test/integration/eksctl_cmd_test.go | 84 ++++++++++++++++++------- test/integration/json_flag_test.go | 24 +++++--- 9 files changed, 211 insertions(+), 115 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7f207f66..8641f6e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -161,7 +161,7 @@ lstk proxies third-party IaC tools at the AWS emulator so they run against Local # 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), strips ambient AWS profile/session config, and defaults credentials/region only when absent (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. +`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 diff --git a/internal/eksctl/env.go b/internal/eksctl/env.go index 924063a4..b20259f3 100644 --- a/internal/eksctl/env.go +++ b/internal/eksctl/env.go @@ -5,23 +5,21 @@ import ( "strings" ) -// endpointEnvVars are the AWS service endpoint variables set to the resolved -// LocalStack endpoint so `eksctl create cluster` and friends target the -// emulator. The seven AWS__ENDPOINT names are the ones eksctl resolves -// itself per client (the "Newer Versions" flow from the LocalStack eksctl docs; -// see version.go for the gate). AWS_ENDPOINT_URL is the aws-sdk-go-v2 generic -// fallback: eksctl builds a few clients with no per-service override (SSM for -// AMI resolution, Outposts, the STS presigner), and without it those would -// resolve to real AWS endpoints. -var endpointEnvVars = []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", +// 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, @@ -31,23 +29,31 @@ func endpointURLOverride() string { return os.Getenv("AWS_ENDPOINT_URL") } -// strippedKeys are ambient AWS configuration variables removed from the eksctl -// subprocess environment. A named profile, default profile, or stale session -// token could otherwise resolve real credentials and silently redirect a -// cluster operation at real AWS. The endpoint variables above pin the service -// endpoints at LocalStack regardless, but stripping these keeps credentials and -// account resolution predictable. -var strippedKeys = map[string]bool{ - "AWS_PROFILE": true, - "AWS_DEFAULT_PROFILE": true, - "AWS_SESSION_TOKEN": true, +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 only when absent so a user-provided AWS_REGION or AWS_ACCESS_KEY_ID -// is respected. +// 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 @@ -55,37 +61,35 @@ var strippedKeys = map[string]bool{ // defaults still applied, keeping the subprocess environment predictable on // every path. func BuildEnv(base []string, endpointURL string) []string { - managed := make(map[string]bool, len(endpointEnvVars)) - if endpointURL != "" { - for _, k := range endpointEnvVars { - managed[k] = true - } - } + endpointKeys := endpointEnvVars() - env := make([]string, 0, len(base)+len(endpointEnvVars)+4) + 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 strippedKeys[key] || managed[key] { + if isStrippedAWSConfigKey(key) || (endpointURL != "" && isEndpointConfigKey(key)) { continue } env = append(env, e) } if endpointURL != "" { - for _, k := range endpointEnvVars { + 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. - setIfAbsent(&env, "AWS_ACCESS_KEY_ID", "test") - setIfAbsent(&env, "AWS_SECRET_ACCESS_KEY", "test") + 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 @@ -98,8 +102,8 @@ func BuildEnv(base []string, endpointURL string) []string { if region == "" { region = "us-east-1" } - setIfAbsent(&env, "AWS_REGION", region) - setIfAbsent(&env, "AWS_DEFAULT_REGION", region) + setDefault(&env, "AWS_REGION", region) + setDefault(&env, "AWS_DEFAULT_REGION", region) return env } @@ -114,10 +118,13 @@ func lookup(env []string, key string) string { return "" } -func setIfAbsent(env *[]string, key, value string) { +func setDefault(env *[]string, key, value string) { prefix := key + "=" - for _, e := range *env { + for i, e := range *env { if strings.HasPrefix(e, prefix) { + if len(e) == len(prefix) { + (*env)[i] = prefix + value + } return } } diff --git a/internal/eksctl/env_test.go b/internal/eksctl/env_test.go index e77714a9..8f628be8 100644 --- a/internal/eksctl/env_test.go +++ b/internal/eksctl/env_test.go @@ -23,9 +23,10 @@ func TestBuildEnvSetsAllServiceEndpoints(t *testing.T) { const url = "http://localhost.localstack.cloud:4566" env := envMap(BuildEnv(nil, url)) - for _, k := range endpointEnvVars { + 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"]) @@ -41,6 +42,23 @@ func TestBuildEnvOverridesExistingEndpoints(t *testing.T) { 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")) @@ -70,6 +88,21 @@ func TestBuildEnvKeepsContradictoryUserRegionsVerbatim(t *testing.T) { 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", @@ -92,7 +125,7 @@ func TestBuildEnvStripsAmbientAWSConfig(t *testing.T) { func TestBuildEnvOfflineLeavesEndpointsUnset(t *testing.T) { env := envMap(BuildEnv(nil, "")) - for _, k := range endpointEnvVars { + for _, k := range endpointEnvVars() { _, ok := env[k] assert.Falsef(t, ok, "%s must not be set when endpointURL is empty", k) } diff --git a/internal/eksctl/exec.go b/internal/eksctl/exec.go index e6ee22cd..9d0bb110 100644 --- a/internal/eksctl/exec.go +++ b/internal/eksctl/exec.go @@ -11,6 +11,8 @@ import ( "fmt" "os" "os/exec" + "strconv" + "strings" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" @@ -35,29 +37,28 @@ func eksctlCmd() string { return "eksctl" } -// offlineCommands are the eksctl subcommands that never contact AWS APIs and so -// do not require a running emulator (nor the minimum-version gate). Everything -// else (create, get, delete, upgrade, scale, …) is treated as AWS-contacting. -var offlineCommands = map[string]bool{ - "version": true, - "info": true, - "help": true, - "completion": true, +func isOfflineCommand(command string) bool { + switch command { + case "version", "info", "help", "completion": + return true + default: + return false + } } -// helpFlags are the flags eksctl recognizes as a help request. Unlike the aws -// CLI, eksctl (Cobra-based) accepts a bare `help` only as the leading token — -// that case is covered by offlineCommands — so `help` is deliberately not -// matched here: in any later position it is a flag value (e.g. a cluster -// literally named "help"), and treating it as a help request would skip the -// emulator and version gates for an AWS-contacting command. -var helpFlags = map[string]bool{"-h": true, "--help": true} - // 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 helpFlags[a] { + 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 } } @@ -67,16 +68,16 @@ func IsHelp(args []string) bool { // 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) || offlineCommands[subcommand(args)] + return IsHelp(args) || isOfflineCommand(subcommand(args)) } -// valueFlags are eksctl global options that consume the following token as -// their value (space-separated form), so the subcommand scan must skip both the -// flag and its value. The `--flag=value` form needs no entry here — it is a -// single token skipped as an ordinary flag. -var valueFlags = map[string]bool{ - "-v": true, "--verbose": true, - "-C": true, "--color": true, +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 @@ -88,7 +89,7 @@ func subcommand(args []string) string { continue } if a[0] == '-' { - if valueFlags[a] && i+1 < len(args) { + if globalFlagTakesValue(a) && i+1 < len(args) { i++ // skip this flag's value } continue diff --git a/internal/eksctl/exec_test.go b/internal/eksctl/exec_test.go index 11dbc35e..a4de020a 100644 --- a/internal/eksctl/exec_test.go +++ b/internal/eksctl/exec_test.go @@ -8,13 +8,14 @@ import ( func TestIsHelp(t *testing.T) { for _, args := range [][]string{ - {"--help"}, {"-h"}, {"create", "cluster", "--help"}, {"get", "clusters", "-h"}, + {"--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 offline via offlineCommands, not IsHelp; in any + // 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{{"create", "cluster"}, {"get", "clusters"}, {"help"}, {"delete", "cluster", "--name", "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) } } @@ -26,6 +27,7 @@ func TestIsOffline(t *testing.T) { {"completion", "bash"}, {"help"}, {"--help"}, + {"--help=true"}, {"-h"}, {"create", "cluster", "--help"}, } @@ -38,6 +40,7 @@ func TestIsOffline(t *testing.T) { {"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) } diff --git a/internal/eksctl/version.go b/internal/eksctl/version.go index 4610a19b..885ced7d 100644 --- a/internal/eksctl/version.go +++ b/internal/eksctl/version.go @@ -24,10 +24,6 @@ const ( // minEksctlVersionString is the human-facing form used in error messages. const minEksctlVersionString = "0.181.0" -// versionRe matches the leading MAJOR.MINOR.PATCH of `eksctl version` output -// (e.g. "0.211.0"). -var versionRe = regexp.MustCompile(`(\d+)\.(\d+)\.(\d+)`) - // 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 @@ -43,14 +39,18 @@ func CheckVersion(ctx context.Context, bin string) error { } 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, _ := strconv.Atoi(m[1]) - minor, _ := strconv.Atoi(m[2]) - patch, _ := strconv.Atoi(m[3]) - if !atLeastMinVersion(major, minor, patch) { + 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 diff --git a/internal/eksctl/version_test.go b/internal/eksctl/version_test.go index 9d59539a..268ca494 100644 --- a/internal/eksctl/version_test.go +++ b/internal/eksctl/version_test.go @@ -18,8 +18,14 @@ func TestCheckVersionString(t *testing.T) { {"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}, } diff --git a/test/integration/eksctl_cmd_test.go b/test/integration/eksctl_cmd_test.go index 6726b816..f061bfdf 100644 --- a/test/integration/eksctl_cmd_test.go +++ b/test/integration/eksctl_cmd_test.go @@ -33,6 +33,9 @@ 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" @@ -43,6 +46,13 @@ echo "ENV_AWS_SESSION_TOKEN=${AWS_SESSION_TOKEN:-}" 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 { @@ -65,7 +75,7 @@ exit %d func TestEksctlVersionNoEmulator(t *testing.T) { t.Parallel() fakeDir := writeFakeEksctl(t, "0.150.0") - e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()) + e := eksctlTestEnv(t, fakeDir) stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "eksctl", "version") require.NoError(t, err, "stderr: %s", stderr) @@ -75,12 +85,12 @@ func TestEksctlVersionNoEmulator(t *testing.T) { // --help (and -h) never require the emulator and are forwarded to eksctl. func TestEksctlHelpNoEmulator(t *testing.T) { t.Parallel() - for _, args := range [][]string{{"--help"}, {"-h"}, {"create", "cluster", "--help"}} { + 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 := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()) + e := eksctlTestEnv(t, fakeDir) cmdArgs := append([]string{"eksctl"}, args...) stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, cmdArgs...) @@ -90,8 +100,9 @@ func TestEksctlHelpNoEmulator(t *testing.T) { } } -// a too-old eksctl fails before an AWS-contacting command runs. -func TestEksctlVersionTooOld(t *testing.T) { +// an unsupported or ambiguous eksctl version fails before an AWS-contacting +// command runs. +func TestEksctlRejectsUnsupportedVersion(t *testing.T) { requireDocker(t) cleanup() t.Cleanup(cleanup) @@ -99,20 +110,29 @@ func TestEksctlVersionTooOld(t *testing.T) { ctx := testContext(t) startTestContainer(t, ctx) - fakeDir := writeFakeEksctl(t, "0.180.0") - e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()) + 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") - // eksctl was never run for real. - assert.NotContains(t, stdout, "ARGS:get") + 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 := env.With(env.DisableEvents, "1").With("PATH", t.TempDir()).With(env.Home, t.TempDir()) + e := eksctlTestEnv(t, t.TempDir()) stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "eksctl", "version") require.Error(t, err) @@ -128,7 +148,7 @@ func TestEksctlHonorsLstkEksctlCmd(t *testing.T) { 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 := env.With(env.DisableEvents, "1").With("PATH", dir).With(env.Home, t.TempDir()). + e := eksctlTestEnv(t, dir). With(env.Key("LSTK_EKSCTL_CMD"), "myeksctl") stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "eksctl", "info") @@ -144,7 +164,7 @@ func TestEksctlFailsWhenEmulatorNotRunning(t *testing.T) { t.Cleanup(cleanup) fakeDir := writeFakeEksctl(t, "0.211.0") - e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()) + e := eksctlTestEnv(t, fakeDir) stdout, _, err := runLstk(t, testContext(t), t.TempDir(), e, "eksctl", "get", "clusters") require.Error(t, err) @@ -165,13 +185,19 @@ func TestEksctlInjectsCleanAWSEnv(t *testing.T) { startTestContainer(t, ctx) fakeDir := writeFakeEksctl(t, "0.211.0") - // Strip ambient values the set-if-absent assertions below depend on, so a - // developer shell exporting real AWS config cannot fail the test. - e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()). - Without(env.AWSAccessKeyID, env.AWSSecretAccessKey, - env.Key("AWS_REGION"), env.Key("AWS_DEFAULT_REGION"), env.Key("AWS_ENDPOINT_URL")). + // 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_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) @@ -184,6 +210,11 @@ func TestEksctlInjectsCleanAWSEnv(t *testing.T) { 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") @@ -191,6 +222,13 @@ func TestEksctlInjectsCleanAWSEnv(t *testing.T) { // 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 @@ -206,7 +244,7 @@ func TestEksctlRequiresAWSEmulator(t *testing.T) { startTestSnowflakeContainer(t, ctx) fakeDir := writeFakeEksctl(t, "0.211.0") - e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()) + e := eksctlTestEnv(t, fakeDir) stdout, _, err := runLstk(t, ctx, t.TempDir(), e, "eksctl", "get", "clusters") require.Error(t, err) @@ -220,7 +258,7 @@ func TestEksctlRequiresAWSEmulator(t *testing.T) { func TestEksctlPropagatesExitCode(t *testing.T) { t.Parallel() fakeDir := writeFakeEksctlExit(t, 7) - e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()) + e := eksctlTestEnv(t, fakeDir) _, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "eksctl", "info") require.Error(t, 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)