Skip to content

Commit d988329

Browse files
az cli proxy
1 parent 9164ef4 commit d988329

13 files changed

Lines changed: 780 additions & 8 deletions

File tree

CLAUDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,14 @@ Created automatically on first run with defaults. Supports emulator types: `aws`
6969

7070
Use `lstk setup <emulator>` to set up CLI integration for an emulator type:
7171
- `lstk setup aws` — Sets up AWS CLI profile in `~/.aws/config` and `~/.aws/credentials`
72+
- `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.
73+
- `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.).
7274

7375
This naming avoids AWS-specific "profile" terminology and uses a clear verb for mutation operations.
7476
The deprecated `lstk config profile` command still works but points users to `lstk setup aws`.
7577

78+
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`.
79+
7680
Environment variables:
7781
- `LOCALSTACK_AUTH_TOKEN` - Auth token (skips browser login if set)
7882
- `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).

README.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,13 +83,20 @@ To see which config file is currently in use:
8383
lstk config path
8484
```
8585

86-
You can also configure AWS CLI integration:
86+
You can also configure cloud CLI integration:
8787

8888
```bash
89-
lstk setup aws
89+
lstk setup aws # localstack profile in ~/.aws/
90+
lstk setup azure # isolated Azure CLI config for `lstk az` (requires the Azure CLI)
9091
```
9192

92-
This sets up a `localstack` profile in `~/.aws/config` and `~/.aws/credentials`.
93+
After `lstk setup azure`, run Azure CLI commands against LocalStack with `lstk az`:
94+
95+
```bash
96+
lstk az group list
97+
```
98+
99+
`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.
93100

94101
You can also point `lstk` at a specific config file for any command:
95102

@@ -196,6 +203,12 @@ lstk config path
196203
# Set up AWS CLI profile integration
197204
lstk setup aws
198205

206+
# Set up Azure CLI integration (isolated config for `lstk az`)
207+
lstk setup azure
208+
209+
# Run Azure CLI commands against LocalStack
210+
lstk az group list
211+
199212
```
200213

201214
## Reporting bugs

cmd/aws.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ Examples:
4747
return fmt.Errorf("failed to get config: %w", err)
4848
}
4949

50-
awsContainer := config.ContainerConfig{Type: config.EmulatorAWS, Port: config.DefaultAWSPort}
50+
awsContainer := config.ContainerConfig{Type: config.EmulatorAWS, Port: config.DefaultPort}
5151
for _, c := range appCfg.Containers {
5252
if c.Type == config.EmulatorAWS {
5353
awsContainer = c

cmd/az.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"os"
7+
"time"
8+
9+
"github.com/localstack/lstk/internal/azurecli"
10+
"github.com/localstack/lstk/internal/azureconfig"
11+
"github.com/localstack/lstk/internal/config"
12+
"github.com/localstack/lstk/internal/container"
13+
"github.com/localstack/lstk/internal/endpoint"
14+
"github.com/localstack/lstk/internal/env"
15+
"github.com/localstack/lstk/internal/output"
16+
"github.com/localstack/lstk/internal/runtime"
17+
"github.com/localstack/lstk/internal/terminal"
18+
"github.com/spf13/cobra"
19+
)
20+
21+
func newAzCmd(cfg *env.Env) *cobra.Command {
22+
return &cobra.Command{
23+
Use: "az [args...]",
24+
Short: "Run Azure CLI commands against LocalStack",
25+
Long: `Run Azure CLI commands against the LocalStack Azure emulator.
26+
27+
Runs 'az <args>' with an isolated AZURE_CONFIG_DIR in which a custom Azure cloud
28+
is registered against LocalStack's endpoints, so your global ~/.azure
29+
configuration is left untouched and plain 'az' commands keep talking to real
30+
Azure.
31+
32+
Run 'lstk setup azure' once before using this command.
33+
34+
Examples:
35+
lstk az group list
36+
lstk az storage account list`,
37+
DisableFlagParsing: true,
38+
PreRunE: initConfig(nil),
39+
RunE: func(cmd *cobra.Command, args []string) error {
40+
rt, err := runtime.NewDockerRuntime(cfg.DockerHost)
41+
if err != nil {
42+
return err
43+
}
44+
45+
appCfg, err := config.Get()
46+
if err != nil {
47+
return fmt.Errorf("failed to get config: %w", err)
48+
}
49+
50+
azureContainer := config.ContainerConfig{Type: config.EmulatorAzure, Port: config.DefaultPort}
51+
for _, c := range appCfg.Containers {
52+
if c.Type == config.EmulatorAzure {
53+
azureContainer = c
54+
break
55+
}
56+
}
57+
58+
sink := output.NewPlainSink(os.Stdout)
59+
60+
configDir, err := config.ConfigDir()
61+
if err != nil {
62+
return fmt.Errorf("failed to resolve config directory: %w", err)
63+
}
64+
azureConfigDir := azureconfig.ConfigDir(configDir)
65+
if !azureconfig.IsSetUp(azureConfigDir) {
66+
sink.Emit(output.ErrorEvent{
67+
Title: "Azure CLI integration is not set up",
68+
Actions: []output.ErrorAction{
69+
{Label: "Set it up:", Value: "lstk setup azure"},
70+
},
71+
})
72+
return output.NewSilentError(fmt.Errorf("azure CLI integration not set up"))
73+
}
74+
75+
if err := rt.IsHealthy(cmd.Context()); err != nil {
76+
rt.EmitUnhealthyError(sink, err)
77+
return output.NewSilentError(fmt.Errorf("runtime not healthy: %w", err))
78+
}
79+
80+
runningName, err := container.ResolveRunningContainerName(cmd.Context(), rt, azureContainer)
81+
if err != nil {
82+
return fmt.Errorf("checking emulator status: %w", err)
83+
}
84+
if runningName == "" {
85+
sink.Emit(output.ErrorEvent{
86+
Title: fmt.Sprintf("%s is not running", azureContainer.DisplayName()),
87+
Actions: []output.ErrorAction{
88+
{Label: "Start LocalStack:", Value: "lstk"},
89+
{Label: "See help:", Value: "lstk -h"},
90+
},
91+
})
92+
return output.NewSilentError(fmt.Errorf("%s is not running", azureContainer.Name()))
93+
}
94+
95+
_, dnsOK := endpoint.ResolveHost(cmd.Context(), azureContainer.Port, cfg.LocalStackHost)
96+
if !dnsOK {
97+
sink.Emit(output.ErrorEvent{
98+
Title: "DNS resolution required for 'lstk az'",
99+
Actions: []output.ErrorAction{
100+
{Label: "Note:", Value: endpoint.DNSRebindNote},
101+
{Label: "Why:", Value: "LocalStack routes Azure requests by Host header"},
102+
{Label: "Fix:", Value: "configure DNS or set LOCALSTACK_HOST"},
103+
},
104+
})
105+
return output.NewSilentError(fmt.Errorf("dns resolution required for 'lstk az'"))
106+
}
107+
108+
azEnv := azureconfig.Env(azureConfigDir)
109+
110+
stdout, stderr := io.Writer(os.Stdout), io.Writer(os.Stderr)
111+
if terminal.IsTerminal(os.Stderr) {
112+
s := terminal.NewSpinner(os.Stderr, "Loading service...", 4*time.Second)
113+
s.Start()
114+
defer s.Stop()
115+
stdout = &terminal.StopOnWriteWriter{W: os.Stdout, Spinner: s}
116+
stderr = &terminal.StopOnWriteWriter{W: os.Stderr, Spinner: s}
117+
}
118+
119+
return azurecli.Exec(cmd.Context(), azEnv, os.Stdin, stdout, stderr, args...)
120+
},
121+
}
122+
}

cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C
7878
newUpdateCmd(cfg),
7979
newDocsCmd(),
8080
newAWSCmd(cfg),
81+
newAzCmd(cfg),
8182
newSnapshotCmd(cfg),
8283
newResetCmd(cfg),
8384
)

cmd/setup.go

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@ func newSetupCmd(cfg *env.Env) *cobra.Command {
1313
cmd := &cobra.Command{
1414
Use: "setup",
1515
Short: "Set up emulator CLI integration",
16-
Long: "Set up emulator CLI integration. Currently only AWS is supported.",
16+
Long: "Set up emulator CLI integration for AWS or Azure.",
1717
}
1818
cmd.AddCommand(newSetupAWSCmd(cfg))
19+
cmd.AddCommand(newSetupAzureCmd(cfg))
1920
return cmd
2021
}
2122

@@ -39,3 +40,29 @@ func newSetupAWSCmd(cfg *env.Env) *cobra.Command {
3940
},
4041
}
4142
}
43+
44+
func newSetupAzureCmd(cfg *env.Env) *cobra.Command {
45+
return &cobra.Command{
46+
Use: "azure",
47+
Short: "Set up Azure CLI integration with LocalStack",
48+
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.",
49+
PreRunE: initConfig(nil),
50+
RunE: func(cmd *cobra.Command, args []string) error {
51+
appConfig, err := config.Get()
52+
if err != nil {
53+
return fmt.Errorf("failed to get config: %w", err)
54+
}
55+
56+
if !isInteractiveMode(cfg) {
57+
return fmt.Errorf("setup azure requires an interactive terminal")
58+
}
59+
60+
configDir, err := config.ConfigDir()
61+
if err != nil {
62+
return fmt.Errorf("failed to resolve config directory: %w", err)
63+
}
64+
65+
return ui.RunSetupAzure(cmd.Context(), appConfig.Containers, cfg.LocalStackHost, configDir)
66+
},
67+
}
68+
}

internal/azurecli/exec.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package azurecli
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"errors"
7+
"fmt"
8+
"io"
9+
"os"
10+
"os/exec"
11+
12+
"go.opentelemetry.io/otel"
13+
"go.opentelemetry.io/otel/attribute"
14+
"go.opentelemetry.io/otel/codes"
15+
)
16+
17+
// ErrNotInstalled is returned when the `az` binary cannot be found on PATH.
18+
var ErrNotInstalled = errors.New("az CLI not found in PATH — install it from https://learn.microsoft.com/cli/azure/install-azure-cli")
19+
20+
// CheckInstalled returns ErrNotInstalled if the `az` binary is not on PATH.
21+
// Callers should use this before performing setup work to avoid leaving partial state.
22+
func CheckInstalled() error {
23+
if _, err := exec.LookPath("az"); err != nil {
24+
return ErrNotInstalled
25+
}
26+
return nil
27+
}
28+
29+
// Exec runs `az <args...>`. extraEnv is appended to the inherited process environment
30+
// (later entries win), letting callers inject AZURE_CONFIG_DIR, proxy, and CA settings
31+
// without mutating the user's global Azure CLI configuration.
32+
func Exec(ctx context.Context, extraEnv []string, stdin io.Reader, stdout, stderr io.Writer, args ...string) error {
33+
ctx, span := otel.Tracer("github.com/localstack/lstk/internal/azurecli").Start(ctx, "az cli")
34+
defer span.End()
35+
36+
azBin, err := exec.LookPath("az")
37+
if err != nil {
38+
span.RecordError(err)
39+
span.SetStatus(codes.Error, err.Error())
40+
return ErrNotInstalled
41+
}
42+
43+
span.SetAttributes(attribute.StringSlice("az.args", args))
44+
45+
cmd := exec.CommandContext(ctx, azBin, args...)
46+
cmd.Stdin = stdin
47+
cmd.Stdout = stdout
48+
cmd.Stderr = stderr
49+
if len(extraEnv) > 0 {
50+
cmd.Env = append(os.Environ(), extraEnv...)
51+
}
52+
if err := cmd.Run(); err != nil {
53+
var exitErr *exec.ExitError
54+
if errors.As(err, &exitErr) {
55+
span.SetAttributes(attribute.Int("az.exit_code", exitErr.ExitCode()))
56+
span.SetStatus(codes.Error, "az cli exited non-zero")
57+
} else {
58+
span.RecordError(err)
59+
span.SetStatus(codes.Error, err.Error())
60+
}
61+
return err
62+
}
63+
return nil
64+
}
65+
66+
// Run executes `az <args...>` with extraEnv and returns the captured stdout, stderr,
67+
// and any error. On non-zero exit, the error wraps stderr to aid debugging.
68+
func Run(ctx context.Context, extraEnv []string, args ...string) (stdout, stderr string, err error) {
69+
var outBuf, errBuf bytes.Buffer
70+
runErr := Exec(ctx, extraEnv, nil, &outBuf, &errBuf, args...)
71+
stdout = outBuf.String()
72+
stderr = errBuf.String()
73+
if runErr != nil {
74+
var exitErr *exec.ExitError
75+
if errors.As(runErr, &exitErr) && stderr != "" {
76+
return stdout, stderr, fmt.Errorf("az %v: %w: %s", args, runErr, stderr)
77+
}
78+
return stdout, stderr, runErr
79+
}
80+
return stdout, stderr, nil
81+
}

0 commit comments

Comments
 (0)