diff --git a/cmd/aws.go b/cmd/aws.go index 686ee86c..f0e59174 100644 --- a/cmd/aws.go +++ b/cmd/aws.go @@ -66,6 +66,12 @@ Examples: return output.NewSilentError(err) } + // --help/-h never contacts LocalStack, so it runs directly without + // requiring Docker or a running emulator (DEVX-1002). + if awscli.IsHelp(passthrough) { + return awscli.Exec(cmd.Context(), "", false, os.Stdout, os.Stderr, passthrough) + } + rt, err := runtime.NewDockerRuntime(cfg.DockerHost) if err != nil { return err diff --git a/internal/awscli/exec.go b/internal/awscli/exec.go index ea91d1ac..6288a941 100644 --- a/internal/awscli/exec.go +++ b/internal/awscli/exec.go @@ -27,6 +27,22 @@ func CheckInstalled() error { return nil } +// helpFlags are the flags/tokens the aws CLI recognizes as a help request. +// "help" is a bare pseudo-subcommand the aws CLI accepts at any command level +// (`aws help`, `aws s3 help`), equivalent to -h/--help. +var helpFlags = map[string]bool{"-h": true, "--help": true, "help": true} + +// IsHelp reports whether args requests the aws CLI's help output. The aws CLI +// answers this without needing a running emulator or resolved endpoint. +func IsHelp(args []string) bool { + for _, a := range args { + if helpFlags[a] { + return true + } + } + return false +} + func Exec(ctx context.Context, endpointURL string, useProfile bool, stdout, stderr io.Writer, args []string) error { ctx, span := otel.Tracer("github.com/localstack/lstk/internal/awscli").Start(ctx, "aws cli") defer span.End() @@ -43,7 +59,9 @@ func Exec(ctx context.Context, endpointURL string, useProfile bool, stdout, stde capacity += 2 } cmdArgs := make([]string, 0, capacity) - cmdArgs = append(cmdArgs, "--endpoint-url", endpointURL) + if endpointURL != "" { + cmdArgs = append(cmdArgs, "--endpoint-url", endpointURL) + } if useProfile { cmdArgs = append(cmdArgs, "--profile", awsconfig.ProfileName) } diff --git a/internal/awscli/exec_test.go b/internal/awscli/exec_test.go index 8a28bb97..e6133f39 100644 --- a/internal/awscli/exec_test.go +++ b/internal/awscli/exec_test.go @@ -6,6 +6,18 @@ import ( "github.com/stretchr/testify/assert" ) +func TestIsHelp(t *testing.T) { + for _, args := range [][]string{ + {"--help"}, {"-h"}, {"s3", "--help"}, {"s3", "ls", "-h"}, + {"help"}, {"s3", "help"}, + } { + assert.Truef(t, IsHelp(args), "%v", args) + } + for _, args := range [][]string{{"s3", "ls"}, {}} { + assert.Falsef(t, IsHelp(args), "%v", args) + } +} + func TestBuildEnvSetsDefaultsWhenAbsent(t *testing.T) { base := []string{"PATH=/usr/bin", "HOME=/home/user"} env := BuildEnv(base) diff --git a/internal/iac/cdk/cli/defaults.go b/internal/iac/cdk/cli/defaults.go index 92169c84..cbc04b04 100644 --- a/internal/iac/cdk/cli/defaults.go +++ b/internal/iac/cdk/cli/defaults.go @@ -58,9 +58,25 @@ var valueFlags = map[string]bool{ } // IsOffline reports whether the CDK invocation described by args is one of the -// offline subcommands that need no running emulator. +// offline subcommands that need no running emulator, or a help request. func IsOffline(args []string) bool { - return offlineCommands[subcommand(args)] + return IsHelp(args) || offlineCommands[subcommand(args)] +} + +// helpFlags are the flags/tokens cdk recognizes as a help request. "help" is a +// bare pseudo-subcommand cdk accepts at any command level (`cdk help`, +// `cdk deploy help`), equivalent to -h/--help. +var helpFlags = map[string]bool{"-h": true, "--help": true, "help": true} + +// IsHelp reports whether args requests cdk's help output. cdk answers this +// without needing a running emulator, same as the other offline commands. +func IsHelp(args []string) bool { + for _, a := range args { + if helpFlags[a] { + return true + } + } + return false } // subcommand returns the first non-flag token in args that is not consumed as a diff --git a/internal/iac/cdk/cli/exec_test.go b/internal/iac/cdk/cli/exec_test.go index f83b20cb..8b492131 100644 --- a/internal/iac/cdk/cli/exec_test.go +++ b/internal/iac/cdk/cli/exec_test.go @@ -89,6 +89,11 @@ func TestIsOffline(t *testing.T) { {"doctor"}, {"acknowledge", "12345"}, {"context"}, + {"--help"}, + {"-h"}, + {"deploy", "--help"}, + {"help"}, + {"deploy", "help"}, } for _, args := range offline { assert.Truef(t, IsOffline(args), "expected %v offline", args) diff --git a/internal/iac/sam/cli/defaults.go b/internal/iac/sam/cli/defaults.go index fdf1a5c5..2efffd8e 100644 --- a/internal/iac/sam/cli/defaults.go +++ b/internal/iac/sam/cli/defaults.go @@ -35,9 +35,23 @@ var offlineCommands = map[string]bool{ var valueFlags = map[string]bool{} // IsOffline reports whether the SAM invocation described by args is one of the -// offline subcommands that need no running emulator. +// offline subcommands that need no running emulator, or a help request. func IsOffline(args []string) bool { - return offlineCommands[subcommand(args)] + return IsHelp(args) || offlineCommands[subcommand(args)] +} + +// helpFlags are the flags sam recognizes as a help request. +var helpFlags = map[string]bool{"-h": true, "--help": true} + +// IsHelp reports whether args requests sam's help output. sam answers this +// without needing a running emulator, same as the other offline commands. +func IsHelp(args []string) bool { + for _, a := range args { + if helpFlags[a] { + return true + } + } + return false } // subcommand returns the first non-flag token in args that is not consumed as a diff --git a/internal/iac/sam/cli/defaults_test.go b/internal/iac/sam/cli/defaults_test.go index f5a61215..6a74bde4 100644 --- a/internal/iac/sam/cli/defaults_test.go +++ b/internal/iac/sam/cli/defaults_test.go @@ -15,6 +15,9 @@ func TestIsOffline(t *testing.T) { {"local", "generate-event", "s3", "put"}, {"local", "invoke"}, {"pipeline", "init"}, + {"--help"}, + {"-h"}, + {"deploy", "--help"}, } for _, args := range offline { assert.Truef(t, IsOffline(args), "expected %v offline", args) diff --git a/internal/iac/terraform/cli/defaults.go b/internal/iac/terraform/cli/defaults.go index 280ca8f6..dbe51728 100644 --- a/internal/iac/terraform/cli/defaults.go +++ b/internal/iac/terraform/cli/defaults.go @@ -39,11 +39,30 @@ var unproxiedCommands = map[string]bool{ } // IsUnproxied reports whether the terraform invocation described by args is one -// of the unconditionally unproxied subcommands (fmt/validate/version). The -// subcommand is the first argument that is not a flag (terraform's global -// options, e.g. -chdir, precede the subcommand). +// of the unconditionally unproxied subcommands (fmt/validate/version), or a +// help request. The subcommand is the first argument that is not a flag +// (terraform's global options, e.g. -chdir, precede the subcommand). func IsUnproxied(args []string) bool { - return unproxiedCommands[subcommand(args)] + return IsHelp(args) || unproxiedCommands[subcommand(args)] +} + +// helpFlags are the flags terraform recognizes as a help request, in any +// position (e.g. `terraform --help`, `terraform plan -h`). +var helpFlags = map[string]bool{ + "-h": true, + "-help": true, + "--help": true, +} + +// IsHelp reports whether args requests terraform's help output via a help flag +// anywhere in args (e.g. `terraform --help`, `terraform plan -h`). +func IsHelp(args []string) bool { + for _, a := range args { + if helpFlags[a] { + return true + } + } + return false } // RequiresEmulator reports whether the invocation needs a running LocalStack diff --git a/internal/iac/terraform/cli/routing_test.go b/internal/iac/terraform/cli/routing_test.go index 6284741b..a337e824 100644 --- a/internal/iac/terraform/cli/routing_test.go +++ b/internal/iac/terraform/cli/routing_test.go @@ -22,6 +22,26 @@ func TestIsUnproxiedSet(t *testing.T) { } } +// DEVX-1002 — help flags (in any position) are unproxied and never require +// the emulator, even in an uninitialized project. Unlike aws/cdk, terraform +// has no bare `help` pseudo-subcommand (`terraform help` is an error), so it +// is not recognized. +func TestIsHelp(t *testing.T) { + trueCases := [][]string{ + {"--help"}, {"-h"}, {"-help"}, + {"plan", "--help"}, {"plan", "-h"}, + } + for _, args := range trueCases { + assert.Truef(t, IsHelp(args), "%v", args) + assert.Truef(t, IsUnproxied(args), "%v", args) + } + + falseCases := [][]string{{"plan"}, {"apply", "-auto-approve"}, {"init"}, {"help"}, {"help", "plan"}} + for _, args := range falseCases { + assert.Falsef(t, IsHelp(args), "%v", args) + } +} + func TestRequiresEmulatorRouting(t *testing.T) { noBackend := t.TempDir() writeTF(t, noBackend, "main.tf", `provider "aws" {}`) @@ -41,6 +61,10 @@ func TestRequiresEmulatorRouting(t *testing.T) { // plan/apply always require the emulator. assert.True(t, RequiresEmulator([]string{"plan"}, noBackend, log.Nop())) assert.True(t, RequiresEmulator([]string{"apply", "-auto-approve"}, noBackend, log.Nop())) + + // help requests never require the emulator, even with an S3 backend present. + assert.False(t, RequiresEmulator([]string{"--help"}, withBackend, log.Nop())) + assert.False(t, RequiresEmulator([]string{"plan", "-h"}, withBackend, log.Nop())) } // 9.2 — adding a backend/remote-state section still refuses to clobber a diff --git a/test/integration/aws_cmd_test.go b/test/integration/aws_cmd_test.go index 981b2974..8865613e 100644 --- a/test/integration/aws_cmd_test.go +++ b/test/integration/aws_cmd_test.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" "github.com/moby/moby/client" @@ -251,6 +252,38 @@ func TestAWSCommandPropagatesExitCode(t *testing.T) { requireExitCode(t, 42, err) } +// DEVX-1002 — --help/-h, and the bare "help" pseudo-subcommand, never contact +// LocalStack, so they run without Docker/an emulator: DOCKER_HOST points at an +// unreachable address (mirroring TestAWSCommandFailsWhenDockerNotRunning) yet +// the command still succeeds and forwards the help request untouched, with no +// --endpoint-url injected. +func TestAWSCommandHelpSkipsDockerAndEmulator(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows Docker error tested separately via windowsDockerErrorEnv") + } + + dir := t.TempDir() + script := "#!/bin/sh\necho \"ARGS:$*\"\n" + require.NoError(t, os.WriteFile(filepath.Join(dir, "aws"), []byte(script), 0755)) + + for _, args := range [][]string{{"--help"}, {"-h"}, {"s3", "--help"}, {"help"}, {"s3", "help"}} { + args := args + t.Run(strings.Join(args, "_"), func(t *testing.T) { + e := env.With(env.DisableEvents, "1"). + With("PATH", dir). + With(env.Home, t.TempDir()). + With(env.Key("DOCKER_HOST"), "tcp://localhost:1") + + cmdArgs := append([]string{"aws"}, 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, " ")) + assert.NotContains(t, stdout, "--endpoint-url") + }) + } +} + func TestAWSCommandFailsWhenDockerNotRunning(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("Windows Docker error tested separately via windowsDockerErrorEnv") diff --git a/test/integration/cdk_cmd_test.go b/test/integration/cdk_cmd_test.go index b5a625fe..3c5050fa 100644 --- a/test/integration/cdk_cmd_test.go +++ b/test/integration/cdk_cmd_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" "github.com/localstack/lstk/test/integration/env" @@ -131,6 +132,26 @@ func TestCDKOfflineCommandsNoEmulator(t *testing.T) { } } +// DEVX-1002 — --help/-h, and the bare "help" pseudo-subcommand, never require +// the emulator, even for an AWS-contacting subcommand, and are forwarded to +// cdk untouched. +func TestCDKHelpNoEmulator(t *testing.T) { + t.Parallel() + for _, args := range [][]string{{"--help"}, {"-h"}, {"deploy", "--help"}, {"help"}, {"deploy", "help"}} { + args := args + t.Run(strings.Join(args, "_"), func(t *testing.T) { + t.Parallel() + fakeDir := writeFakeCDK(t, "2.177.0") + e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()) + + cmdArgs := append([]string{"cdk"}, 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, " ")) + }) + } +} + // 7.5 — a too-old cdk fails before the command runs. func TestCDKVersionTooOld(t *testing.T) { t.Parallel() diff --git a/test/integration/sam_cmd_test.go b/test/integration/sam_cmd_test.go index 7b0474bf..cd1e6d5b 100644 --- a/test/integration/sam_cmd_test.go +++ b/test/integration/sam_cmd_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" "github.com/localstack/lstk/test/integration/env" @@ -133,6 +134,25 @@ func TestSAMOfflineCommandsNoEmulator(t *testing.T) { } } +// DEVX-1002 — --help (and -h) never require the emulator, even for an +// AWS-contacting subcommand, and are forwarded to sam untouched. +func TestSAMHelpNoEmulator(t *testing.T) { + t.Parallel() + for _, args := range [][]string{{"--help"}, {"-h"}, {"deploy", "--help"}} { + args := args + t.Run(strings.Join(args, "_"), func(t *testing.T) { + t.Parallel() + fakeDir := writeFakeSAM(t, "1.95.0") + e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()) + + cmdArgs := append([]string{"sam"}, 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 sam fails before the command runs. func TestSAMVersionTooOld(t *testing.T) { t.Parallel() diff --git a/test/integration/terraform_cmd_test.go b/test/integration/terraform_cmd_test.go index 916e44ab..9f1ea076 100644 --- a/test/integration/terraform_cmd_test.go +++ b/test/integration/terraform_cmd_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" "github.com/localstack/lstk/test/integration/env" @@ -167,6 +168,29 @@ func TestTerraformUnproxiedSkipsOverride(t *testing.T) { } } +// DEVX-1002 — --help (and -h) never require the emulator/provider schema, even +// in an uninitialized project with no running emulator, and are forwarded to +// terraform untouched. +func TestTerraformHelpSkipsOverride(t *testing.T) { + t.Parallel() + for _, args := range [][]string{{"--help"}, {"-h"}, {"-help"}, {"plan", "--help"}} { + args := args + t.Run(strings.Join(args, "_"), func(t *testing.T) { + t.Parallel() + fakeDir := writeFakeTerraform(t) + workDir := t.TempDir() + e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, t.TempDir()) + + cmdArgs := append([]string{"terraform"}, args...) + stdout, stderr, err := runLstk(t, testContext(t), workDir, e, cmdArgs...) + require.NoError(t, err, "stderr: %s", stderr) + + assert.Contains(t, stdout, "ARGS:"+strings.Join(args, " ")) + assert.NoFileExists(t, filepath.Join(workDir, tfOverrideFile)) + }) + } +} + // 7.6 (validation) — invalid --account fails before terraform is invoked. No // emulator needed because validation happens at the command boundary. func TestTerraformInvalidAccountRejected(t *testing.T) {