Skip to content

Commit 22e2d3a

Browse files
Add lstk az start-interception and stop-interception commands (#336)
1 parent afc5702 commit 22e2d3a

7 files changed

Lines changed: 486 additions & 75 deletions

File tree

CLAUDE.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,14 @@ Use `lstk setup <emulator>` to set up CLI integration for an emulator type:
7272
- `lstk setup aws` — Sets up AWS CLI profile in `~/.aws/config` and `~/.aws/credentials`
7373
- `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.
7474
- `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.).
75+
- `lstk az start-interception` / `lstk az stop-interception` — Opt-in second mode: instead of the isolated dir, these mutate the user's **global** `~/.azure` so plain `az` (any terminal/script) targets LocalStack, then switch back. `start-interception` runs the same register → activate → `instance_discovery=false` → dummy-login flow against the global config (but does not touch global telemetry/survey prefs) and is independent of `lstk setup azure`. `stop-interception` switches the active cloud back to `AzureCloud` (override with `--cloud <name>`, validated against the live `az cloud list`) and re-enables instance discovery — but only if `LocalStack` is still the active cloud, to avoid clobbering an unrelated selection.
7576

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

79-
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`.
80+
The default `lstk az <args>` mode mirrors `lstk aws`: 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`.
81+
82+
`lstk az start-interception`/`stop-interception` additionally offer azlocal's global pattern (the same cloud registration applied to `~/.azure` rather than the isolated dir), so existing `az` scripts run unmodified against LocalStack. This is intentionally documented as optional because it mutates global state; prefer the isolated `lstk az <args>` mode unless a script must invoke plain `az`. The interception domain logic lives in `internal/azureconfig/interception.go` and reuses the shared `registerLocalStackCloud` helper; the command wiring (subcommands under `az` plus the shared `azPreflight` checks) is in `cmd/az.go`.
8083

8184
Environment variables:
8285
- `LOCALSTACK_AUTH_TOKEN` - Auth token (skips browser login if set)

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,16 @@ lstk az group list
109109

110110
`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.
111111

112+
To run existing `az` scripts unmodified against LocalStack, you can instead redirect your **global** Azure CLI:
113+
114+
```bash
115+
lstk az start-interception # plain `az` now targets LocalStack
116+
az group list # hits LocalStack, no `lstk` prefix needed
117+
lstk az stop-interception # back to real Azure (use --cloud to pick another cloud)
118+
```
119+
120+
This is optional and changes global state affecting every `az` invocation until you stop it; prefer `lstk az <command>` unless a script must call plain `az`.
121+
112122
You can also point `lstk` at a specific config file for any command:
113123

114124
```bash
@@ -255,6 +265,10 @@ lstk setup azure
255265
# Run Azure CLI commands against LocalStack
256266
lstk az group list
257267

268+
# Or redirect your global `az` so existing scripts hit LocalStack unmodified
269+
lstk az start-interception
270+
lstk az stop-interception
271+
258272
# Save emulator state to a local file
259273
lstk snapshot save ./my-snapshot.snapshot
260274

cmd/az.go

Lines changed: 127 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cmd
22

33
import (
4+
"context"
45
"fmt"
56
"io"
67
"os"
@@ -15,45 +16,30 @@ import (
1516
"github.com/localstack/lstk/internal/output"
1617
"github.com/localstack/lstk/internal/runtime"
1718
"github.com/localstack/lstk/internal/terminal"
19+
"github.com/localstack/lstk/internal/ui"
1820
"github.com/spf13/cobra"
1921
)
2022

2123
func newAzCmd(cfg *env.Env) *cobra.Command {
22-
return &cobra.Command{
24+
cmd := &cobra.Command{
2325
Use: "az [args...]",
2426
Short: "Run Azure CLI commands against LocalStack",
2527
Long: `Run Azure CLI commands against the LocalStack Azure emulator.
2628
27-
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.
29+
'lstk az <args>' 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 mode.
2830
29-
Run 'lstk setup azure' once before using this command.
31+
Alternatively, 'lstk az start-interception' redirects your global 'az' to LocalStack so existing scripts run unmodified, and 'lstk az stop-interception' switches back. Interception changes global state and is optional — prefer 'lstk az <args>' unless you specifically need plain 'az' to target LocalStack.
3032
3133
Examples:
3234
lstk az group list
33-
lstk az storage account list`,
35+
lstk az storage account list
36+
lstk az start-interception
37+
lstk az stop-interception`,
3438
DisableFlagParsing: true,
3539
PreRunE: initConfig(nil),
3640
RunE: func(cmd *cobra.Command, args []string) error {
3741
sink := output.NewPlainSink(os.Stdout)
3842

39-
rt, err := runtime.NewDockerRuntime(cfg.DockerHost)
40-
if err != nil {
41-
return err
42-
}
43-
44-
appCfg, err := config.Get()
45-
if err != nil {
46-
return fmt.Errorf("failed to get config: %w", err)
47-
}
48-
49-
azureContainer := config.ContainerConfig{Type: config.EmulatorAzure, Port: config.DefaultPort}
50-
for _, c := range appCfg.Containers {
51-
if c.Type == config.EmulatorAzure {
52-
azureContainer = c
53-
break
54-
}
55-
}
56-
5743
configDir, err := config.ConfigDir()
5844
if err != nil {
5945
return fmt.Errorf("failed to resolve config directory: %w", err)
@@ -69,45 +55,8 @@ Examples:
6955
return output.NewSilentError(fmt.Errorf("azure CLI integration not set up"))
7056
}
7157

72-
if err := azurecli.CheckInstalled(); err != nil {
73-
sink.Emit(output.ErrorEvent{
74-
Title: "az CLI not found in PATH",
75-
Actions: []output.ErrorAction{{Label: "Install Azure CLI:", Value: azurecli.InstallURL}},
76-
})
77-
return output.NewSilentError(err)
78-
}
79-
80-
if err := rt.IsHealthy(cmd.Context()); err != nil {
81-
rt.EmitUnhealthyError(sink, err)
82-
return output.NewSilentError(fmt.Errorf("runtime not healthy: %w", err))
83-
}
84-
85-
runningName, err := container.ResolveRunningContainerName(cmd.Context(), rt, azureContainer)
86-
if err != nil {
87-
return fmt.Errorf("checking emulator status: %w", err)
88-
}
89-
if runningName == "" {
90-
sink.Emit(output.ErrorEvent{
91-
Title: fmt.Sprintf("%s is not running", azureContainer.DisplayName()),
92-
Actions: []output.ErrorAction{
93-
{Label: "Start LocalStack:", Value: "lstk"},
94-
{Label: "See help:", Value: "lstk -h"},
95-
},
96-
})
97-
return output.NewSilentError(fmt.Errorf("%s is not running", azureContainer.Name()))
98-
}
99-
100-
_, dnsOK := endpoint.ResolveHost(cmd.Context(), azureContainer.Port, cfg.LocalStackHost)
101-
if !dnsOK {
102-
sink.Emit(output.ErrorEvent{
103-
Title: "DNS resolution required for 'lstk az'",
104-
Actions: []output.ErrorAction{
105-
{Label: "Note:", Value: "Could not resolve *." + endpoint.Hostname + " to 127.0.0.1."},
106-
{Label: "Why:", Value: "the Azure emulator serves endpoints under *." + endpoint.Hostname + ", which the Azure CLI must be able to resolve"},
107-
{Label: "Fix:", Value: "configure DNS or set LOCALSTACK_HOST"},
108-
},
109-
})
110-
return output.NewSilentError(fmt.Errorf("dns resolution required for 'lstk az'"))
58+
if _, err := azPreflight(cmd.Context(), cfg, sink); err != nil {
59+
return err
11160
}
11261

11362
azEnv := azureconfig.Env(azureConfigDir)
@@ -124,4 +73,121 @@ Examples:
12473
return azurecli.Exec(cmd.Context(), azEnv, os.Stdin, stdout, stderr, args...)
12574
},
12675
}
76+
77+
cmd.AddCommand(newAzStartInterceptionCmd(cfg))
78+
cmd.AddCommand(newAzStopInterceptionCmd(cfg))
79+
return cmd
80+
}
81+
82+
func newAzStartInterceptionCmd(cfg *env.Env) *cobra.Command {
83+
return &cobra.Command{
84+
Use: "start-interception",
85+
Short: "Redirect global 'az' to the LocalStack Azure emulator",
86+
Long: "Register and activate a custom 'LocalStack' cloud in your global Azure CLI configuration (~/.azure) so that plain 'az' commands in any terminal target the LocalStack Azure emulator. This lets existing 'az' scripts run unmodified against LocalStack. It changes global state affecting every 'az' invocation until you run 'lstk az stop-interception'; this is independent of the isolated 'lstk az' setup.",
87+
Args: cobra.NoArgs,
88+
PreRunE: initConfig(nil),
89+
RunE: func(cmd *cobra.Command, args []string) error {
90+
preflight := func(ctx context.Context, sink output.Sink) (string, error) {
91+
return azPreflight(ctx, cfg, sink)
92+
}
93+
94+
// Run preflight under the same sink as the operation so its errors render
95+
// in the TUI when interactive, instead of leaking plain output to stdout.
96+
if isInteractiveMode(cfg) {
97+
return ui.RunStartInterception(cmd.Context(), preflight)
98+
}
99+
100+
sink := output.NewPlainSink(os.Stdout)
101+
endpointURL, err := preflight(cmd.Context(), sink)
102+
if err != nil {
103+
return err
104+
}
105+
return azureconfig.StartInterception(cmd.Context(), sink, endpointURL)
106+
},
107+
}
108+
}
109+
110+
func newAzStopInterceptionCmd(cfg *env.Env) *cobra.Command {
111+
var cloud string
112+
c := &cobra.Command{
113+
Use: "stop-interception",
114+
Short: "Switch global 'az' back to real Azure",
115+
Long: "Switch your global Azure CLI cloud away from the LocalStack emulator back to real Azure (AzureCloud by default; use --cloud to choose another registered cloud) and re-enable instance discovery. To avoid clobbering an unrelated selection, it only changes the active cloud when 'LocalStack' is currently active; otherwise it reports the current cloud and does nothing.",
116+
Args: cobra.NoArgs,
117+
PreRunE: initConfig(nil),
118+
RunE: func(cmd *cobra.Command, args []string) error {
119+
if isInteractiveMode(cfg) {
120+
return ui.RunStopInterception(cmd.Context(), cloud)
121+
}
122+
return azureconfig.StopInterception(cmd.Context(), output.NewPlainSink(os.Stdout), cloud)
123+
},
124+
}
125+
c.Flags().StringVar(&cloud, "cloud", azureconfig.PublicCloudName, "Azure cloud to switch back to")
126+
return c
127+
}
128+
129+
// azPreflight runs the checks shared by 'lstk az' passthrough and 'start-interception':
130+
// the Azure CLI is installed, the Docker runtime is healthy, the Azure emulator is
131+
// running, and *.localhost.localstack.cloud resolves. On failure it emits the matching
132+
// ErrorEvent and returns a silent error. On success it returns the resolved LocalStack
133+
// Azure endpoint URL.
134+
func azPreflight(ctx context.Context, cfg *env.Env, sink output.Sink) (string, error) {
135+
if err := azurecli.CheckInstalled(); err != nil {
136+
sink.Emit(output.ErrorEvent{
137+
Title: "az CLI not found in PATH",
138+
Actions: []output.ErrorAction{{Label: "Install Azure CLI:", Value: azurecli.InstallURL}},
139+
})
140+
return "", output.NewSilentError(err)
141+
}
142+
143+
appCfg, err := config.Get()
144+
if err != nil {
145+
return "", fmt.Errorf("failed to get config: %w", err)
146+
}
147+
azureContainer := config.ContainerConfig{Type: config.EmulatorAzure, Port: config.DefaultPort}
148+
for _, c := range appCfg.Containers {
149+
if c.Type == config.EmulatorAzure {
150+
azureContainer = c
151+
break
152+
}
153+
}
154+
155+
rt, err := runtime.NewDockerRuntime(cfg.DockerHost)
156+
if err != nil {
157+
return "", err
158+
}
159+
if err := rt.IsHealthy(ctx); err != nil {
160+
rt.EmitUnhealthyError(sink, err)
161+
return "", output.NewSilentError(fmt.Errorf("runtime not healthy: %w", err))
162+
}
163+
164+
runningName, err := container.ResolveRunningContainerName(ctx, rt, azureContainer)
165+
if err != nil {
166+
return "", fmt.Errorf("checking emulator status: %w", err)
167+
}
168+
if runningName == "" {
169+
sink.Emit(output.ErrorEvent{
170+
Title: fmt.Sprintf("%s is not running", azureContainer.DisplayName()),
171+
Actions: []output.ErrorAction{
172+
{Label: "Start LocalStack:", Value: "lstk"},
173+
{Label: "See help:", Value: "lstk -h"},
174+
},
175+
})
176+
return "", output.NewSilentError(fmt.Errorf("%s is not running", azureContainer.Name()))
177+
}
178+
179+
resolvedHost, dnsOK := endpoint.ResolveHost(ctx, azureContainer.Port, cfg.LocalStackHost)
180+
if !dnsOK {
181+
sink.Emit(output.ErrorEvent{
182+
Title: "DNS resolution required for 'lstk az'",
183+
Actions: []output.ErrorAction{
184+
{Label: "Note:", Value: "Could not resolve *." + endpoint.Hostname + " to 127.0.0.1."},
185+
{Label: "Why:", Value: "the Azure emulator serves endpoints under *." + endpoint.Hostname + ", which the Azure CLI must be able to resolve"},
186+
{Label: "Fix:", Value: "configure DNS or set LOCALSTACK_HOST"},
187+
},
188+
})
189+
return "", output.NewSilentError(fmt.Errorf("dns resolution required for 'lstk az'"))
190+
}
191+
192+
return azureconfig.BuildEndpoint(resolvedHost), nil
127193
}

internal/azureconfig/azureconfig.go

Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,31 @@ func cloudExists(ctx context.Context, azEnv []string) (bool, error) {
109109
return strings.TrimSpace(stdout) == CloudName, nil
110110
}
111111

112+
// ListClouds returns the names of all clouds registered in the Azure CLI config
113+
// selected by azEnv (built-ins like AzureCloud plus any custom-registered clouds).
114+
func ListClouds(ctx context.Context, azEnv []string) ([]string, error) {
115+
stdout, _, err := azurecli.Run(ctx, azEnv, "cloud", "list", "--query", "[].name", "-o", "tsv")
116+
if err != nil {
117+
return nil, err
118+
}
119+
var names []string
120+
for _, line := range strings.Split(stdout, "\n") {
121+
if name := strings.TrimSpace(line); name != "" {
122+
names = append(names, name)
123+
}
124+
}
125+
return names, nil
126+
}
127+
128+
// ActiveCloud returns the name of the currently active Azure CLI cloud.
129+
func ActiveCloud(ctx context.Context, azEnv []string) (string, error) {
130+
stdout, _, err := azurecli.Run(ctx, azEnv, "cloud", "show", "--query", "name", "-o", "tsv")
131+
if err != nil {
132+
return "", err
133+
}
134+
return strings.TrimSpace(stdout), nil
135+
}
136+
112137
// RunSetup derives the LocalStack Azure endpoint from the configured containers
113138
// and runs Setup against the isolated Azure CLI config dir under lstkConfigDir.
114139
// It works with any sink, so it serves both the interactive (TUI) and
@@ -162,8 +187,31 @@ func Setup(ctx context.Context, sink output.Sink, endpointURL, azureConfigDir st
162187
if err := os.MkdirAll(azureConfigDir, 0700); err != nil {
163188
return fmt.Errorf("could not create %s: %w", azureConfigDir, err)
164189
}
165-
azEnv := Env(azureConfigDir)
166190

191+
if err := registerLocalStackCloud(ctx, sink, Env(azureConfigDir), endpointURL, true); err != nil {
192+
return err
193+
}
194+
195+
if err := os.WriteFile(filepath.Join(azureConfigDir, setupMarkerFile), []byte("ok\n"), 0600); err != nil {
196+
return fmt.Errorf("writing setup marker: %w", err)
197+
}
198+
199+
sink.Emit(output.MessageEvent{
200+
Severity: output.SeveritySuccess,
201+
Text: "Azure CLI integration ready. Run 'lstk az <command>' to talk to LocalStack.",
202+
})
203+
return nil
204+
}
205+
206+
// registerLocalStackCloud registers (or updates) the LocalStack custom cloud in the
207+
// Azure CLI config selected by azEnv, activates it, disables instance discovery, and
208+
// logs in with the dummy service principal. A nil azEnv targets the user's global
209+
// ~/.azure; a non-nil azEnv (AZURE_CONFIG_DIR=...) targets an isolated dir.
210+
//
211+
// setLstkPrefs additionally disables telemetry and the survey link. That is fine for
212+
// the isolated dir but must not be forced on the user's global config, so interception
213+
// passes false.
214+
func registerLocalStackCloud(ctx context.Context, sink output.Sink, azEnv []string, endpointURL string, setLstkPrefs bool) error {
167215
cloudConfigJSON, err := BuildCloudConfig(endpointURL)
168216
if err != nil {
169217
return fmt.Errorf("building cloud config: %w", err)
@@ -189,9 +237,12 @@ func Setup(ctx context.Context, sink output.Sink, endpointURL, azureConfigDir st
189237

190238
// instance_discovery=false: `az` would otherwise try to validate the authority
191239
// against the public AAD discovery endpoint, which the emulator can't serve.
192-
if _, _, err := azurecli.Run(ctx, azEnv, "config", "set",
193-
"core.instance_discovery=false", "core.collect_telemetry=false", "output.show_survey_link=no",
194-
"--only-show-errors"); err != nil {
240+
configArgs := []string{"config", "set", "core.instance_discovery=false"}
241+
if setLstkPrefs {
242+
configArgs = append(configArgs, "core.collect_telemetry=false", "output.show_survey_link=no")
243+
}
244+
configArgs = append(configArgs, "--only-show-errors")
245+
if _, _, err := azurecli.Run(ctx, azEnv, configArgs...); err != nil {
195246
return fmt.Errorf("could not configure Azure CLI: %w", err)
196247
}
197248

@@ -204,14 +255,5 @@ func Setup(ctx context.Context, sink output.Sink, endpointURL, azureConfigDir st
204255
); err != nil {
205256
return fmt.Errorf("could not log in to the LocalStack Azure emulator: %w", err)
206257
}
207-
208-
if err := os.WriteFile(filepath.Join(azureConfigDir, setupMarkerFile), []byte("ok\n"), 0600); err != nil {
209-
return fmt.Errorf("writing setup marker: %w", err)
210-
}
211-
212-
sink.Emit(output.MessageEvent{
213-
Severity: output.SeveritySuccess,
214-
Text: "Azure CLI integration ready. Run 'lstk az <command>' to talk to LocalStack.",
215-
})
216258
return nil
217259
}

0 commit comments

Comments
 (0)