From ad41209b5d2483597dd0ec76ccbc737d41be2f6a Mon Sep 17 00:00:00 2001 From: carole-lavillonniere Date: Tue, 19 May 2026 11:52:40 +0200 Subject: [PATCH] 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") +}