From 1e376ecd12224e8f7b72570ccbae8ed449b85594 Mon Sep 17 00:00:00 2001 From: carole-lavillonniere Date: Tue, 19 May 2026 11:52:40 +0200 Subject: [PATCH 1/2] register azure custom cloud --- cmd/setup.go | 24 ++- internal/azurecli/exec.go | 67 +++++++ internal/azureconfig/azureconfig.go | 214 +++++++++++++++++++++++ internal/azureconfig/azureconfig_test.go | 94 ++++++++++ internal/ui/run_azureconfig.go | 44 +++++ test/integration/setup_azure_test.go | 57 ++++++ 6 files changed, 499 insertions(+), 1 deletion(-) 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/cmd/setup.go b/cmd/setup.go index f0663f05..1f3a5cb7 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,24 @@ func newSetupAWSCmd(cfg *env.Env) *cobra.Command { }, } } + +func newSetupAzureCmd(cfg *env.Env) *cobra.Command { + return &cobra.Command{ + Use: "azure", + Short: "Set up the LocalStack Azure custom cloud", + Long: "Register and activate the LocalStack custom cloud in the Azure CLI configuration, and log in with dummy service-principal credentials. 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") + } + + return ui.RunSetupAzure(cmd.Context(), appConfig.Containers, cfg.LocalStackHost) + }, + } +} diff --git a/internal/azurecli/exec.go b/internal/azurecli/exec.go new file mode 100644 index 00000000..0601f366 --- /dev/null +++ b/internal/azurecli/exec.go @@ -0,0 +1,67 @@ +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") + +// Exec runs `az ` with the given stdout/stderr writers, inheriting stdin. +func Exec(ctx context.Context, 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 = os.Stdin + cmd.Stdout = stdout + cmd.Stderr = stderr + 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 ` 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, args ...string) (stdout, stderr string, err error) { + var outBuf, errBuf bytes.Buffer + runErr := Exec(ctx, &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..7f3a0ff9 --- /dev/null +++ b/internal/azureconfig/azureconfig.go @@ -0,0 +1,214 @@ +package azureconfig + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + + "github.com/localstack/lstk/internal/azurecli" + "github.com/localstack/lstk/internal/output" +) + +const ( + CloudName = "LocalStack" + DefaultCloudName = "AzureCloud" + AzureSubdomain = "azure" + + // 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" +) + +// BuildEndpoint returns the LocalStack Azure endpoint URL for the given host:port. +// The endpoint must use the "azure." subdomain so the LocalStack proxy routes +// requests to the Azure backend. +func BuildEndpoint(host string) string { + return "https://" + AzureSubdomain + "." + host +} + +// IsRunning probes the LocalStack health endpoint at the given Azure endpoint. +func IsRunning(ctx context.Context, endpointURL string) error { + ctx, span := otel.Tracer("github.com/localstack/lstk/internal/azureconfig").Start(ctx, "azureconfig.IsRunning") + defer span.End() + + url := strings.TrimRight(endpointURL, "/") + "/_localstack/health" + span.SetAttributes(attribute.String("azure.health_url", url)) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Do(req) + 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 +} + +// cloudExists reports whether the LocalStack cloud is already registered with `az`. +func cloudExists(ctx context.Context) (bool, error) { + stdout, _, err := azurecli.Run(ctx, "cloud", "list", "--query", "[].name", "--output", "json") + if err != nil { + return false, err + } + var names []string + if err := json.Unmarshal([]byte(stdout), &names); err != nil { + return false, fmt.Errorf("parsing az cloud list output: %w", err) + } + for _, n := range names { + if n == CloudName { + return true, nil + } + } + return false, nil +} + +// buildCloudConfig produces the --cloud-config JSON payload for `az cloud register|update`. +// Trailing slashes match the format produced by the official Azure cloud config so +// that az appends paths without producing concatenated URLs. +func buildCloudConfig(endpointURL string) ([]byte, error) { + trimmed := strings.TrimRight(endpointURL, "/") + withSlash := trimmed + "/" + cfg := struct { + Endpoints map[string]string `json:"endpoints"` + }{ + Endpoints: map[string]string{ + "activeDirectory": trimmed, + "activeDirectoryResourceId": trimmed, + "activeDirectoryGraphResourceId": trimmed, + "management": withSlash, + "microsoftGraphResourceId": withSlash, + "resourceManager": withSlash, + "logAnalyticsResourceId": trimmed, + }, + } + return json.Marshal(cfg) +} + +// registerOrUpdate registers (or updates if it already exists) the LocalStack cloud. +func registerOrUpdate(ctx context.Context, endpointURL string, alreadyExists bool) error { + verb := "register" + if alreadyExists { + verb = "update" + } + cloudConfigJSON, err := buildCloudConfig(endpointURL) + if err != nil { + return err + } + _, _, err = azurecli.Run(ctx, "cloud", verb, + "--name", CloudName, + "--cloud-config", string(cloudConfigJSON), + ) + return err +} + +func setActiveCloud(ctx context.Context, name string) error { + _, _, err := azurecli.Run(ctx, "cloud", "set", "--name", name, "--only-show-errors") + return err +} + +func disableInstanceDiscovery(ctx context.Context) error { + _, _, err := azurecli.Run(ctx, "config", "set", "core.instance_discovery=false", "--only-show-errors") + return err +} + +func loginServicePrincipal(ctx context.Context) error { + _, _, err := azurecli.Run(ctx, "login", "--service-principal", + "-u", servicePrincipalUser, + "-p", servicePrincipalPass, + "--tenant", servicePrincipalTenant, + "--only-show-errors", + ) + return err +} + +// Setup runs the full Azure custom cloud setup flow against the given LocalStack +// Azure endpoint. It expects the emulator to be running and prompts the user for +// confirmation before mutating the local Azure CLI configuration. +func Setup(ctx context.Context, sink output.Sink, endpointURL string) error { + ctx, span := otel.Tracer("github.com/localstack/lstk/internal/azureconfig").Start(ctx, "azureconfig.Setup") + defer span.End() + + if err := IsRunning(ctx, endpointURL); err != nil { + sink.Emit(output.MessageEvent{ + Severity: output.SeverityWarning, + Text: fmt.Sprintf( + "LocalStack Azure emulator not reachable at %s. Start it with 'lstk start' before running 'lstk setup azure'.", + endpointURL, + ), + }) + return fmt.Errorf("emulator not reachable at %s: %w", endpointURL, err) + } + + responseCh := make(chan output.InputResponse, 1) + sink.Emit(output.UserInputRequestEvent{ + Prompt: "Set up the LocalStack Azure cloud in `az`?", + Options: []output.InputOption{{Key: "y", Label: "Y"}, {Key: "n", Label: "n"}}, + ResponseCh: responseCh, + }) + + select { + case resp := <-responseCh: + if resp.Cancelled { + return nil + } + if resp.SelectedKey == "n" { + sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: "Skipped Azure cloud setup."}) + return nil + } + case <-ctx.Done(): + return ctx.Err() + } + + exists, err := cloudExists(ctx) + if err != nil { + sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("could not list Azure clouds: %v", err)}) + return err + } + + verb := "Registering" + if exists { + verb = "Updating" + } + sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: fmt.Sprintf("%s %q cloud...", verb, CloudName)}) + if err := registerOrUpdate(ctx, endpointURL, exists); err != nil { + sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("could not register Azure cloud: %v", err)}) + return err + } + + sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: "Activating cloud and disabling instance discovery..."}) + if err := setActiveCloud(ctx, CloudName); err != nil { + sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("could not activate Azure cloud: %v", err)}) + return err + } + if err := disableInstanceDiscovery(ctx); err != nil { + sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("could not disable instance discovery: %v", err)}) + return err + } + + sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: "Logging in with dummy service-principal credentials..."}) + if err := loginServicePrincipal(ctx); err != nil { + sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("could not log in to Azure CLI: %v", err)}) + return err + } + + sink.Emit(output.MessageEvent{ + Severity: output.SeveritySuccess, + Text: fmt.Sprintf("Set up %q Azure cloud at %s.", CloudName, endpointURL), + }) + return nil +} diff --git a/internal/azureconfig/azureconfig_test.go b/internal/azureconfig/azureconfig_test.go new file mode 100644 index 00000000..eb98133e --- /dev/null +++ b/internal/azureconfig/azureconfig_test.go @@ -0,0 +1,94 @@ +package azureconfig + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildEndpoint(t *testing.T) { + 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) { + assert.Equal(t, tc.want, BuildEndpoint(tc.host)) + }) + } +} + +func TestBuildCloudConfig(t *testing.T) { + raw, err := buildCloudConfig("https://azure.localhost.localstack.cloud:4566") + require.NoError(t, err) + + var got struct { + Endpoints map[string]string `json:"endpoints"` + } + require.NoError(t, json.Unmarshal(raw, &got)) + + const bare = "https://azure.localhost.localstack.cloud:4566" + const slash = bare + "/" + + // Endpoints that the `az` CLI appends paths to must end with "/" + // (mirrors the azlocal reference implementation). + assert.Equal(t, slash, got.Endpoints["management"]) + assert.Equal(t, slash, got.Endpoints["microsoftGraphResourceId"]) + assert.Equal(t, slash, got.Endpoints["resourceManager"]) + + // AD/log endpoints are used as resource identifiers and must NOT end with "/". + assert.Equal(t, bare, got.Endpoints["activeDirectory"]) + assert.Equal(t, bare, got.Endpoints["activeDirectoryResourceId"]) + assert.Equal(t, bare, got.Endpoints["activeDirectoryGraphResourceId"]) + assert.Equal(t, bare, got.Endpoints["logAnalyticsResourceId"]) +} + +func TestBuildCloudConfigStripsExistingTrailingSlash(t *testing.T) { + raw, err := buildCloudConfig("https://azure.localhost.localstack.cloud:4566/") + require.NoError(t, err) + + var got struct { + Endpoints map[string]string `json:"endpoints"` + } + require.NoError(t, json.Unmarshal(raw, &got)) + + // No double-slash even if caller passes a trailing slash. + assert.Equal(t, "https://azure.localhost.localstack.cloud:4566/", got.Endpoints["management"]) + assert.Equal(t, "https://azure.localhost.localstack.cloud:4566", got.Endpoints["activeDirectory"]) +} + +func TestIsRunningOK(t *testing.T) { + 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, IsRunning(context.Background(), srv.URL)) +} + +func TestIsRunningNon200(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer srv.Close() + + err := IsRunning(context.Background(), srv.URL) + require.Error(t, err) + assert.Contains(t, err.Error(), "503") +} + +func TestIsRunningUnreachable(t *testing.T) { + // Closed port on loopback should refuse the connection quickly. + err := IsRunning(context.Background(), "http://127.0.0.1:1") + require.Error(t, err) +} diff --git a/internal/ui/run_azureconfig.go b/internal/ui/run_azureconfig.go new file mode 100644 index 00000000..15f652b8 --- /dev/null +++ b/internal/ui/run_azureconfig.go @@ -0,0 +1,44 @@ +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 runs the LocalStack Azure custom cloud setup flow with TUI output. +// It locates the Azure emulator entry in the user's config to derive the port and +// then builds the `https://azure.:` endpoint used by the Azure CLI. +func RunSetupAzure(parentCtx context.Context, containers []config.ContainerConfig, localStackHost 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 in config.toml; add a [[containers]] entry with type = \"azure\"") + } + + resolvedHost, dnsOK := endpoint.ResolveHost(parentCtx, azureContainer.Port, localStackHost) + endpointURL := azureconfig.BuildEndpoint(resolvedHost) + + 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 the LocalStack proxy routes 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) + }) +} diff --git a/test/integration/setup_azure_test.go b/test/integration/setup_azure_test.go new file mode 100644 index 00000000..c4b3782b --- /dev/null +++ b/test/integration/setup_azure_test.go @@ -0,0 +1,57 @@ +package integration_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/localstack/lstk/test/integration/env" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// azureSetupEnv returns a base environment with an isolated HOME so that tests +// never read or write the developer's real Azure/AWS config files. +func azureSetupEnv(t *testing.T) (env.Environ, string) { + t.Helper() + tmpHome := t.TempDir() + return env.With(env.Home, tmpHome), tmpHome +} + +func TestSetupAzureNonInteractiveReturnsError(t *testing.T) { + t.Parallel() + baseEnv, _ := azureSetupEnv(t) + + _, stderr, err := runLstk(t, testContext(t), "", + baseEnv, + "setup", "azure", + ) + require.Error(t, err) + assert.Contains(t, stderr, "setup azure requires an interactive terminal") +} + +// writeConfigToml writes the given TOML content to $HOME/.config/lstk/config.toml +// under the temp home so lstk picks it up via its standard config resolution. +func writeConfigToml(t *testing.T, home, content string) { + t.Helper() + dir := filepath.Join(home, ".config", "lstk") + require.NoError(t, os.MkdirAll(dir, 0700)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "config.toml"), []byte(content), 0600)) +} + +func TestSetupAzureErrorsWhenNoAzureEmulatorConfigured(t *testing.T) { + t.Parallel() + baseEnv, tmpHome := azureSetupEnv(t) + + // Config that only declares an AWS emulator — no [[containers]] of type "azure". + writeConfigToml(t, tmpHome, ` +[[containers]] +type = "aws" +port = "4566" +`) + + out, err := runLstkInPTY(t, testContext(t), baseEnv, "setup", "azure") + require.Error(t, err, "expected setup azure to fail without an azure container in config") + requireExitCode(t, 1, err) + assert.Contains(t, out, "no azure emulator configured") +} From 777a0a407a11d2ca56e836aaeb3847da2882d88c Mon Sep 17 00:00:00 2001 From: carole-lavillonniere Date: Tue, 19 May 2026 11:59:54 +0200 Subject: [PATCH 2/2] document lstk setup azure --- CLAUDE.md | 1 + README.md | 10 ++++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7d9202b6..68752ad8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,6 +59,7 @@ 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` — Registers and activates the LocalStack custom cloud in `az`, disables instance discovery, and logs in with dummy service-principal credentials. Requires the `az` CLI and a running Azure emulator. 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`. diff --git a/README.md b/README.md index 5a0d3ce8..0eea7352 100644 --- a/README.md +++ b/README.md @@ -83,14 +83,13 @@ 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 # LocalStack custom cloud in `az` (requires the Azure CLI) ``` -This sets up a `localstack` profile in `~/.aws/config` and `~/.aws/credentials`. - You can also point `lstk` at a specific config file for any command: ```bash @@ -196,6 +195,9 @@ lstk config path # Set up AWS CLI profile integration lstk setup aws +# Set up Azure CLI integration (LocalStack custom cloud) +lstk setup azure + ``` ## Reporting bugs