From ad41209b5d2483597dd0ec76ccbc737d41be2f6a Mon Sep 17 00:00:00 2001 From: carole-lavillonniere Date: Tue, 19 May 2026 11:52:40 +0200 Subject: [PATCH 1/4] az cli proxy --- CLAUDE.md | 4 + README.md | 19 ++- cmd/aws.go | 2 +- cmd/az.go | 122 +++++++++++++++ cmd/root.go | 1 + cmd/setup.go | 29 +++- internal/azurecli/exec.go | 81 ++++++++++ internal/azureconfig/azureconfig.go | 191 +++++++++++++++++++++++ internal/azureconfig/azureconfig_test.go | 123 +++++++++++++++ internal/config/config.go | 2 +- internal/config/containers.go | 4 +- internal/ui/run_azureconfig.go | 46 ++++++ test/integration/setup_azure_test.go | 164 +++++++++++++++++++ 13 files changed, 780 insertions(+), 8 deletions(-) create mode 100644 cmd/az.go create mode 100644 internal/azurecli/exec.go create mode 100644 internal/azureconfig/azureconfig.go create mode 100644 internal/azureconfig/azureconfig_test.go create mode 100644 internal/ui/run_azureconfig.go create mode 100644 test/integration/setup_azure_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 84f65723..67a3a45c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,10 +69,14 @@ Created automatically on first run with defaults. Supports emulator types: `aws` Use `lstk setup ` to set up CLI integration for an emulator type: - `lstk setup aws` — Sets up AWS CLI profile in `~/.aws/config` and `~/.aws/credentials` +- `lstk setup azure` — Prepares an isolated Azure CLI config dir (under the lstk config dir, via `AZURE_CONFIG_DIR`): registers a custom Azure cloud (`LocalStack`) whose endpoints point at the LocalStack Azure emulator, activates it, disables Azure CLI instance discovery and telemetry, and performs a one-time dummy service-principal login. The user's global `~/.azure` is left untouched. Requires the `az` CLI and a running Azure emulator. +- `lstk az ` — Runs `az ` against that isolated config dir, so the Azure CLI talks to LocalStack for Azure service URLs and to the real internet for everything else (extension downloads, etc.). 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`. +Azure CLI integration deliberately mirrors `lstk aws`, not azlocal's `start-interception` (which globally mutates `~/.azure`). 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`. + Environment variables: - `LOCALSTACK_AUTH_TOKEN` - Auth token (skips browser login if set) - `LSTK_OTEL=1` - Enables OpenTelemetry trace export (disabled by default); when enabled, standard `OTEL_EXPORTER_OTLP_*` env vars are respected by the SDK. Requires an OTLP-compatible backend to receive and visualize telemetry — for local development, `make otel` starts one (UI at http://localhost:16686). diff --git a/README.md b/README.md index 5a0d3ce8..4d469c62 100644 --- a/README.md +++ b/README.md @@ -83,13 +83,20 @@ To see which config file is currently in use: lstk config path ``` -You can also configure AWS CLI integration: +You can also configure cloud CLI integration: ```bash -lstk setup aws +lstk setup aws # localstack profile in ~/.aws/ +lstk setup azure # isolated Azure CLI config for `lstk az` (requires the Azure CLI) ``` -This sets up a `localstack` profile in `~/.aws/config` and `~/.aws/credentials`. +After `lstk setup azure`, run Azure CLI commands against LocalStack with `lstk az`: + +```bash +lstk az group list +``` + +`lstk setup azure` registers a custom Azure cloud — pointing at LocalStack's endpoints — inside an isolated `AZURE_CONFIG_DIR`, so your global `~/.azure` keeps pointing at real Azure. You can also point `lstk` at a specific config file for any command: @@ -196,6 +203,12 @@ lstk config path # Set up AWS CLI profile integration lstk setup aws +# Set up Azure CLI integration (isolated config for `lstk az`) +lstk setup azure + +# Run Azure CLI commands against LocalStack +lstk az group list + ``` ## Reporting bugs diff --git a/cmd/aws.go b/cmd/aws.go index 4dd7d247..ef952e12 100644 --- a/cmd/aws.go +++ b/cmd/aws.go @@ -47,7 +47,7 @@ Examples: return fmt.Errorf("failed to get config: %w", err) } - awsContainer := config.ContainerConfig{Type: config.EmulatorAWS, Port: config.DefaultAWSPort} + awsContainer := config.ContainerConfig{Type: config.EmulatorAWS, Port: config.DefaultPort} for _, c := range appCfg.Containers { if c.Type == config.EmulatorAWS { awsContainer = c diff --git a/cmd/az.go b/cmd/az.go new file mode 100644 index 00000000..c9bddea0 --- /dev/null +++ b/cmd/az.go @@ -0,0 +1,122 @@ +package cmd + +import ( + "fmt" + "io" + "os" + "time" + + "github.com/localstack/lstk/internal/azurecli" + "github.com/localstack/lstk/internal/azureconfig" + "github.com/localstack/lstk/internal/config" + "github.com/localstack/lstk/internal/container" + "github.com/localstack/lstk/internal/endpoint" + "github.com/localstack/lstk/internal/env" + "github.com/localstack/lstk/internal/output" + "github.com/localstack/lstk/internal/runtime" + "github.com/localstack/lstk/internal/terminal" + "github.com/spf13/cobra" +) + +func newAzCmd(cfg *env.Env) *cobra.Command { + return &cobra.Command{ + Use: "az [args...]", + Short: "Run Azure CLI commands against LocalStack", + Long: `Run Azure CLI commands against the LocalStack Azure emulator. + +Runs 'az ' with an isolated AZURE_CONFIG_DIR in which a custom Azure cloud +is registered against LocalStack's endpoints, so your global ~/.azure +configuration is left untouched and plain 'az' commands keep talking to real +Azure. + +Run 'lstk setup azure' once before using this command. + +Examples: + lstk az group list + lstk az storage account list`, + DisableFlagParsing: true, + PreRunE: initConfig(nil), + RunE: func(cmd *cobra.Command, args []string) error { + rt, err := runtime.NewDockerRuntime(cfg.DockerHost) + if err != nil { + return err + } + + appCfg, err := config.Get() + if err != nil { + return fmt.Errorf("failed to get config: %w", err) + } + + azureContainer := config.ContainerConfig{Type: config.EmulatorAzure, Port: config.DefaultPort} + for _, c := range appCfg.Containers { + if c.Type == config.EmulatorAzure { + azureContainer = c + break + } + } + + sink := output.NewPlainSink(os.Stdout) + + configDir, err := config.ConfigDir() + if err != nil { + return fmt.Errorf("failed to resolve config directory: %w", err) + } + azureConfigDir := azureconfig.ConfigDir(configDir) + if !azureconfig.IsSetUp(azureConfigDir) { + sink.Emit(output.ErrorEvent{ + Title: "Azure CLI integration is not set up", + Actions: []output.ErrorAction{ + {Label: "Set it up:", Value: "lstk setup azure"}, + }, + }) + return output.NewSilentError(fmt.Errorf("azure CLI integration not set up")) + } + + if err := rt.IsHealthy(cmd.Context()); err != nil { + rt.EmitUnhealthyError(sink, err) + return output.NewSilentError(fmt.Errorf("runtime not healthy: %w", err)) + } + + runningName, err := container.ResolveRunningContainerName(cmd.Context(), rt, azureContainer) + if err != nil { + return fmt.Errorf("checking emulator status: %w", err) + } + if runningName == "" { + sink.Emit(output.ErrorEvent{ + Title: fmt.Sprintf("%s is not running", azureContainer.DisplayName()), + Actions: []output.ErrorAction{ + {Label: "Start LocalStack:", Value: "lstk"}, + {Label: "See help:", Value: "lstk -h"}, + }, + }) + return output.NewSilentError(fmt.Errorf("%s is not running", azureContainer.Name())) + } + + _, dnsOK := endpoint.ResolveHost(cmd.Context(), azureContainer.Port, cfg.LocalStackHost) + if !dnsOK { + sink.Emit(output.ErrorEvent{ + Title: "DNS resolution required for 'lstk az'", + Actions: []output.ErrorAction{ + {Label: "Note:", Value: endpoint.DNSRebindNote}, + {Label: "Why:", Value: "LocalStack routes Azure requests by Host header"}, + {Label: "Fix:", Value: "configure DNS or set LOCALSTACK_HOST"}, + }, + }) + return output.NewSilentError(fmt.Errorf("dns resolution required for 'lstk az'")) + } + + azEnv := azureconfig.Env(azureConfigDir) + + stdout, stderr := io.Writer(os.Stdout), io.Writer(os.Stderr) + if terminal.IsTerminal(os.Stderr) { + s := terminal.NewSpinner(os.Stderr, "Loading service...", 4*time.Second) + s.Start() + defer s.Stop() + stdout = &terminal.StopOnWriteWriter{W: os.Stdout, Spinner: s} + stderr = &terminal.StopOnWriteWriter{W: os.Stderr, Spinner: s} + } + + return azurecli.Exec(cmd.Context(), azEnv, os.Stdin, stdout, stderr, args...) + }, + } +} diff --git a/cmd/root.go b/cmd/root.go index 9330d357..4d89313f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -84,6 +84,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C newDocsCmd(), newAWSCmd(cfg), newSnapshotCmd(cfg, tel, logger), + newAzCmd(cfg), newResetCmd(cfg), newSaveCmd(cfg), newLoadCmd(cfg, tel, logger), diff --git a/cmd/setup.go b/cmd/setup.go index f0663f05..0ede86e4 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -13,9 +13,10 @@ func newSetupCmd(cfg *env.Env) *cobra.Command { cmd := &cobra.Command{ Use: "setup", Short: "Set up emulator CLI integration", - Long: "Set up emulator CLI integration. Currently only AWS is supported.", + Long: "Set up emulator CLI integration for AWS or Azure.", } cmd.AddCommand(newSetupAWSCmd(cfg)) + cmd.AddCommand(newSetupAzureCmd(cfg)) return cmd } @@ -39,3 +40,29 @@ func newSetupAWSCmd(cfg *env.Env) *cobra.Command { }, } } + +func newSetupAzureCmd(cfg *env.Env) *cobra.Command { + return &cobra.Command{ + Use: "azure", + Short: "Set up Azure CLI integration with LocalStack", + Long: "Prepare an isolated Azure CLI config directory that routes 'lstk az' commands to the LocalStack Azure emulator. Your global ~/.azure configuration is left untouched. Requires the `az` CLI and a running LocalStack Azure emulator.", + 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("setup azure requires an interactive terminal") + } + + configDir, err := config.ConfigDir() + if err != nil { + return fmt.Errorf("failed to resolve config directory: %w", err) + } + + return ui.RunSetupAzure(cmd.Context(), appConfig.Containers, cfg.LocalStackHost, configDir) + }, + } +} diff --git a/internal/azurecli/exec.go b/internal/azurecli/exec.go new file mode 100644 index 00000000..160e15c8 --- /dev/null +++ b/internal/azurecli/exec.go @@ -0,0 +1,81 @@ +package azurecli + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" +) + +// ErrNotInstalled is returned when the `az` binary cannot be found on PATH. +var ErrNotInstalled = errors.New("az CLI not found in PATH — install it from https://learn.microsoft.com/cli/azure/install-azure-cli") + +// CheckInstalled returns ErrNotInstalled if the `az` binary is not on PATH. +// Callers should use this before performing setup work to avoid leaving partial state. +func CheckInstalled() error { + if _, err := exec.LookPath("az"); err != nil { + return ErrNotInstalled + } + return nil +} + +// Exec runs `az `. extraEnv is appended to the inherited process environment +// (later entries win), letting callers inject AZURE_CONFIG_DIR, proxy, and CA settings +// without mutating the user's global Azure CLI configuration. +func Exec(ctx context.Context, extraEnv []string, stdin io.Reader, stdout, stderr io.Writer, args ...string) error { + ctx, span := otel.Tracer("github.com/localstack/lstk/internal/azurecli").Start(ctx, "az cli") + defer span.End() + + azBin, err := exec.LookPath("az") + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return ErrNotInstalled + } + + span.SetAttributes(attribute.StringSlice("az.args", args)) + + cmd := exec.CommandContext(ctx, azBin, args...) + cmd.Stdin = stdin + cmd.Stdout = stdout + cmd.Stderr = stderr + if len(extraEnv) > 0 { + cmd.Env = append(os.Environ(), extraEnv...) + } + if err := cmd.Run(); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + span.SetAttributes(attribute.Int("az.exit_code", exitErr.ExitCode())) + span.SetStatus(codes.Error, "az cli exited non-zero") + } else { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } + return err + } + return nil +} + +// Run executes `az ` with extraEnv and returns the captured stdout, stderr, +// and any error. On non-zero exit, the error wraps stderr to aid debugging. +func Run(ctx context.Context, extraEnv []string, args ...string) (stdout, stderr string, err error) { + var outBuf, errBuf bytes.Buffer + runErr := Exec(ctx, extraEnv, nil, &outBuf, &errBuf, args...) + stdout = outBuf.String() + stderr = errBuf.String() + if runErr != nil { + var exitErr *exec.ExitError + if errors.As(runErr, &exitErr) && stderr != "" { + return stdout, stderr, fmt.Errorf("az %v: %w: %s", args, runErr, stderr) + } + return stdout, stderr, runErr + } + return stdout, stderr, nil +} diff --git a/internal/azureconfig/azureconfig.go b/internal/azureconfig/azureconfig.go new file mode 100644 index 00000000..a412902f --- /dev/null +++ b/internal/azureconfig/azureconfig.go @@ -0,0 +1,191 @@ +package azureconfig + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + + "github.com/localstack/lstk/internal/azurecli" + "github.com/localstack/lstk/internal/output" +) + +const ( + AzureSubdomain = "azure" + CloudName = "LocalStack" + + // setupMarkerFile is written at the end of a successful Setup so partial failures + // (e.g. login crash after cloud registration) don't make IsSetUp report true. + setupMarkerFile = ".lstk-setup-complete" + + // Dummy service principal credentials. The LocalStack Azure emulator does + // not validate these — any values that look like a service principal login + // are accepted. + servicePrincipalUser = "any-app" + servicePrincipalPass = "any-pass" + servicePrincipalTenant = "anytenant" +) + +func ConfigDir(lstkConfigDir string) string { + return filepath.Join(lstkConfigDir, "azure") +} + +func BuildEndpoint(host string) string { + return "https://" + AzureSubdomain + "." + host +} + +func IsHealthy(ctx context.Context, endpointURL string) error { + ctx, span := otel.Tracer("github.com/localstack/lstk/internal/azureconfig").Start(ctx, "azureconfig.IsHealthy") + defer span.End() + + url := strings.TrimRight(endpointURL, "/") + "/_localstack/health" + span.SetAttributes(attribute.String("azure.health_url", url)) + + resp, err := httpGet(ctx, url) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status %d", resp.StatusCode) + } + return nil +} + +func Env(azureConfigDir string) []string { + return []string{"AZURE_CONFIG_DIR=" + azureConfigDir} +} + +func IsSetUp(azureConfigDir string) bool { + _, err := os.Stat(filepath.Join(azureConfigDir, setupMarkerFile)) + return err == nil +} + +func httpGet(ctx context.Context, url string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + client := &http.Client{Timeout: 10 * time.Second} + return client.Do(req) +} + +// BuildCloudConfig returns the JSON payload for `az cloud register/update --cloud-config`. +func BuildCloudConfig(endpointURL string) (string, error) { + base := strings.TrimRight(endpointURL, "/") + payload := map[string]any{ + "endpoints": map[string]string{ + "activeDirectory": base, + "activeDirectoryResourceId": base, + "activeDirectoryGraphResourceId": base, + "management": base + "/", + "microsoftGraphResourceId": base + "/", + "resourceManager": base + "/", + "logAnalyticsResourceId": base, + }, + } + b, err := json.Marshal(payload) + if err != nil { + return "", err + } + return string(b), nil +} + +func cloudExists(ctx context.Context, azEnv []string) (bool, error) { + stdout, _, err := azurecli.Run(ctx, azEnv, + "cloud", "list", "--query", fmt.Sprintf("[?name=='%s'].name", CloudName), "-o", "tsv") + if err != nil { + return false, err + } + return strings.TrimSpace(stdout) == CloudName, nil +} + +// Setup registers the LocalStack custom cloud in an isolated AZURE_CONFIG_DIR, +// activates it, disables instance discovery, and logs in with a dummy SP. +func Setup(ctx context.Context, sink output.Sink, endpointURL, azureConfigDir string) error { + ctx, span := otel.Tracer("github.com/localstack/lstk/internal/azureconfig").Start(ctx, "azureconfig.Setup") + defer span.End() + + // Bail early if `az` is missing so we don't leave a half-configured dir behind. + if err := azurecli.CheckInstalled(); err != nil { + sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: err.Error()}) + return err + } + + if err := IsHealthy(ctx, endpointURL); err != nil { + sink.Emit(output.MessageEvent{ + Severity: output.SeverityWarning, + Text: fmt.Sprintf("LocalStack Azure emulator not reachable at %s. Run 'lstk' to start it before running 'lstk setup azure'.", endpointURL), + }) + return fmt.Errorf("emulator not reachable at %s: %w", endpointURL, err) + } + + if err := os.MkdirAll(azureConfigDir, 0700); err != nil { + sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("could not create %s: %v", azureConfigDir, err)}) + return err + } + azEnv := Env(azureConfigDir) + + cloudConfigJSON, err := BuildCloudConfig(endpointURL) + if err != nil { + return fmt.Errorf("building cloud config: %w", err) + } + + exists, err := cloudExists(ctx, azEnv) + if err != nil { + sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("could not list Azure clouds: %v", err)}) + return err + } + action, verb := "register", "Registering" + if exists { + action, verb = "update", "Updating" + } + sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: fmt.Sprintf("%s '%s' custom cloud...", verb, CloudName)}) + if _, _, err := azurecli.Run(ctx, azEnv, + "cloud", action, "--name", CloudName, "--cloud-config", cloudConfigJSON, "--only-show-errors"); err != nil { + sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("could not %s '%s' cloud: %v", action, CloudName, err)}) + return err + } + + if _, _, err := azurecli.Run(ctx, azEnv, "cloud", "set", "--name", CloudName, "--only-show-errors"); err != nil { + sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("could not activate '%s' cloud: %v", CloudName, err)}) + return err + } + + // instance_discovery=false: `az` would otherwise try to validate the authority + // against the public AAD discovery endpoint, which the emulator can't serve. + if _, _, err := azurecli.Run(ctx, azEnv, "config", "set", + "core.instance_discovery=false", "core.collect_telemetry=false", "output.show_survey_link=no", + "--only-show-errors"); err != nil { + sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("could not configure Azure CLI: %v", err)}) + return err + } + + sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: "Logging in with dummy service-principal credentials..."}) + if _, _, err := azurecli.Run(ctx, azEnv, "login", "--service-principal", + "-u", servicePrincipalUser, + "-p", servicePrincipalPass, + "--tenant", servicePrincipalTenant, + "--only-show-errors", + ); err != nil { + sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("could not log in to the LocalStack Azure emulator: %v", err)}) + return err + } + + if err := os.WriteFile(filepath.Join(azureConfigDir, setupMarkerFile), []byte("ok\n"), 0600); err != nil { + return fmt.Errorf("writing setup marker: %w", err) + } + + sink.Emit(output.MessageEvent{ + Severity: output.SeveritySuccess, + Text: "Azure CLI integration ready. Run 'lstk az ' to talk to LocalStack.", + }) + return nil +} diff --git a/internal/azureconfig/azureconfig_test.go b/internal/azureconfig/azureconfig_test.go new file mode 100644 index 00000000..a6c88e94 --- /dev/null +++ b/internal/azureconfig/azureconfig_test.go @@ -0,0 +1,123 @@ +package azureconfig + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildEndpoint(t *testing.T) { + t.Parallel() + tests := []struct { + host string + want string + }{ + {"localhost.localstack.cloud:4566", "https://azure.localhost.localstack.cloud:4566"}, + {"127.0.0.1:4566", "https://azure.127.0.0.1:4566"}, + {"example.com:8080", "https://azure.example.com:8080"}, + } + for _, tc := range tests { + t.Run(tc.host, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, BuildEndpoint(tc.host)) + }) + } +} + +func TestConfigDir(t *testing.T) { + t.Parallel() + assert.Equal(t, filepath.Join("/home/u/.config/lstk", "azure"), ConfigDir("/home/u/.config/lstk")) +} + +func TestEnv(t *testing.T) { + t.Parallel() + assert.Equal(t, []string{"AZURE_CONFIG_DIR=/cfg/azure"}, Env("/cfg/azure")) +} + +func TestIsHealthyOK(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/_localstack/health", r.URL.Path) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + require.NoError(t, IsHealthy(context.Background(), srv.URL)) +} + +func TestIsHealthyNon200(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer srv.Close() + + err := IsHealthy(context.Background(), srv.URL) + require.Error(t, err) + assert.Contains(t, err.Error(), "503") +} + +func TestIsHealthyUnreachable(t *testing.T) { + t.Parallel() + err := IsHealthy(context.Background(), "http://127.0.0.1:1") + require.Error(t, err) +} + +func TestBuildCloudConfig(t *testing.T) { + t.Parallel() + const endpoint = "https://azure.localhost.localstack.cloud:4566" + raw, err := BuildCloudConfig(endpoint) + require.NoError(t, err) + + var parsed struct { + Endpoints map[string]string `json:"endpoints"` + } + require.NoError(t, json.Unmarshal([]byte(raw), &parsed)) + + // Every Azure endpoint must point at LocalStack — otherwise `az` would talk to real + // Azure for that service and could hit the user's real account. + require.NotEmpty(t, parsed.Endpoints) + for key, value := range parsed.Endpoints { + assert.Truef(t, strings.HasPrefix(value, endpoint), + "endpoint %q must start with %q, got %q", key, endpoint, value) + } + + for _, key := range []string{ + "activeDirectory", + "activeDirectoryResourceId", + "activeDirectoryGraphResourceId", + "management", + "microsoftGraphResourceId", + "resourceManager", + "logAnalyticsResourceId", + } { + _, ok := parsed.Endpoints[key] + assert.Truef(t, ok, "cloud-config endpoints map missing key %q", key) + } +} + +func TestBuildCloudConfigTrimsTrailingSlash(t *testing.T) { + t.Parallel() + withSlash, err := BuildCloudConfig("https://azure.localhost.localstack.cloud:4566/") + require.NoError(t, err) + withoutSlash, err := BuildCloudConfig("https://azure.localhost.localstack.cloud:4566") + require.NoError(t, err) + assert.Equal(t, withoutSlash, withSlash, "trailing slash on input must not change the rendered cloud-config") +} + +func TestIsSetUp(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.False(t, IsSetUp(dir), "fresh dir without marker should not look set up") + + require.NoError(t, os.WriteFile(filepath.Join(dir, setupMarkerFile), []byte("ok\n"), 0600)) + require.True(t, IsSetUp(dir), "marker file presence is the setup signal") +} diff --git a/internal/config/config.go b/internal/config/config.go index 14fdbff3..26c05aaa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -31,7 +31,7 @@ func setDefaults() { { "type": "aws", "tag": "latest", - "port": DefaultAWSPort, + "port": DefaultPort, }, }) } diff --git a/internal/config/containers.go b/internal/config/containers.go index a7fb1cd4..89a05069 100644 --- a/internal/config/containers.go +++ b/internal/config/containers.go @@ -18,7 +18,7 @@ const ( EmulatorSnowflake EmulatorType = "snowflake" EmulatorAzure EmulatorType = "azure" - DefaultAWSPort = "4566" + DefaultPort = "4566" dockerRegistry = "localstack" ) @@ -233,7 +233,7 @@ func (c *ContainerConfig) HealthPath() (string, error) { func (c *ContainerConfig) ContainerPort() (string, error) { switch c.Type { case EmulatorAWS, EmulatorSnowflake, EmulatorAzure: - return DefaultAWSPort + "/tcp", nil + return DefaultPort + "/tcp", nil default: return "", fmt.Errorf("%s emulator not supported yet by lstk", c.Type) } diff --git a/internal/ui/run_azureconfig.go b/internal/ui/run_azureconfig.go new file mode 100644 index 00000000..89c23ca5 --- /dev/null +++ b/internal/ui/run_azureconfig.go @@ -0,0 +1,46 @@ +package ui + +import ( + "context" + "fmt" + + "github.com/localstack/lstk/internal/azureconfig" + "github.com/localstack/lstk/internal/config" + "github.com/localstack/lstk/internal/endpoint" + "github.com/localstack/lstk/internal/output" +) + +// RunSetupAzure prepares the isolated Azure CLI config dir with TUI output. +// It derives the LocalStack Azure endpoint from the Azure emulator config and +// runs the setup, which registers a custom Azure cloud pointing at LocalStack +// without touching the user's global ~/.azure configuration. +func RunSetupAzure(parentCtx context.Context, containers []config.ContainerConfig, localStackHost, lstkConfigDir string) error { + var azureContainer *config.ContainerConfig + for i := range containers { + if containers[i].Type == config.EmulatorAzure { + azureContainer = &containers[i] + break + } + } + if azureContainer == nil { + return fmt.Errorf("no azure emulator configured — run 'lstk start' and select the Azure emulator first") + } + + resolvedHost, dnsOK := endpoint.ResolveHost(parentCtx, azureContainer.Port, localStackHost) + endpointURL := azureconfig.BuildEndpoint(resolvedHost) + azureConfigDir := azureconfig.ConfigDir(lstkConfigDir) + + return runWithTUI(parentCtx, withoutHeader(), func(ctx context.Context, sink output.Sink) error { + if !dnsOK { + sink.Emit(output.MessageEvent{ + Severity: output.SeverityWarning, + Text: fmt.Sprintf( + "%s Azure setup requires DNS resolution because LocalStack routes Azure requests by Host header. Configure DNS or set LOCALSTACK_HOST.", + endpoint.DNSRebindNote, + ), + }) + return fmt.Errorf("dns resolution required for azure setup") + } + return azureconfig.Setup(ctx, sink, endpointURL, azureConfigDir) + }) +} diff --git a/test/integration/setup_azure_test.go b/test/integration/setup_azure_test.go new file mode 100644 index 00000000..66adc251 --- /dev/null +++ b/test/integration/setup_azure_test.go @@ -0,0 +1,164 @@ +package integration_test + +import ( + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + + "github.com/creack/pty" + "github.com/localstack/lstk/test/integration/env" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func requireAzCLI(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("az"); err != nil { + t.Skip("az CLI not available") + } +} + +// azureWorkDir prepares a fresh workDir with a project-local `.lstk/config.toml` +// containing an Azure container, and returns its path. Tests run `lstk` with +// `cmd.Dir = workDir` so the project-local config search finds this file — +// `lstk az` has `DisableFlagParsing: true`, so a `--config` flag wouldn't reach +// the parent flag set. +func azureWorkDir(t *testing.T) string { + t.Helper() + workDir := t.TempDir() + lstkDir := filepath.Join(workDir, ".lstk") + require.NoError(t, os.MkdirAll(lstkDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(lstkDir, "config.toml"), []byte(` +[[containers]] +type = "azure" +tag = "latest" +port = "4566" +`), 0644)) + return workDir +} + +func writeAzureSetupMarker(t *testing.T, workDir string) { + t.Helper() + dir := filepath.Join(workDir, ".lstk", "azure") + require.NoError(t, os.MkdirAll(dir, 0700)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".lstk-setup-complete"), []byte("ok\n"), 0600)) +} + +func TestAzCommandErrorsWhenNotSetUp(t *testing.T) { + t.Parallel() + workDir := azureWorkDir(t) + + stdout, _, err := runLstk(t, testContext(t), workDir, + env.With(env.Home, t.TempDir()), + "az", "group", "list", + ) + require.Error(t, err) + requireExitCode(t, 1, err) + assert.Contains(t, stdout, "Azure CLI integration is not set up") + assert.Contains(t, stdout, "lstk setup azure") +} + +func TestSetupAzureNonInteractiveReturnsError(t *testing.T) { + t.Parallel() + + _, stderr, err := runLstk(t, testContext(t), "", + env.With(env.Home, t.TempDir()), + "setup", "azure", + ) + require.Error(t, err) + assert.Contains(t, stderr, "setup azure requires an interactive terminal") +} + +func TestAzCommandErrorsWhenEmulatorNotRunning(t *testing.T) { + requireDocker(t) + cleanup() + cleanupAzure() + t.Cleanup(cleanup) + t.Cleanup(cleanupAzure) + + workDir := azureWorkDir(t) + writeAzureSetupMarker(t, workDir) + + stdout, _, err := runLstk(t, testContext(t), workDir, + env.With(env.Home, t.TempDir()), + "az", "group", "list", + ) + require.Error(t, err) + requireExitCode(t, 1, err) + assert.Contains(t, stdout, "is not running") + assert.Contains(t, stdout, "Start LocalStack") +} + +// TestSetupAzureAndAzCommandSucceed requires Docker, the Azure CLI, and LOCALSTACK_AUTH_TOKEN. +func TestSetupAzureAndAzCommandSucceed(t *testing.T) { + requireDocker(t) + requireAzCLI(t) + _ = env.Require(t, env.AuthToken) + if runtime.GOOS == "windows" { + t.Skip("PTY not supported on Windows") + } + + cleanup() + cleanupAzure() + t.Cleanup(cleanup) + t.Cleanup(cleanupAzure) + + tmpHome := t.TempDir() + // The emulator runs as root and writes root-owned files into the lstk + // volume dir; Go's TempDir cleanup can't remove those without help. + t.Cleanup(func() { + volumeDir := filepath.Join(tmpHome, ".cache", "lstk", "volume") + if _, err := os.Stat(volumeDir); err == nil { + _ = exec.Command("docker", "run", "--rm", "-v", volumeDir+":/d", "alpine", "sh", "-c", "rm -rf /d/*").Run() + } + }) + + baseEnv := env.With(env.AuthToken, env.Get(env.AuthToken)).With(env.Home, tmpHome) + mockServer := createMockLicenseServer(true) + defer mockServer.Close() + + workDir := azureWorkDir(t) + ctx := testContext(t) + + _, stderr, err := runLstk(t, ctx, workDir, + baseEnv.With(env.APIEndpoint, mockServer.URL), + "start", + ) + require.NoError(t, err, "lstk start failed: %s", stderr) + + binPath, err := filepath.Abs(binaryPath()) + require.NoError(t, err) + cmd := exec.CommandContext(ctx, binPath, "setup", "azure") + cmd.Dir = workDir + cmd.Env = baseEnv.With(env.APIEndpoint, mockServer.URL) + ptmx, err := pty.Start(cmd) + require.NoError(t, err, "failed to start setup azure in PTY") + defer func() { _ = ptmx.Close() }() + + out := &syncBuffer{} + outputCh := make(chan struct{}) + go func() { + _, _ = io.Copy(out, ptmx) + close(outputCh) + }() + require.NoError(t, cmd.Wait(), "setup azure should succeed; output:\n%s", out.String()) + <-outputCh + assert.Contains(t, out.String(), "Azure CLI integration ready") + + markerPath := filepath.Join(workDir, ".lstk", "azure", ".lstk-setup-complete") + _, err = os.Stat(markerPath) + require.NoError(t, err, "marker file should be written on successful setup") + + // `az cloud show` reads the isolated config dir locally, so the assertion + // doesn't depend on emulator-side behaviour for any specific Azure service. + stdout, stderr2, err := runLstk(t, ctx, workDir, + baseEnv.With(env.APIEndpoint, mockServer.URL), + "az", "cloud", "show", "--name", "LocalStack", + ) + require.NoError(t, err, "lstk az cloud show failed: %s", stderr2) + assert.Contains(t, stdout, "azure.localhost.localstack.cloud:4566", + "registered cloud should expose the LocalStack Azure endpoint") +} From f986533af85eef363b30eb1788bddb62c5609b53 Mon Sep 17 00:00:00 2001 From: carole-lavillonniere Date: Tue, 2 Jun 2026 12:20:26 +0200 Subject: [PATCH 2/4] clarify dns error message for azure commands --- cmd/az.go | 4 ++-- internal/ui/run_azureconfig.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/az.go b/cmd/az.go index c9bddea0..848c4ec6 100644 --- a/cmd/az.go +++ b/cmd/az.go @@ -97,8 +97,8 @@ Examples: sink.Emit(output.ErrorEvent{ Title: "DNS resolution required for 'lstk az'", Actions: []output.ErrorAction{ - {Label: "Note:", Value: endpoint.DNSRebindNote}, - {Label: "Why:", Value: "LocalStack routes Azure requests by Host header"}, + {Label: "Note:", Value: "Could not resolve *." + endpoint.Hostname + " to 127.0.0.1."}, + {Label: "Why:", Value: "the Azure emulator serves endpoints under *." + endpoint.Hostname + ", which the Azure CLI must be able to resolve"}, {Label: "Fix:", Value: "configure DNS or set LOCALSTACK_HOST"}, }, }) diff --git a/internal/ui/run_azureconfig.go b/internal/ui/run_azureconfig.go index 89c23ca5..8db4e026 100644 --- a/internal/ui/run_azureconfig.go +++ b/internal/ui/run_azureconfig.go @@ -35,8 +35,8 @@ func RunSetupAzure(parentCtx context.Context, containers []config.ContainerConfi sink.Emit(output.MessageEvent{ Severity: output.SeverityWarning, Text: fmt.Sprintf( - "%s Azure setup requires DNS resolution because LocalStack routes Azure requests by Host header. Configure DNS or set LOCALSTACK_HOST.", - endpoint.DNSRebindNote, + "Could not resolve *.%s to 127.0.0.1. Azure setup requires DNS resolution because the Azure emulator serves endpoints under *.%s. Configure DNS or set LOCALSTACK_HOST.", + endpoint.Hostname, endpoint.Hostname, ), }) return fmt.Errorf("dns resolution required for azure setup") From 49044572ec6c2961ef0b6a1edbb5833d97cce545 Mon Sep 17 00:00:00 2001 From: carole-lavillonniere Date: Tue, 2 Jun 2026 13:37:28 +0200 Subject: [PATCH 3/4] support non-interactive setup azure for ci --- cmd/setup.go | 12 ++++++---- internal/azureconfig/azureconfig.go | 33 +++++++++++++++++++++++++ internal/ui/run_azureconfig.go | 36 ++++------------------------ test/integration/setup_azure_test.go | 18 ++++++++++++-- 4 files changed, 60 insertions(+), 39 deletions(-) diff --git a/cmd/setup.go b/cmd/setup.go index 0ede86e4..c38ef96e 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -2,9 +2,12 @@ package cmd import ( "fmt" + "os" + "github.com/localstack/lstk/internal/azureconfig" "github.com/localstack/lstk/internal/config" "github.com/localstack/lstk/internal/env" + "github.com/localstack/lstk/internal/output" "github.com/localstack/lstk/internal/ui" "github.com/spf13/cobra" ) @@ -53,16 +56,15 @@ func newSetupAzureCmd(cfg *env.Env) *cobra.Command { return fmt.Errorf("failed to get config: %w", err) } - if !isInteractiveMode(cfg) { - return fmt.Errorf("setup azure requires an interactive terminal") - } - configDir, err := config.ConfigDir() if err != nil { return fmt.Errorf("failed to resolve config directory: %w", err) } - return ui.RunSetupAzure(cmd.Context(), appConfig.Containers, cfg.LocalStackHost, configDir) + if isInteractiveMode(cfg) { + return ui.RunSetupAzure(cmd.Context(), appConfig.Containers, cfg.LocalStackHost, configDir) + } + return azureconfig.RunSetup(cmd.Context(), output.NewPlainSink(os.Stdout), appConfig.Containers, cfg.LocalStackHost, configDir) }, } } diff --git a/internal/azureconfig/azureconfig.go b/internal/azureconfig/azureconfig.go index a412902f..28fe757c 100644 --- a/internal/azureconfig/azureconfig.go +++ b/internal/azureconfig/azureconfig.go @@ -14,6 +14,8 @@ import ( "go.opentelemetry.io/otel/attribute" "github.com/localstack/lstk/internal/azurecli" + "github.com/localstack/lstk/internal/config" + "github.com/localstack/lstk/internal/endpoint" "github.com/localstack/lstk/internal/output" ) @@ -107,6 +109,37 @@ func cloudExists(ctx context.Context, azEnv []string) (bool, error) { return strings.TrimSpace(stdout) == CloudName, nil } +// RunSetup derives the LocalStack Azure endpoint from the configured containers +// and runs Setup against the isolated Azure CLI config dir under lstkConfigDir. +// It works with any sink, so it serves both the interactive (TUI) and +// non-interactive (plain) paths. +func RunSetup(ctx context.Context, sink output.Sink, containers []config.ContainerConfig, localStackHost, lstkConfigDir string) error { + var azureContainer *config.ContainerConfig + for i := range containers { + if containers[i].Type == config.EmulatorAzure { + azureContainer = &containers[i] + break + } + } + if azureContainer == nil { + return fmt.Errorf("no azure emulator configured — run 'lstk start' and select the Azure emulator first") + } + + resolvedHost, dnsOK := endpoint.ResolveHost(ctx, azureContainer.Port, localStackHost) + if !dnsOK { + sink.Emit(output.MessageEvent{ + Severity: output.SeverityWarning, + Text: fmt.Sprintf( + "Could not resolve *.%s to 127.0.0.1. Azure setup requires DNS resolution because the Azure emulator serves endpoints under *.%s. Configure DNS or set LOCALSTACK_HOST.", + endpoint.Hostname, endpoint.Hostname, + ), + }) + return fmt.Errorf("dns resolution required for azure setup") + } + + return Setup(ctx, sink, BuildEndpoint(resolvedHost), ConfigDir(lstkConfigDir)) +} + // Setup registers the LocalStack custom cloud in an isolated AZURE_CONFIG_DIR, // activates it, disables instance discovery, and logs in with a dummy SP. func Setup(ctx context.Context, sink output.Sink, endpointURL, azureConfigDir string) error { diff --git a/internal/ui/run_azureconfig.go b/internal/ui/run_azureconfig.go index 8db4e026..f03c3497 100644 --- a/internal/ui/run_azureconfig.go +++ b/internal/ui/run_azureconfig.go @@ -2,45 +2,17 @@ package ui import ( "context" - "fmt" "github.com/localstack/lstk/internal/azureconfig" "github.com/localstack/lstk/internal/config" - "github.com/localstack/lstk/internal/endpoint" "github.com/localstack/lstk/internal/output" ) -// RunSetupAzure prepares the isolated Azure CLI config dir with TUI output. -// It derives the LocalStack Azure endpoint from the Azure emulator config and -// runs the setup, which registers a custom Azure cloud pointing at LocalStack -// without touching the user's global ~/.azure configuration. +// RunSetupAzure runs the Azure CLI setup flow with TUI output. The setup +// itself (endpoint resolution, custom cloud registration, dummy login) +// lives in azureconfig.RunSetup so non-interactive mode can reuse it. func RunSetupAzure(parentCtx context.Context, containers []config.ContainerConfig, localStackHost, lstkConfigDir string) error { - var azureContainer *config.ContainerConfig - for i := range containers { - if containers[i].Type == config.EmulatorAzure { - azureContainer = &containers[i] - break - } - } - if azureContainer == nil { - return fmt.Errorf("no azure emulator configured — run 'lstk start' and select the Azure emulator first") - } - - resolvedHost, dnsOK := endpoint.ResolveHost(parentCtx, azureContainer.Port, localStackHost) - endpointURL := azureconfig.BuildEndpoint(resolvedHost) - azureConfigDir := azureconfig.ConfigDir(lstkConfigDir) - return runWithTUI(parentCtx, withoutHeader(), func(ctx context.Context, sink output.Sink) error { - if !dnsOK { - sink.Emit(output.MessageEvent{ - Severity: output.SeverityWarning, - Text: fmt.Sprintf( - "Could not resolve *.%s to 127.0.0.1. Azure setup requires DNS resolution because the Azure emulator serves endpoints under *.%s. Configure DNS or set LOCALSTACK_HOST.", - endpoint.Hostname, endpoint.Hostname, - ), - }) - return fmt.Errorf("dns resolution required for azure setup") - } - return azureconfig.Setup(ctx, sink, endpointURL, azureConfigDir) + return azureconfig.RunSetup(ctx, sink, containers, localStackHost, lstkConfigDir) }) } diff --git a/test/integration/setup_azure_test.go b/test/integration/setup_azure_test.go index 66adc251..52e6fa59 100644 --- a/test/integration/setup_azure_test.go +++ b/test/integration/setup_azure_test.go @@ -61,7 +61,10 @@ func TestAzCommandErrorsWhenNotSetUp(t *testing.T) { assert.Contains(t, stdout, "lstk setup azure") } -func TestSetupAzureNonInteractiveReturnsError(t *testing.T) { +// Non-interactive mode must not be rejected upfront (CI use case): with no +// Azure emulator in the config, the setup logic itself runs and reports the +// domain error instead of "requires an interactive terminal". +func TestSetupAzureNonInteractiveRunsWithoutTerminal(t *testing.T) { t.Parallel() _, stderr, err := runLstk(t, testContext(t), "", @@ -69,7 +72,8 @@ func TestSetupAzureNonInteractiveReturnsError(t *testing.T) { "setup", "azure", ) require.Error(t, err) - assert.Contains(t, stderr, "setup azure requires an interactive terminal") + assert.Contains(t, stderr, "no azure emulator configured") + assert.NotContains(t, stderr, "interactive terminal") } func TestAzCommandErrorsWhenEmulatorNotRunning(t *testing.T) { @@ -161,4 +165,14 @@ func TestSetupAzureAndAzCommandSucceed(t *testing.T) { require.NoError(t, err, "lstk az cloud show failed: %s", stderr2) assert.Contains(t, stdout, "azure.localhost.localstack.cloud:4566", "registered cloud should expose the LocalStack Azure endpoint") + + // Setup must also work without a terminal (CI use case): runLstk uses + // pipes, so this exercises the plain-sink path end to end, updating the + // already-registered cloud. + stdoutNI, stderrNI, err := runLstk(t, ctx, workDir, + baseEnv.With(env.APIEndpoint, mockServer.URL), + "setup", "azure", + ) + require.NoError(t, err, "non-interactive setup azure failed: stdout=%s stderr=%s", stdoutNI, stderrNI) + assert.Contains(t, stdoutNI, "Azure CLI integration ready") } From 0cdf9e038dcad836fec9537e9f611448102c53f7 Mon Sep 17 00:00:00 2001 From: carole-lavillonniere Date: Tue, 2 Jun 2026 14:21:47 +0200 Subject: [PATCH 4/4] report setup azure failures once instead of warning plus error --- internal/azureconfig/azureconfig.go | 45 ++++++++++++---------------- test/integration/setup_azure_test.go | 18 +++++++++++ 2 files changed, 37 insertions(+), 26 deletions(-) diff --git a/internal/azureconfig/azureconfig.go b/internal/azureconfig/azureconfig.go index 28fe757c..f519e687 100644 --- a/internal/azureconfig/azureconfig.go +++ b/internal/azureconfig/azureconfig.go @@ -114,6 +114,12 @@ func cloudExists(ctx context.Context, azEnv []string) (bool, error) { // It works with any sink, so it serves both the interactive (TUI) and // non-interactive (plain) paths. func RunSetup(ctx context.Context, sink output.Sink, containers []config.ContainerConfig, localStackHost, lstkConfigDir string) error { + // Check the cheapest prerequisite first: without `az` nothing else + // matters, and a DNS resolution error would only be noise. + if err := azurecli.CheckInstalled(); err != nil { + return err + } + var azureContainer *config.ContainerConfig for i := range containers { if containers[i].Type == config.EmulatorAzure { @@ -127,14 +133,10 @@ func RunSetup(ctx context.Context, sink output.Sink, containers []config.Contain resolvedHost, dnsOK := endpoint.ResolveHost(ctx, azureContainer.Port, localStackHost) if !dnsOK { - sink.Emit(output.MessageEvent{ - Severity: output.SeverityWarning, - Text: fmt.Sprintf( - "Could not resolve *.%s to 127.0.0.1. Azure setup requires DNS resolution because the Azure emulator serves endpoints under *.%s. Configure DNS or set LOCALSTACK_HOST.", - endpoint.Hostname, endpoint.Hostname, - ), - }) - return fmt.Errorf("dns resolution required for azure setup") + return fmt.Errorf( + "could not resolve *.%s to 127.0.0.1 — Azure setup requires DNS resolution because the Azure emulator serves endpoints under *.%s; configure DNS or set LOCALSTACK_HOST", + endpoint.Hostname, endpoint.Hostname, + ) } return Setup(ctx, sink, BuildEndpoint(resolvedHost), ConfigDir(lstkConfigDir)) @@ -147,22 +149,18 @@ func Setup(ctx context.Context, sink output.Sink, endpointURL, azureConfigDir st defer span.End() // Bail early if `az` is missing so we don't leave a half-configured dir behind. + // Errors are returned without emitting them: the caller's display layer + // renders the returned error, so emitting too would print it twice. if err := azurecli.CheckInstalled(); err != nil { - sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: err.Error()}) return err } if err := IsHealthy(ctx, endpointURL); err != nil { - sink.Emit(output.MessageEvent{ - Severity: output.SeverityWarning, - Text: fmt.Sprintf("LocalStack Azure emulator not reachable at %s. Run 'lstk' to start it before running 'lstk setup azure'.", endpointURL), - }) - return fmt.Errorf("emulator not reachable at %s: %w", endpointURL, err) + return fmt.Errorf("LocalStack Azure emulator not reachable at %s — run 'lstk' to start it before running 'lstk setup azure': %w", endpointURL, err) } if err := os.MkdirAll(azureConfigDir, 0700); err != nil { - sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("could not create %s: %v", azureConfigDir, err)}) - return err + return fmt.Errorf("could not create %s: %w", azureConfigDir, err) } azEnv := Env(azureConfigDir) @@ -173,8 +171,7 @@ func Setup(ctx context.Context, sink output.Sink, endpointURL, azureConfigDir st exists, err := cloudExists(ctx, azEnv) if err != nil { - sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("could not list Azure clouds: %v", err)}) - return err + return fmt.Errorf("could not list Azure clouds: %w", err) } action, verb := "register", "Registering" if exists { @@ -183,13 +180,11 @@ func Setup(ctx context.Context, sink output.Sink, endpointURL, azureConfigDir st sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: fmt.Sprintf("%s '%s' custom cloud...", verb, CloudName)}) if _, _, err := azurecli.Run(ctx, azEnv, "cloud", action, "--name", CloudName, "--cloud-config", cloudConfigJSON, "--only-show-errors"); err != nil { - sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("could not %s '%s' cloud: %v", action, CloudName, err)}) - return err + return fmt.Errorf("could not %s '%s' cloud: %w", action, CloudName, err) } if _, _, err := azurecli.Run(ctx, azEnv, "cloud", "set", "--name", CloudName, "--only-show-errors"); err != nil { - sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("could not activate '%s' cloud: %v", CloudName, err)}) - return err + return fmt.Errorf("could not activate '%s' cloud: %w", CloudName, err) } // instance_discovery=false: `az` would otherwise try to validate the authority @@ -197,8 +192,7 @@ func Setup(ctx context.Context, sink output.Sink, endpointURL, azureConfigDir st if _, _, err := azurecli.Run(ctx, azEnv, "config", "set", "core.instance_discovery=false", "core.collect_telemetry=false", "output.show_survey_link=no", "--only-show-errors"); err != nil { - sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("could not configure Azure CLI: %v", err)}) - return err + return fmt.Errorf("could not configure Azure CLI: %w", err) } sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: "Logging in with dummy service-principal credentials..."}) @@ -208,8 +202,7 @@ func Setup(ctx context.Context, sink output.Sink, endpointURL, azureConfigDir st "--tenant", servicePrincipalTenant, "--only-show-errors", ); err != nil { - sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("could not log in to the LocalStack Azure emulator: %v", err)}) - return err + return fmt.Errorf("could not log in to the LocalStack Azure emulator: %w", err) } if err := os.WriteFile(filepath.Join(azureConfigDir, setupMarkerFile), []byte("ok\n"), 0600); err != nil { diff --git a/test/integration/setup_azure_test.go b/test/integration/setup_azure_test.go index 52e6fa59..103379e0 100644 --- a/test/integration/setup_azure_test.go +++ b/test/integration/setup_azure_test.go @@ -6,6 +6,7 @@ import ( "os/exec" "path/filepath" "runtime" + "strings" "testing" "github.com/creack/pty" @@ -76,6 +77,23 @@ func TestSetupAzureNonInteractiveRunsWithoutTerminal(t *testing.T) { assert.NotContains(t, stderr, "interactive terminal") } +// When the az CLI is missing, the error must be reported exactly once — +// not as a warning and then again as the final error. +func TestSetupAzureReportsMissingAzCLIOnce(t *testing.T) { + t.Parallel() + workDir := azureWorkDir(t) + + stdout, stderr, err := runLstk(t, testContext(t), workDir, + env.With(env.Home, t.TempDir()).With("PATH", t.TempDir()), + "setup", "azure", + ) + require.Error(t, err) + assert.Contains(t, stderr, "az CLI not found in PATH") + combined := stdout + stderr + assert.Equal(t, 1, strings.Count(combined, "az CLI not found in PATH"), + "missing az CLI must be reported exactly once, got:\n%s", combined) +} + func TestAzCommandErrorsWhenEmulatorNotRunning(t *testing.T) { requireDocker(t) cleanup()