Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,14 @@ Created automatically on first run with defaults. Supports emulator types: `aws`

Use `lstk setup <emulator>` 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 <args>` — Runs `az <args>` 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).
Expand Down
19 changes: 16 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cmd/aws.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
122 changes: 122 additions & 0 deletions cmd/az.go
Original file line number Diff line number Diff line change
@@ -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 <args>' 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: "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"},
},
})
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...)
},
}
}
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
31 changes: 30 additions & 1 deletion cmd/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -13,9 +16,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
}

Expand All @@ -39,3 +43,28 @@ 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)
}

configDir, err := config.ConfigDir()
if err != nil {
return fmt.Errorf("failed to resolve config directory: %w", err)
}

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)
},
}
}
81 changes: 81 additions & 0 deletions internal/azurecli/exec.go
Original file line number Diff line number Diff line change
@@ -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
Comment thread
anisaoshafi marked this conversation as resolved.
}
return nil
}

// Exec runs `az <args...>`. 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 <args...>` 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
}
Loading
Loading