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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -75,7 +75,7 @@ Notes:
- `version/` - Version info
- `volume/` - `lstk volume` domain logic

Commands are registered in `cmd/root.go` in two Cobra groups: the `commands` group (start, stop, restart, login, logout, status, logs, setup, config, volume, update, docs, snapshot, reset, save, load) and the `tools` group of proxy commands (aws, terraform/tf, cdk, sam, az). Shared helpers: `cmd/root.go` (wiring, groups, `requireSubcommand`, `initConfig`), `cmd/help.go` (help template), `cmd/iac.go` (IaC command boundary), `cmd/extension.go` (extension dispatch).
Commands are registered in `cmd/root.go` in two Cobra groups: the `commands` group (start, stop, restart, login, logout, status, logs, setup, config, volume, update, docs, snapshot, reset, save, load) and the `tools` group of proxy commands (aws, terraform/tf, cdk, sam, az, eksctl). Shared helpers: `cmd/root.go` (wiring, groups, `requireSubcommand`, `initConfig`), `cmd/help.go` (help template), `cmd/iac.go` (IaC command boundary), `cmd/extension.go` (extension dispatch).

# Commits, PRs, and Linear

Expand Down Expand Up @@ -147,13 +147,17 @@ Environment variables:

lstk proxies third-party IaC tools at the AWS emulator so they run against LocalStack with no `*local` wrapper installed. Each command forwards its args to the real tool after configuring the environment; domain logic lives under `internal/iac/<tool>/cli/`, wiring in `cmd/<tool>.go`, with shared command-boundary helpers in `cmd/iac.go`. Siblings: `lstk terraform` (alias `tf`), `lstk cdk`, `lstk sam`.

# eksctl Proxy

`lstk eksctl` proxies [eksctl](https://eksctl.io/) at the AWS emulator, replacing the manual `AWS_*_ENDPOINT` exports from the "Newer Versions" flow in the LocalStack eksctl docs. Domain logic is `internal/eksctl/` (an exec wrapper like `awscli`/`azurecli`, not an IaC tool), wiring in `cmd/eksctl.go`. It sets the CloudFormation, EC2, EKS, ELB, ELBv2, IAM, and STS service endpoints plus the generic `AWS_ENDPOINT_URL` (`internal/eksctl/env.go` — the generic var covers the clients eksctl builds without a per-service override: SSM, Outposts, the STS presigner) to the resolved LocalStack endpoint, honors a user-set `AWS_ENDPOINT_URL` as an override (same contract as the terraform/cdk/sam proxies), clears inherited service-specific endpoint overrides and `AWS_IGNORE_CONFIGURED_ENDPOINT_URLS`, strips ambient AWS profile/session config, and defaults credentials/region when unset or empty (one resolved region seeds both `AWS_REGION` and `AWS_DEFAULT_REGION` so the injected pair can't contradict a user-set one). It gates on eksctl >= 0.181.0 (`internal/eksctl/version.go`) — the boundary the LocalStack docs define for the env-var flow (0.181.0 moved endpoint resolution to per-client resolvers; 0.180.0 read the same variables via a deprecated SDK global resolver, so the gate is about supporting only the documented flow, not about older versions ignoring the variables). Offline subcommands (`version`, `info`, `completion`) and `-h`/`--help` run without Docker, a running emulator, or the version gate; a bare `help` counts only as the leading token (elsewhere it's a flag value — e.g. a cluster named "help" — and must not skip the gates). Everything else requires the AWS emulator via the shared `requireRunningAWSEmulator`/`resolveAWSContainer` helpers in `cmd/iac.go`. `LSTK_EKSCTL_CMD` overrides the binary name.

# Extensions

lstk supports Git-style extensions: when `lstk <name>` is not a built-in command or alias, lstk resolves and execs an external `lstk-<name>` executable, forwarding arguments verbatim and propagating the exit code. Built-ins always win. Resolution order is built-ins → bundled dir (the directory of the symlink-resolved lstk executable) → `PATH`; there is no manifest. Runtime context is conveyed via `LSTK_EXT_API_VERSION` and `LSTK_EXT_CONTEXT` (JSON: `configDir`, optional `authToken`, `nonInteractive`, `json`, `emulators` array) — see `extension.Context`/`Environ` in `internal/extension/context.go`; dispatch and help listing are in `cmd/extension.go`. Automated distribution/co-update of bundled extensions is deferred to the `add-bundled-extension-distribution` change. See [extensions-authoring.md](docs/extensions-authoring.md) for the author-facing contract.

# Signal Forwarding to Wrapped Tools

Wrapped external tools (`aws`, `terraform`, `cdk`, `sam`, `az`, and extensions) are run through `proc.Run(cmd)` (in `internal/proc/run.go`) rather than `cmd.Run()`. These execs are created with `exec.CommandContext` using lstk's root context, which is cancelled on `SIGINT`/`SIGTERM`; `exec.CommandContext`'s default `Cancel` would then SIGKILL the child immediately, denying tools like `terraform apply` the chance to clean up (e.g. release the state lock). `proc.Run` disarms that (its `Cancel` returns `os.ErrProcessDone`, which both suppresses the kill and avoids injecting `context.Canceled` into the wait result, preserving the tool's real exit code) and instead lets the tool terminate from the signal it receives, waiting for it to finish its own shutdown. Forwarding is per-signal: `SIGTERM` is always relayed to the child (a terminal never generates it, so `kill <lstk-pid>` / `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 <lstk-pid>` / `timeout` / an IDE stop button would otherwise never reach the tool), while `SIGINT` is relayed only when none of lstk's std streams is a terminal — an attached terminal already delivers Ctrl-C to the child via the foreground process group, and a second near-simultaneous SIGINT makes tools like terraform abort immediately instead of cleaning up. The any-stream check matters: with only stdin redirected (`yes | lstk terraform apply`) lstk still sits in the terminal's foreground process group. This differs from `npm/launcher.js`, which forwards unconditionally — safe there because its child is lstk itself, which tolerates duplicate signals; wrapped tools do not. Short internal captured-output execs (version checks, schema discovery, backend provisioning) still use `cmd.Run()` directly. End-to-end signal tests live in `test/integration/signal_forwarding_test.go`, backed by the reference extension's `signal-wait` mode.


# Snapshots
Expand Down
87 changes: 87 additions & 0 deletions cmd/eksctl.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package cmd

import (
"fmt"
"os"

"github.com/localstack/lstk/internal/eksctl"
"github.com/localstack/lstk/internal/endpoint"
"github.com/localstack/lstk/internal/env"
"github.com/localstack/lstk/internal/log"
"github.com/localstack/lstk/internal/output"
"github.com/localstack/lstk/internal/runtime"
"github.com/spf13/cobra"
)

func newEksctlCmd(cfg *env.Env, logger log.Logger) *cobra.Command {
// DisableFlagParsing means Cobra won't strip lstk's own flags; PreRunE does
// that and stashes the remaining args here for RunE to forward to eksctl.
var passthrough []string
return &cobra.Command{
Use: "eksctl [args...]",
Short: "Run eksctl against LocalStack",
Long: `Proxy eksctl commands to the running LocalStack emulator.

Requires eksctl version 0.181.0 or newer on your PATH. lstk points eksctl at LocalStack by setting the AWS service endpoint environment variables it reads (CloudFormation, EC2, EKS, ELB, ELBv2, IAM, STS, plus the generic AWS_ENDPOINT_URL), so cluster operations target the emulator instead of real AWS. This is the "Newer Versions" flow from the LocalStack docs; older eksctl releases are rejected since lstk supports only that flow.

eksctl support in LocalStack is experimental and may not work in all cases.

Supported environment variables:
LSTK_EKSCTL_CMD eksctl binary to invoke (default eksctl)
AWS_ENDPOINT_URL Overrides the auto-resolved LocalStack endpoint
AWS_REGION Deployment region (default us-east-1)
AWS_ACCESS_KEY_ID Access key LocalStack derives the account from (default test)

Examples:
lstk eksctl create cluster --nodes 1
lstk eksctl get clusters
lstk eksctl delete cluster --name my-cluster`,
DisableFlagParsing: true,
PreRunE: func(cmd *cobra.Command, args []string) error {
var gf globalFlags
passthrough, gf = stripGlobalFlags(args)
if gf.nonInteractive {
cfg.NonInteractive = true
}
if jsonPrecedesCommandName(cmd.CalledAs()) {
cfg.JSON = true
}
if gf.configPath != "" {
// initConfigDeferCreate reads the "config" flag, so feed the value back to it.
if err := cmd.Flags().Set("config", gf.configPath); err != nil {
return err
}
}
return initConfigDeferCreate(nil)(cmd, args)
},
RunE: func(cmd *cobra.Command, _ []string) error {
sink := output.NewPlainSink(os.Stdout)

// Offline subcommands (version/info/completion) and --help never
// contact AWS, so they run without Docker or a running emulator.
if eksctl.IsOffline(passthrough) {
return eksctl.Run(cmd.Context(), "", sink, logger, passthrough)
}

rt, err := runtime.NewDockerRuntime(cfg.DockerHost)
if err != nil {
return err
}

awsContainer := resolveAWSContainer()

if err := rt.IsHealthy(cmd.Context()); err != nil {
rt.EmitUnhealthyError(sink, err)
return output.NewSilentError(fmt.Errorf("runtime not healthy: %w", err))
}

if err := requireRunningAWSEmulator(cmd.Context(), rt, sink, awsContainer, "eksctl"); err != nil {
return err
}

host, _ := endpoint.ResolveHost(cmd.Context(), awsContainer.Port, cfg.LocalStackHost)

return eksctl.Run(cmd.Context(), "http://"+host, sink, logger, passthrough)
},
}
}
2 changes: 1 addition & 1 deletion cmd/help_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
25 changes: 13 additions & 12 deletions cmd/iac.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,22 @@ import (
"github.com/localstack/lstk/internal/runtime"
)

// Shared command-boundary helpers for the IaC proxy commands (terraform, cdk).
// These live here rather than in any one command's file because both commands
// depend on them equally; keeping them in cmd/ (not a domain package) is
// deliberate — they touch config.Get(), the output.Sink, and the raw CLI args,
// all of which are command-boundary concerns.
// Shared command-boundary helpers for the AWS-targeting proxy commands
// (terraform, cdk, sam, eksctl). These live here rather than in any one
// command's file because the commands depend on them equally; keeping them in
// cmd/ (not a domain package) is deliberate — they touch config.Get(), the
// output.Sink, and the raw CLI args, all of which are command-boundary
// concerns.

var accountIDRe = regexp.MustCompile(`^\d{12}$`)

// requireRunningAWSEmulator verifies the AWS emulator is running before an IaC
// proxy command (terraform/cdk) that contacts AWS proceeds. When it is not
// running it emits an actionable error through the sink — an AWS-specific
// message naming the other emulator when a non-AWS one is up, otherwise the
// generic "not running" error — and returns a silent error. cmdLabel is the
// lstk command name used in the message (e.g. "terraform"/"cdk"). It returns nil
// when the AWS emulator is running.
// requireRunningAWSEmulator verifies the AWS emulator is running before an
// AWS-targeting proxy command (terraform/cdk/sam/eksctl) that contacts AWS
// proceeds. When it is not running it emits an actionable error through the
// sink — an AWS-specific message naming the other emulator when a non-AWS one
// is up, otherwise the generic "not running" error — and returns a silent
// error. cmdLabel is the lstk command name used in the message (e.g.
// "terraform"/"cdk"/"eksctl"). It returns nil when the AWS emulator is running.
func requireRunningAWSEmulator(ctx context.Context, rt runtime.Runtime, sink output.Sink, awsContainer config.ContainerConfig, cmdLabel string) error {
runningName, err := container.ResolveRunningContainerName(ctx, rt, awsContainer)
if err != nil {
Expand Down
3 changes: 2 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,13 +181,14 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C
}

// Proxy commands that forward to a wrapped tool (AWS/Azure CLI, Terraform,
// CDK, SAM) configured to target LocalStack.
// CDK, SAM, eksctl) configured to target LocalStack.
tools := []*cobra.Command{
newAWSCmd(cfg),
newTerraformCmd(cfg, logger),
newCDKCmd(cfg, logger),
newSamCmd(cfg, logger),
newAzCmd(cfg),
newEksctlCmd(cfg, logger),
}
for _, c := range tools {
c.GroupID = groupTools
Expand Down
132 changes: 132 additions & 0 deletions internal/eksctl/env.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package eksctl

import (
"os"
"strings"
)

// endpointEnvVars returns the AWS service endpoint variables set to the
// resolved LocalStack endpoint. The seven AWS_<SVC>_ENDPOINT names are the ones
// eksctl resolves itself per client; AWS_ENDPOINT_URL covers clients eksctl
// builds without one of those overrides, including SSM and Outposts.
func endpointEnvVars() []string {
return []string{
"AWS_CLOUDFORMATION_ENDPOINT",
"AWS_EC2_ENDPOINT",
"AWS_EKS_ENDPOINT",
"AWS_ELB_ENDPOINT",
"AWS_ELBV2_ENDPOINT",
"AWS_IAM_ENDPOINT",
"AWS_STS_ENDPOINT",
"AWS_ENDPOINT_URL",
}
}

// endpointURLOverride returns AWS_ENDPOINT_URL from the process environment,
// which takes precedence over the auto-resolved LocalStack endpoint (same
// contract as the terraform/cdk/sam proxies).
func endpointURLOverride() string {
return os.Getenv("AWS_ENDPOINT_URL")
}

func isStrippedAWSConfigKey(key string) bool {
switch key {
case "AWS_PROFILE", "AWS_DEFAULT_PROFILE", "AWS_SESSION_TOKEN":
return true
default:
return false
}
}

// isEndpointConfigKey reports whether key can override the LocalStack endpoint
// for an AWS service. Service-specific AWS_ENDPOINT_URL_<SERVICE> values take
// precedence over AWS_ENDPOINT_URL, while eksctl also supports legacy
// AWS_<SERVICE>_ENDPOINT variables for some clients.
func isEndpointConfigKey(key string) bool {
return key == "AWS_ENDPOINT_URL" ||
key == "AWS_IGNORE_CONFIGURED_ENDPOINT_URLS" ||
strings.HasPrefix(key, "AWS_ENDPOINT_URL_") ||
(strings.HasPrefix(key, "AWS_") && strings.HasSuffix(key, "_ENDPOINT"))
}

// BuildEnv returns the environment for the eksctl subprocess: base with ambient
// AWS profile/session config stripped, the LocalStack service endpoint variables
// set (overriding any pre-existing entries), and credential/region defaults
// filled in when absent or empty so a meaningful user-provided AWS_REGION or
// AWS_ACCESS_KEY_ID is respected.
//
// When endpointURL is empty (offline subcommands like `version`/`completion`),
// no endpoint variables are set or stripped — the invocation does not contact
// LocalStack. The profile/session keys are still stripped and the credential
// defaults still applied, keeping the subprocess environment predictable on
// every path.
func BuildEnv(base []string, endpointURL string) []string {
endpointKeys := endpointEnvVars()

env := make([]string, 0, len(base)+len(endpointKeys)+5)
for _, e := range base {
key, _, ok := strings.Cut(e, "=")
if !ok {
env = append(env, e)
continue
}
if isStrippedAWSConfigKey(key) || (endpointURL != "" && isEndpointConfigKey(key)) {
continue
}
env = append(env, e)
}

if endpointURL != "" {
for _, k := range endpointKeys {
env = append(env, k+"="+endpointURL)
}
// A caller or AWS profile can otherwise make SDK-managed clients ignore
// the generic AWS_ENDPOINT_URL and fall back to their real endpoints.
env = append(env, "AWS_IGNORE_CONFIGURED_ENDPOINT_URLS=false")
}

// LocalStack derives the account id from the access key; "test" maps to the
// default account. Region defaults to us-east-1. Both are only defaults —
// a user-set value (or eksctl's own --region flag) takes precedence.
setDefault(&env, "AWS_ACCESS_KEY_ID", "test")
setDefault(&env, "AWS_SECRET_ACCESS_KEY", "test")

// Resolve one region and default both variables to it. Defaulting them
// independently would let an injected AWS_REGION=us-east-1 shadow a
// user-set AWS_DEFAULT_REGION (the SDK resolves AWS_REGION first), moving
// the cluster to a region the user never asked for.
region := lookup(env, "AWS_REGION")
if region == "" {
region = lookup(env, "AWS_DEFAULT_REGION")
}
if region == "" {
region = "us-east-1"
}
setDefault(&env, "AWS_REGION", region)
setDefault(&env, "AWS_DEFAULT_REGION", region)

return env
}

func lookup(env []string, key string) string {
prefix := key + "="
for _, e := range env {
if strings.HasPrefix(e, prefix) {
return strings.TrimPrefix(e, prefix)
}
}
return ""
}

func setDefault(env *[]string, key, value string) {
prefix := key + "="
for i, e := range *env {
if strings.HasPrefix(e, prefix) {
if len(e) == len(prefix) {
(*env)[i] = prefix + value
}
return
}
}
*env = append(*env, prefix+value)
}
Loading
Loading