Skip to content

Commit 62261ac

Browse files
register azure custom cloud
1 parent ca6f7bc commit 62261ac

10 files changed

Lines changed: 642 additions & 4 deletions

File tree

CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,13 @@ 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`): 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.
7273

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

77+
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.
78+
7679
Environment variables:
7780
- `LOCALSTACK_AUTH_TOKEN` - Auth token (skips browser login if set)
7881
- `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+
This routes `az` through LocalStack using an isolated 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/az.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
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/endpoint"
13+
"github.com/localstack/lstk/internal/env"
14+
"github.com/localstack/lstk/internal/output"
15+
"github.com/localstack/lstk/internal/terminal"
16+
"github.com/spf13/cobra"
17+
)
18+
19+
func newAzCmd(cfg *env.Env) *cobra.Command {
20+
return &cobra.Command{
21+
Use: "az [args...]",
22+
Short: "Run Azure CLI commands against LocalStack",
23+
Long: `Proxy Azure CLI commands to the LocalStack Azure emulator.
24+
25+
Runs 'az <args>' with an isolated AZURE_CONFIG_DIR routed through LocalStack's
26+
HTTPS proxy, so your global ~/.azure configuration is left untouched and plain
27+
'az' commands keep talking to real Azure.
28+
29+
Run 'lstk setup azure' once before using this command.
30+
31+
Examples:
32+
lstk az group list
33+
lstk az storage account list`,
34+
DisableFlagParsing: true,
35+
PreRunE: initConfig(nil),
36+
RunE: func(cmd *cobra.Command, args []string) error {
37+
appCfg, err := config.Get()
38+
if err != nil {
39+
return fmt.Errorf("failed to get config: %w", err)
40+
}
41+
42+
azureContainer := config.ContainerConfig{Type: config.EmulatorAzure, Port: config.DefaultAWSPort}
43+
for _, c := range appCfg.Containers {
44+
if c.Type == config.EmulatorAzure {
45+
azureContainer = c
46+
break
47+
}
48+
}
49+
50+
sink := output.NewPlainSink(os.Stdout)
51+
52+
configDir, err := config.ConfigDir()
53+
if err != nil {
54+
return fmt.Errorf("failed to resolve config directory: %w", err)
55+
}
56+
azureConfigDir := azureconfig.ConfigDir(configDir)
57+
if !azureconfig.IsSetUp(azureConfigDir) {
58+
sink.Emit(output.ErrorEvent{
59+
Title: "Azure CLI integration is not set up",
60+
Actions: []output.ErrorAction{
61+
{Label: "Set it up:", Value: "lstk setup azure"},
62+
},
63+
})
64+
return output.NewSilentError(fmt.Errorf("azure CLI integration not set up"))
65+
}
66+
67+
host, _ := endpoint.ResolveHost(cmd.Context(), azureContainer.Port, cfg.LocalStackHost)
68+
endpointURL := azureconfig.BuildEndpoint(host)
69+
70+
if err := azureconfig.IsRunning(cmd.Context(), endpointURL); err != nil {
71+
sink.Emit(output.ErrorEvent{
72+
Title: fmt.Sprintf("%s is not running", azureContainer.DisplayName()),
73+
Actions: []output.ErrorAction{
74+
{Label: "Start LocalStack:", Value: "lstk"},
75+
{Label: "See help:", Value: "lstk -h"},
76+
},
77+
})
78+
return output.NewSilentError(fmt.Errorf("%s is not running", azureContainer.Name()))
79+
}
80+
81+
proxyEndpoint, err := azureconfig.ProxyEndpoint(cmd.Context(), endpointURL)
82+
if err != nil {
83+
return fmt.Errorf("could not discover LocalStack proxy: %w", err)
84+
}
85+
azEnv := azureconfig.ProxyEnv(azureConfigDir, proxyEndpoint, azureconfig.CACertPath(azureConfigDir))
86+
87+
stdout, stderr := io.Writer(os.Stdout), io.Writer(os.Stderr)
88+
if terminal.IsTerminal(os.Stderr) {
89+
s := terminal.NewSpinner(os.Stderr, "Loading service...", 4*time.Second)
90+
s.Start()
91+
defer s.Stop()
92+
stdout = &terminal.StopOnWriteWriter{W: os.Stdout, Spinner: s}
93+
stderr = &terminal.StopOnWriteWriter{W: os.Stderr, Spinner: s}
94+
}
95+
96+
return azurecli.Exec(cmd.Context(), azEnv, os.Stdin, stdout, stderr, args...)
97+
},
98+
}
99+
}

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: 24 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,25 @@ 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+
configDir, err := config.ConfigDir()
57+
if err != nil {
58+
return fmt.Errorf("failed to resolve config directory: %w", err)
59+
}
60+
61+
return ui.RunSetupAzure(cmd.Context(), appConfig.Containers, cfg.LocalStackHost, configDir)
62+
},
63+
}
64+
}

internal/azurecli/exec.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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+
// Exec runs `az <args...>`. extraEnv is appended to the inherited process environment
21+
// (later entries win), letting callers inject AZURE_CONFIG_DIR, proxy, and CA settings
22+
// without mutating the user's global Azure CLI configuration.
23+
func Exec(ctx context.Context, extraEnv []string, stdin io.Reader, stdout, stderr io.Writer, args ...string) error {
24+
ctx, span := otel.Tracer("github.com/localstack/lstk/internal/azurecli").Start(ctx, "az cli")
25+
defer span.End()
26+
27+
azBin, err := exec.LookPath("az")
28+
if err != nil {
29+
span.RecordError(err)
30+
span.SetStatus(codes.Error, err.Error())
31+
return ErrNotInstalled
32+
}
33+
34+
span.SetAttributes(attribute.StringSlice("az.args", args))
35+
36+
cmd := exec.CommandContext(ctx, azBin, args...)
37+
cmd.Stdin = stdin
38+
cmd.Stdout = stdout
39+
cmd.Stderr = stderr
40+
if len(extraEnv) > 0 {
41+
cmd.Env = append(os.Environ(), extraEnv...)
42+
}
43+
if err := cmd.Run(); err != nil {
44+
var exitErr *exec.ExitError
45+
if errors.As(err, &exitErr) {
46+
span.SetAttributes(attribute.Int("az.exit_code", exitErr.ExitCode()))
47+
span.SetStatus(codes.Error, "az cli exited non-zero")
48+
} else {
49+
span.RecordError(err)
50+
span.SetStatus(codes.Error, err.Error())
51+
}
52+
return err
53+
}
54+
return nil
55+
}
56+
57+
// Run executes `az <args...>` with extraEnv and returns the captured stdout, stderr,
58+
// and any error. On non-zero exit, the error wraps stderr to aid debugging.
59+
func Run(ctx context.Context, extraEnv []string, args ...string) (stdout, stderr string, err error) {
60+
var outBuf, errBuf bytes.Buffer
61+
runErr := Exec(ctx, extraEnv, nil, &outBuf, &errBuf, args...)
62+
stdout = outBuf.String()
63+
stderr = errBuf.String()
64+
if runErr != nil {
65+
var exitErr *exec.ExitError
66+
if errors.As(runErr, &exitErr) && stderr != "" {
67+
return stdout, stderr, fmt.Errorf("az %v: %w: %s", args, runErr, stderr)
68+
}
69+
return stdout, stderr, runErr
70+
}
71+
return stdout, stderr, nil
72+
}

0 commit comments

Comments
 (0)