Skip to content

Commit efd8783

Browse files
Add lstk cdk command (#302)
1 parent 6818438 commit efd8783

34 files changed

Lines changed: 1725 additions & 234 deletions

File tree

.github/workflows/ci.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,14 @@ jobs:
134134
with:
135135
tofu_wrapper: false
136136

137+
# Install the AWS CDK CLI so the `lstk cdk` end-to-end tests
138+
# (cdk_e2e_test.go) run on the Docker-capable Linux shards. They skip
139+
# automatically wherever cdk is absent (macOS/Windows). lstk requires
140+
# CDK >= 2.177.0; the latest release satisfies that.
141+
- name: Install AWS CDK
142+
if: matrix.os == 'ubuntu-latest'
143+
run: npm install -g aws-cdk
144+
137145
- name: Run integration tests
138146
run: make test-integration
139147
env:

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ Note: Integration tests require `LOCALSTACK_AUTH_TOKEN` environment variable for
4343
- `ui/` - Bubble Tea views for interactive output
4444
- `update/` - Self-update logic: version check via GitHub API, binary/Homebrew/npm update paths, archive extraction
4545
- `log/` - Internal diagnostic logging (not for user-facing output — use `output/` for that)
46-
- `iac/` - Wrappers for third-party infrastructure as code tools, such as Terraform.
46+
- `iac/` - Wrappers for third-party infrastructure as code tools, such as Terraform and CDK.
4747

4848
# Logging
4949

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ Running `lstk` will automatically handle configuration setup and start LocalStac
4949
- **Browser-based login** — authenticate via browser and store credentials securely in the system keyring
5050
- **AWS CLI profile** — optionally configure a `localstack` profile in `~/.aws/` after start
5151
- **Terraform integration** — proxy Terraform commands to LocalStack with automatic AWS provider endpoint configuration
52+
- **CDK integration** — proxy AWS CDK commands to LocalStack with automatic endpoint configuration (requires AWS CDK >= 2.177.0)
5253
- **Self-update** — check for and install the latest `lstk` release with `lstk update`
5354
- **Shell completions** — bash, zsh, and fish completions included
5455

@@ -192,6 +193,23 @@ lstk --non-interactive
192193
- `AWS_REGION` — Fallback for `--region` flag
193194
- `AWS_ACCESS_KEY_ID` — Fallback for `--account` flag
194195

196+
### CDK Integration
197+
198+
`lstk cdk` is a proxy that runs AWS CDK commands against LocalStack, pointing the CDK CLI at LocalStack's endpoints via environment variables (so deploys target the running emulator instead of real AWS).
199+
200+
**Requires AWS CDK CLI version 2.177.0 or newer** on your `PATH` (lstk targets LocalStack purely through environment variables, which older CDK versions ignore).
201+
202+
**lstk-specific flags** (appear after the `cdk` subcommand):
203+
- `--region <region>` — Deployment region (default: `us-east-1`)
204+
205+
CDK always targets the default LocalStack account 000000000000; there is no --account flag.
206+
207+
**Environment variables:**
208+
- `LSTK_CDK_CMD` — CDK binary to invoke (default: `cdk`)
209+
- `AWS_ENDPOINT_URL` — Override the auto-resolved LocalStack endpoint
210+
- `AWS_ENDPOINT_URL_S3` — Override the auto-derived S3 endpoint
211+
- `AWS_REGION` — Fallback for `--region` flag
212+
195213
## Usage
196214

197215
```bash
@@ -261,6 +279,13 @@ lstk terraform --region us-west-2 plan
261279
# Apply Terraform configuration (short form)
262280
lstk tf apply
263281

282+
# Bootstrap and deploy an AWS CDK app against LocalStack
283+
lstk cdk bootstrap
284+
lstk cdk deploy --require-approval never
285+
286+
# Synthesize a CDK app (offline, no running emulator needed)
287+
lstk cdk synth
288+
264289
```
265290

266291
## Snapshots

cmd/cdk.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"os"
6+
7+
"github.com/localstack/lstk/internal/endpoint"
8+
"github.com/localstack/lstk/internal/env"
9+
cdkcli "github.com/localstack/lstk/internal/iac/cdk/cli"
10+
"github.com/localstack/lstk/internal/log"
11+
"github.com/localstack/lstk/internal/output"
12+
"github.com/localstack/lstk/internal/runtime"
13+
"github.com/spf13/cobra"
14+
)
15+
16+
func newCDKCmd(cfg *env.Env, logger log.Logger) *cobra.Command {
17+
// DisableFlagParsing means Cobra won't strip lstk's own flags; PreRunE does
18+
// that and stashes the remaining args here for RunE to forward to cdk.
19+
var passthrough []string
20+
return &cobra.Command{
21+
Use: "cdk [args...]",
22+
Short: "Run AWS CDK against LocalStack",
23+
Long: `Proxy CDK commands to the running LocalStack emulator.
24+
25+
Requires the AWS CDK CLI version 2.177.0 or newer on your PATH.
26+
27+
lstk-specific flags (must appear before the cdk action):
28+
--region <region> Deployment region (default us-east-1)
29+
30+
CDK always targets the default LocalStack account 000000000000; there is no --account flag.
31+
32+
Supported environment variables:
33+
AWS_ENDPOINT_URL Override the auto-resolved LocalStack endpoint
34+
AWS_ENDPOINT_URL_S3 Override the auto-derived S3 endpoint
35+
LSTK_CDK_CMD CDK binary to invoke (default cdk)
36+
AWS_REGION Fallback for --region
37+
38+
Examples:
39+
lstk cdk bootstrap
40+
lstk cdk --region us-west-2 deploy
41+
lstk cdk synth`,
42+
DisableFlagParsing: true,
43+
PreRunE: func(cmd *cobra.Command, args []string) error {
44+
var gf globalFlags
45+
passthrough, gf = stripGlobalFlags(args)
46+
if gf.nonInteractive {
47+
cfg.NonInteractive = true
48+
}
49+
if gf.configPath != "" {
50+
if err := cmd.Flags().Set("config", gf.configPath); err != nil {
51+
return err
52+
}
53+
}
54+
return initConfig(nil)(cmd, args)
55+
},
56+
RunE: func(cmd *cobra.Command, _ []string) error {
57+
sink := output.NewPlainSink(os.Stdout)
58+
59+
if err := rejectPreSubcommandFlags(cmd.CalledAs()); err != nil {
60+
return emitValidationError(sink, err)
61+
}
62+
63+
cdkArgs, regionFlag, accountFlag, _, err := stripLeadingIaCFlags(passthrough, false)
64+
if err != nil {
65+
return emitValidationError(sink, err)
66+
}
67+
68+
// CDK has no reliable way to target a non-default account (the
69+
// account is derived by LocalStack from the access key id via an
70+
// STS round-trip CDK does not honor consistently), so --account is
71+
// rejected rather than silently ignored. CDK always uses the
72+
// default account 000000000000.
73+
if accountFlag != "" {
74+
return emitValidationError(sink, fmt.Errorf("--account is not supported by lstk cdk; CDK always uses the default LocalStack account 000000000000"))
75+
}
76+
77+
region := resolveRegion(regionFlag)
78+
79+
awsContainer := resolveAWSContainer()
80+
81+
// Offline subcommands never contact AWS, so they run without a
82+
// running emulator. We still resolve the endpoint (DNS only) and
83+
// inject it, so a synth-time context lookup routes to LocalStack.
84+
if cdkcli.IsOffline(cdkArgs) {
85+
host, _ := endpoint.ResolveHost(cmd.Context(), awsContainer.Port, cfg.LocalStackHost)
86+
return cdkcli.Run(cmd.Context(), "http://"+host, region, sink, logger, cdkArgs)
87+
}
88+
89+
rt, err := runtime.NewDockerRuntime(cfg.DockerHost)
90+
if err != nil {
91+
return err
92+
}
93+
94+
if err := rt.IsHealthy(cmd.Context()); err != nil {
95+
rt.EmitUnhealthyError(sink, err)
96+
return output.NewSilentError(fmt.Errorf("runtime not healthy: %w", err))
97+
}
98+
99+
if err := requireRunningAWSEmulator(cmd.Context(), rt, sink, awsContainer, "cdk"); err != nil {
100+
return err
101+
}
102+
103+
host, dnsOK := endpoint.ResolveHost(cmd.Context(), awsContainer.Port, cfg.LocalStackHost)
104+
if !dnsOK {
105+
// CDK has no env-only lever to force S3 path style, so on the
106+
// loopback fallback its S3 asset operations (bootstrap, asset
107+
// deploys) may fail virtual-host addressing. Warn rather than
108+
// block — non-S3 services still work. See the cdk-proxy design.
109+
sink.Emit(output.MessageEvent{
110+
Severity: output.SeverityWarning,
111+
Text: "Could not resolve localhost.localstack.cloud; using 127.0.0.1. CDK S3 asset operations (bootstrap, asset deploys) may fail on this host — ensure localhost.localstack.cloud resolves, or set AWS_ENDPOINT_URL/AWS_ENDPOINT_URL_S3 to a virtual-host-capable host.",
112+
})
113+
}
114+
115+
return cdkcli.Run(cmd.Context(), "http://"+host, region, sink, logger, cdkArgs)
116+
},
117+
}
118+
}

cmd/iac.go

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"regexp"
8+
"strings"
9+
10+
"github.com/localstack/lstk/internal/config"
11+
"github.com/localstack/lstk/internal/container"
12+
tfcli "github.com/localstack/lstk/internal/iac/terraform/cli"
13+
"github.com/localstack/lstk/internal/output"
14+
"github.com/localstack/lstk/internal/runtime"
15+
)
16+
17+
// Shared command-boundary helpers for the IaC proxy commands (terraform, cdk).
18+
// These live here rather than in any one command's file because both commands
19+
// depend on them equally; keeping them in cmd/ (not a domain package) is
20+
// deliberate — they touch config.Get(), the output.Sink, and the raw CLI args,
21+
// all of which are command-boundary concerns.
22+
23+
var accountIDRe = regexp.MustCompile(`^\d{12}$`)
24+
25+
// requireRunningAWSEmulator verifies the AWS emulator is running before an IaC
26+
// proxy command (terraform/cdk) that contacts AWS proceeds. When it is not
27+
// running it emits an actionable error through the sink — an AWS-specific
28+
// message naming the other emulator when a non-AWS one is up, otherwise the
29+
// generic "not running" error — and returns a silent error. cmdLabel is the
30+
// lstk command name used in the message (e.g. "terraform"/"cdk"). It returns nil
31+
// when the AWS emulator is running.
32+
func requireRunningAWSEmulator(ctx context.Context, rt runtime.Runtime, sink output.Sink, awsContainer config.ContainerConfig, cmdLabel string) error {
33+
runningName, err := container.ResolveRunningContainerName(ctx, rt, awsContainer)
34+
if err != nil {
35+
return fmt.Errorf("checking emulator status: %w", err)
36+
}
37+
if runningName != "" {
38+
return nil
39+
}
40+
// These commands only work with the AWS emulator. If a different emulator
41+
// is running, say so specifically rather than reporting a misleading
42+
// "AWS not running".
43+
if other := runningNonAWSEmulator(ctx, rt); other != "" {
44+
sink.Emit(output.ErrorEvent{
45+
Title: fmt.Sprintf("lstk %s requires the %s, but the %s is running", cmdLabel, awsContainer.DisplayName(), other),
46+
Actions: []output.ErrorAction{
47+
{Label: "Start the AWS emulator:", Value: "lstk"},
48+
},
49+
})
50+
return output.NewSilentError(fmt.Errorf("lstk %s requires the AWS emulator, but the %s is running", cmdLabel, other))
51+
}
52+
sink.Emit(output.ErrorEvent{
53+
Title: fmt.Sprintf("%s is not running", awsContainer.DisplayName()),
54+
Actions: []output.ErrorAction{
55+
{Label: "Start LocalStack:", Value: "lstk"},
56+
{Label: "See help:", Value: "lstk -h"},
57+
},
58+
})
59+
return output.NewSilentError(fmt.Errorf("%s is not running", awsContainer.Name()))
60+
}
61+
62+
// runningNonAWSEmulator returns the display name of a running non-AWS emulator
63+
// (e.g. Snowflake or Azure), or "" if none is running. The IaC proxy commands
64+
// support only the AWS emulator, so this lets them give a specific error when a
65+
// different emulator is running instead of a misleading "AWS not running".
66+
func runningNonAWSEmulator(ctx context.Context, rt runtime.Runtime) string {
67+
var others []config.ContainerConfig
68+
for _, t := range config.SelectableEmulatorTypes {
69+
if t == config.EmulatorAWS {
70+
continue
71+
}
72+
others = append(others, config.ContainerConfig{Type: t, Port: config.DefaultPort})
73+
}
74+
running, err := container.RunningEmulators(ctx, rt, others)
75+
if err != nil || len(running) == 0 {
76+
return ""
77+
}
78+
return running[0].DisplayName()
79+
}
80+
81+
// resolveAWSContainer returns the configured AWS emulator container, falling
82+
// back to defaults when no matching entry exists (mirrors cmd/aws.go).
83+
func resolveAWSContainer() config.ContainerConfig {
84+
awsContainer := config.ContainerConfig{Type: config.EmulatorAWS, Port: config.DefaultPort}
85+
appCfg, err := config.Get()
86+
if err != nil {
87+
return awsContainer
88+
}
89+
for _, c := range appCfg.Containers {
90+
if c.Type == config.EmulatorAWS {
91+
return c
92+
}
93+
}
94+
return awsContainer
95+
}
96+
97+
// emitValidationError renders a command-boundary validation failure through the
98+
// sink (consistent with the other IaC proxy error events) and returns a silent
99+
// error so the top-level handler does not print it a second time.
100+
func emitValidationError(sink output.Sink, err error) error {
101+
sink.Emit(output.ErrorEvent{Title: err.Error()})
102+
return output.NewSilentError(err)
103+
}
104+
105+
// rejectPreSubcommandFlags returns an error if --region or --account appears in
106+
// the raw command line before the subcommand token. Such flags are consumed by
107+
// Cobra during command resolution and would otherwise be silently dropped;
108+
// calledAs is the name the command was invoked as (e.g. "terraform"/"tf"/"cdk").
109+
func rejectPreSubcommandFlags(calledAs string) error {
110+
cmdIdx := -1
111+
for i, a := range os.Args {
112+
if a == calledAs {
113+
cmdIdx = i
114+
break
115+
}
116+
}
117+
if cmdIdx <= 0 {
118+
return nil
119+
}
120+
for _, a := range os.Args[1:cmdIdx] {
121+
if a == "--region" || a == "--account" ||
122+
strings.HasPrefix(a, "--region=") || strings.HasPrefix(a, "--account=") {
123+
return fmt.Errorf("--region and --account must appear after the %s subcommand (e.g. `lstk %s --region us-west-2 ...`)", calledAs, calledAs)
124+
}
125+
}
126+
return nil
127+
}
128+
129+
// stripLeadingIaCFlags extracts the lstk-specific --region/--account flags, and
130+
// (when recognizeChdir is set) reads terraform's global -chdir, but only in
131+
// leading position (between the subcommand alias and the action). It accepts
132+
// both --flag value and --flag=value forms for the lstk flags and -chdir=DIR for
133+
// chdir, stops at the first token that is none of these (forwarding the rest
134+
// verbatim), and errors if a leading lstk flag is missing its value.
135+
//
136+
// --region/--account are consumed and removed from the returned args; -chdir is
137+
// read for lstk's own working-directory resolution but kept in the returned
138+
// args, because terraform itself must also see it to switch directories. Only
139+
// the -chdir=DIR form is recognized (terraform does not accept a space-separated
140+
// -chdir DIR); any other spelling falls through and is forwarded verbatim. CDK
141+
// has no -chdir equivalent, so it calls this with recognizeChdir=false and
142+
// ignores the returned chdir.
143+
func stripLeadingIaCFlags(args []string, recognizeChdir bool) (remaining []string, region, account, chdir string, err error) {
144+
i := 0
145+
for i < len(args) {
146+
arg := args[i]
147+
switch {
148+
case arg == "--region":
149+
if i+1 >= len(args) {
150+
return nil, "", "", "", fmt.Errorf("--region requires a value")
151+
}
152+
region = args[i+1]
153+
i += 2
154+
case strings.HasPrefix(arg, "--region="):
155+
region = strings.TrimPrefix(arg, "--region=")
156+
i++
157+
case arg == "--account":
158+
if i+1 >= len(args) {
159+
return nil, "", "", "", fmt.Errorf("--account requires a value")
160+
}
161+
account = args[i+1]
162+
i += 2
163+
case strings.HasPrefix(arg, "--account="):
164+
account = strings.TrimPrefix(arg, "--account=")
165+
i++
166+
case recognizeChdir && strings.HasPrefix(arg, "-chdir="):
167+
// Read the value but keep -chdir in the forwarded args so terraform
168+
// also switches into it; continue scanning so leading --region/--account
169+
// positioned after -chdir are still consumed.
170+
chdir = strings.TrimPrefix(arg, "-chdir=")
171+
remaining = append(remaining, arg)
172+
i++
173+
default:
174+
return append(remaining, args[i:]...), region, account, chdir, nil
175+
}
176+
}
177+
return remaining, region, account, chdir, nil
178+
}
179+
180+
// resolveRegion applies the precedence --region flag → AWS_REGION → us-east-1.
181+
// The deprecated AWS_DEFAULT_REGION is intentionally not consulted.
182+
func resolveRegion(flag string) string {
183+
if flag != "" {
184+
return flag
185+
}
186+
if v := os.Getenv("AWS_REGION"); v != "" {
187+
return v
188+
}
189+
return "us-east-1"
190+
}
191+
192+
// resolveAccount applies the precedence --account flag → AWS_ACCESS_KEY_ID →
193+
// test. A flag value must be exactly 12 digits. An AWS_ACCESS_KEY_ID value is
194+
// run through DeactivateAccessKey so a real key (AKIA…/ASIA…) accidentally
195+
// present in the environment is never written into the override or sent to
196+
// LocalStack; the validated 12-digit flag is used as-is.
197+
func resolveAccount(flag string) (string, error) {
198+
if flag != "" {
199+
if !accountIDRe.MatchString(flag) {
200+
return "", fmt.Errorf("--account must be a 12-digit AWS account id, got %q", flag)
201+
}
202+
return flag, nil
203+
}
204+
if v := os.Getenv("AWS_ACCESS_KEY_ID"); v != "" {
205+
return tfcli.DeactivateAccessKey(v), nil
206+
}
207+
return "test", nil
208+
}

0 commit comments

Comments
 (0)