Skip to content
Draft
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
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,13 @@ 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`): caches the LocalStack CA, disables Azure CLI telemetry, and performs a one-time dummy service-principal login routed through LocalStack's HTTPS proxy. The user's global `~/.azure` is left untouched. 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`.

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`. `lstk az <args>` runs `az <args>` with that isolated dir plus `HTTP(S)_PROXY` pointing at LocalStack's proxy (discovered via `/_localstack/proxy`) and `REQUESTS_CA_BUNDLE`/`SSL_CERT_FILE` set to the cached CA bundle. Because all traffic is proxied, no per-service endpoint registration is needed — new emulator services work without CLI changes.

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
```

This routes `az` through LocalStack using an isolated 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
99 changes: 99 additions & 0 deletions cmd/az.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
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/endpoint"
"github.com/localstack/lstk/internal/env"
"github.com/localstack/lstk/internal/output"
"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: `Proxy Azure CLI commands to the LocalStack Azure emulator.

Runs 'az <args>' with an isolated AZURE_CONFIG_DIR routed through LocalStack's
HTTPS proxy, 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 {
appCfg, err := config.Get()
if err != nil {
return fmt.Errorf("failed to get config: %w", err)
}

azureContainer := config.ContainerConfig{Type: config.EmulatorAzure, Port: config.DefaultAWSPort}
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"))
}

host, _ := endpoint.ResolveHost(cmd.Context(), azureContainer.Port, cfg.LocalStackHost)
endpointURL := azureconfig.BuildEndpoint(host)

if err := azureconfig.IsRunning(cmd.Context(), endpointURL); err != nil {
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()))
}

proxyEndpoint, err := azureconfig.ProxyEndpoint(cmd.Context(), endpointURL)
if err != nil {
return fmt.Errorf("could not discover LocalStack proxy: %w", err)
}
azEnv := azureconfig.ProxyEnv(azureConfigDir, proxyEndpoint, azureconfig.CACertPath(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 @@ -78,6 +78,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C
newUpdateCmd(cfg),
newDocsCmd(),
newAWSCmd(cfg),
newAzCmd(cfg),
newSnapshotCmd(cfg),
)

Expand Down
25 changes: 24 additions & 1 deletion cmd/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

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

return ui.RunSetupAzure(cmd.Context(), appConfig.Containers, cfg.LocalStackHost, configDir)
},
}
}
72 changes: 72 additions & 0 deletions internal/azurecli/exec.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
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 <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