diff --git a/CLAUDE.md b/CLAUDE.md index cdde31cf..c09a5160 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,7 +106,6 @@ Use `lstk setup ` to set up CLI integration for an emulator type: - `lstk az start-interception` / `lstk az stop-interception` — Opt-in second mode: instead of the isolated dir, these mutate the user's **global** `~/.azure` so plain `az` (any terminal/script) targets LocalStack, then switch back. `start-interception` runs the same register → activate → `instance_discovery=false` → dummy-login flow against the global config (but does not touch global telemetry/survey prefs) and is independent of `lstk setup azure`. `stop-interception` switches the active cloud back to `AzureCloud` (override with `--cloud `, validated against the live `az cloud list`) and re-enables instance discovery — but only if `LocalStack` is still the active cloud, to avoid clobbering an unrelated selection. This naming avoids AWS-specific "profile" terminology and uses a clear verb for mutation operations. -The deprecated `lstk config profile` command still works but points users to `lstk setup aws`. The default `lstk az ` mode mirrors `lstk aws`: the Azure CLI has no `--endpoint-url`/`--profile`, so the only isolation knob is `AZURE_CONFIG_DIR`. Inside that isolated dir we register a custom cloud whose endpoints point at `https://azure.localhost.localstack.cloud:4566`, so `az` makes direct calls to LocalStack for Azure services (no HTTP(S) forward proxy in front of `az`). `core.instance_discovery=false` is required because `az` does not recognise the LocalStack host as a real Azure cloud. Adding a new Azure service that needs its own endpoint in `az`'s cloud config means extending the map in `internal/azureconfig/azureconfig.go::BuildCloudConfig`. diff --git a/cmd/config.go b/cmd/config.go index 9f899146..241764a0 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -4,43 +4,19 @@ import ( "fmt" "github.com/localstack/lstk/internal/config" - "github.com/localstack/lstk/internal/env" - "github.com/localstack/lstk/internal/ui" "github.com/spf13/cobra" ) -func newConfigCmd(cfg *env.Env) *cobra.Command { +func newConfigCmd() *cobra.Command { cmd := &cobra.Command{ Use: "config", Short: "Manage configuration", } requireSubcommand(cmd) - cmd.AddCommand(newConfigProfileCmd(cfg)) cmd.AddCommand(newConfigPathCmd()) return cmd } -func newConfigProfileCmd(cfg *env.Env) *cobra.Command { - return &cobra.Command{ - Use: "profile", - Short: "Deprecated: use 'lstk setup aws' instead", - PreRunE: initConfig(nil), - RunE: func(cmd *cobra.Command, args []string) error { - appConfig, err := config.Get() - if err != nil { - return fmt.Errorf("failed to get config: %w", err) - } - - if !isInteractiveMode(cfg) { - return fmt.Errorf("config profile requires an interactive terminal") - } - - // Delegate to the same handler as "lstk setup aws" - return ui.RunConfigProfile(cmd.Context(), appConfig.Containers, cfg.LocalStackHost, false) - }, - } -} - func newConfigPathCmd() *cobra.Command { return &cobra.Command{ Use: "path", diff --git a/cmd/root.go b/cmd/root.go index 0c156803..06946d69 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -117,7 +117,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C newStatusCmd(cfg), newLogsCmd(cfg), newSetupCmd(cfg), - newConfigCmd(cfg), + newConfigCmd(), newVolumeCmd(cfg), newUpdateCmd(cfg), newDocsCmd(), diff --git a/cmd/setup.go b/cmd/setup.go index 7765e547..c2064a45 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -44,7 +44,7 @@ func newSetupAWSCmd(cfg *env.Env) *cobra.Command { } if isInteractiveMode(cfg) { - return ui.RunConfigProfile(cmd.Context(), appConfig.Containers, cfg.LocalStackHost, force) + return ui.RunSetupAWS(cmd.Context(), appConfig.Containers, cfg.LocalStackHost, force) } resolvedHost, dnsOK, err := awsconfig.ResolveProfileHost(cmd.Context(), appConfig.Containers, cfg.LocalStackHost) diff --git a/internal/awsconfig/awsconfig.go b/internal/awsconfig/awsconfig.go index 6f24ee39..841aaa0d 100644 --- a/internal/awsconfig/awsconfig.go +++ b/internal/awsconfig/awsconfig.go @@ -288,8 +288,8 @@ func applyProfile(sink output.Sink, resolvedHost, configPath, credsPath string, // // When skipConfirm is true the confirmation prompt is bypassed and the profile is written directly. // -// explicit is true for the user-invoked `lstk setup aws` / `lstk config profile` -// commands, where writing the profile is the command's whole purpose, so a write +// explicit is true for the user-invoked `lstk setup aws` command, where writing +// the profile is the command's whole purpose, so a write // failure must surface a non-zero exit. It is false for the best-effort post-start // convenience flow (EnsureProfile during `lstk start`), where a write failure must // only warn and must not abort an already-running emulator. diff --git a/internal/ui/run_awsconfig.go b/internal/ui/run_awsconfig.go index fcda2c64..5d553f82 100644 --- a/internal/ui/run_awsconfig.go +++ b/internal/ui/run_awsconfig.go @@ -9,10 +9,10 @@ import ( "github.com/localstack/lstk/internal/output" ) -// RunConfigProfile runs the AWS profile setup flow with TUI output. +// RunSetupAWS runs the AWS profile setup flow with TUI output. // It resolves the host from the AWS container config and runs the setup. // When force is true, the confirmation prompt is skipped. -func RunConfigProfile(parentCtx context.Context, containers []config.ContainerConfig, localStackHost string, force bool) error { +func RunSetupAWS(parentCtx context.Context, containers []config.ContainerConfig, localStackHost string, force bool) error { resolvedHost, dnsOK, err := awsconfig.ResolveProfileHost(parentCtx, containers, localStackHost) if err != nil { return err diff --git a/test/integration/awsconfig_test.go b/test/integration/awsconfig_test.go index e154171f..a8513e8b 100644 --- a/test/integration/awsconfig_test.go +++ b/test/integration/awsconfig_test.go @@ -208,57 +208,6 @@ func TestStartEmitsNoteWhenAWSProfileIsPartial(t *testing.T) { "profile prompt should not appear for a partial setup") } -func TestConfigProfileCreatesAWSProfileWhenConfirmed(t *testing.T) { - t.Parallel() - if runtime.GOOS == "windows" { - t.Skip("PTY not supported on Windows") - } - baseEnv, tmpHome := awsConfigEnv(t) - - ctx := testContext(t) - cmd := exec.CommandContext(ctx, binaryPath(), "config", "profile") - cmd.Env = baseEnv - - ptmx, err := pty.Start(cmd) - require.NoError(t, err, "failed to start command in PTY") - defer func() { _ = ptmx.Close() }() - - out := &syncBuffer{} - outputCh := make(chan struct{}) - go func() { - _, _ = io.Copy(out, ptmx) - close(outputCh) - }() - - // Wait for the AWS profile prompt. - require.Eventually(t, func() bool { - return bytes.Contains(out.Bytes(), []byte(awsSetupPrompt)) - }, 2*time.Minute, 200*time.Millisecond, "AWS profile prompt should appear") - - // Press Y to confirm. - _, err = ptmx.Write([]byte("y")) - require.NoError(t, err) - - err = cmd.Wait() - <-outputCh - require.NoError(t, err) - - configContent, err := os.ReadFile(filepath.Join(tmpHome, ".aws", "config")) - require.NoError(t, err, "~/.aws/config should have been created") - assert.Contains(t, string(configContent), "[profile localstack]") - assert.Contains(t, string(configContent), "endpoint_url") - - credsContent, err := os.ReadFile(filepath.Join(tmpHome, ".aws", "credentials")) - require.NoError(t, err, "~/.aws/credentials should have been created") - normalizedCreds := strings.Join(strings.Fields(string(credsContent)), " ") - assert.Contains(t, normalizedCreds, "[localstack]") - assert.Contains(t, normalizedCreds, "aws_access_key_id = test") - assert.Contains(t, normalizedCreds, "aws_secret_access_key = test") - - assert.Contains(t, out.String(), "Created LocalStack profile in ~/.aws") - assert.NotContains(t, out.String(), "Skipped adding LocalStack AWS profile.") -} - func TestSetupAWSCreatesAWSProfileWhenConfirmed(t *testing.T) { t.Parallel() if runtime.GOOS == "windows" { @@ -363,7 +312,7 @@ func TestSetupAWSExitsNonZeroWhenProfileWriteFails(t *testing.T) { assert.NotContains(t, out.String(), "Created LocalStack profile") } -func TestConfigProfileDoesNotCreateAWSProfileWhenDeclined(t *testing.T) { +func TestSetupAWSDoesNotCreateAWSProfileWhenDeclined(t *testing.T) { t.Parallel() if runtime.GOOS == "windows" { t.Skip("PTY not supported on Windows") @@ -371,7 +320,7 @@ func TestConfigProfileDoesNotCreateAWSProfileWhenDeclined(t *testing.T) { baseEnv, tmpHome := awsConfigEnv(t) ctx := testContext(t) - cmd := exec.CommandContext(ctx, binaryPath(), "config", "profile") + cmd := exec.CommandContext(ctx, binaryPath(), "setup", "aws") cmd.Env = baseEnv ptmx, err := pty.Start(cmd) diff --git a/test/integration/az_interception_test.go b/test/integration/az_interception_test.go index 2f433bb3..ed04705c 100644 --- a/test/integration/az_interception_test.go +++ b/test/integration/az_interception_test.go @@ -58,15 +58,21 @@ func TestAzStopInterceptionNoOpWhenNotIntercepting(t *testing.T) { workDir := azureWorkDir(t) home := t.TempDir() // fresh ~/.azure: active cloud is the default AzureCloud + // Disable az telemetry: interception deliberately leaves the global config's telemetry + // prefs untouched, so without this the CLI spawns a detached background upload process + // that lingers past `az` and holds a handle in `home`, breaking t.TempDir() cleanup on + // Windows ("The process cannot access the file because it is being used by another process"). + azEnv := env.With(env.Home, home).With(env.AzureCollectTelemetry, "false") + stdout, _, err := runLstk(t, testContext(t), workDir, - env.With(env.Home, home), + azEnv, "az", "stop-interception", ) require.NoError(t, err, "stop-interception must not fail when LocalStack is not active") assert.Contains(t, stdout, "not the active Azure cloud") // It must not have switched the active cloud to anything. - active, azErr := runAzRaw(t, testContext(t), env.With(env.Home, home), "cloud", "show", "--query", "name", "-o", "tsv") + active, azErr := runAzRaw(t, testContext(t), azEnv, "cloud", "show", "--query", "name", "-o", "tsv") require.NoError(t, azErr) assert.Equal(t, "AzureCloud", active) } diff --git a/test/integration/env/env.go b/test/integration/env/env.go index 3aeb8b1d..978dcef2 100644 --- a/test/integration/env/env.go +++ b/test/integration/env/env.go @@ -23,6 +23,11 @@ const ( OtelEndpoint Key = "OTEL_EXPORTER_OTLP_ENDPOINT" AWSAccessKeyID Key = "AWS_ACCESS_KEY_ID" AWSSecretAccessKey Key = "AWS_SECRET_ACCESS_KEY" + // AzureCollectTelemetry is the env form of the Azure CLI's `core.collect_telemetry` + // config key. Tests that run `az` against a temp HOME set it to "false" so the CLI + // does not spawn its detached background telemetry-upload process, which on Windows + // outlives `az` and keeps a handle inside the temp dir — breaking t.TempDir() cleanup. + AzureCollectTelemetry Key = "AZURE_CORE_COLLECT_TELEMETRY" ) // UnreachableAnalyticsEndpoint is a closed local port used as the default diff --git a/test/integration/exit_code_test.go b/test/integration/exit_code_test.go index 04367eaa..afbd0ba4 100644 --- a/test/integration/exit_code_test.go +++ b/test/integration/exit_code_test.go @@ -26,6 +26,7 @@ func TestInvalidUsageExitsNonZero(t *testing.T) { {"unknown flag on root", []string{"--bogus-flag-xyz"}, "unknown flag"}, {"unknown command", []string{"bogus-command"}, "unknown command"}, {"unknown config subcommand", []string{"config", "bogus"}, `unknown command "bogus"`}, + {"removed config profile subcommand", []string{"config", "profile"}, `unknown command "profile"`}, {"unknown setup subcommand", []string{"setup", "bogus"}, `unknown command "bogus"`}, {"unknown volume subcommand", []string{"volume", "bogus"}, `unknown command "bogus"`}, {"unknown snapshot subcommand", []string{"snapshot", "bogus"}, `unknown command "bogus"`},