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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ 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` — Registers and activates the LocalStack custom cloud in `az`, disables instance discovery, and logs in with dummy service-principal credentials. 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`.
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,13 @@ 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 # LocalStack custom cloud in `az` (requires the Azure CLI)
```

This sets up a `localstack` profile in `~/.aws/config` and `~/.aws/credentials`.

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

```bash
Expand Down Expand Up @@ -196,6 +195,9 @@ lstk config path
# Set up AWS CLI profile integration
lstk setup aws

# Set up Azure CLI integration (LocalStack custom cloud)
lstk setup azure

```

## Reporting bugs
Expand Down
24 changes: 23 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,24 @@ func newSetupAWSCmd(cfg *env.Env) *cobra.Command {
},
}
}

func newSetupAzureCmd(cfg *env.Env) *cobra.Command {
return &cobra.Command{
Use: "azure",
Short: "Set up the LocalStack Azure custom cloud",
Long: "Register and activate the LocalStack custom cloud in the Azure CLI configuration, and log in with dummy service-principal credentials. 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)
}

if !isInteractiveMode(cfg) {
return fmt.Errorf("setup azure requires an interactive terminal")
}

return ui.RunSetupAzure(cmd.Context(), appConfig.Containers, cfg.LocalStackHost)
},
}
}
67 changes: 67 additions & 0 deletions internal/azurecli/exec.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
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...>` with the given stdout/stderr writers, inheriting stdin.
func Exec(ctx context.Context, 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 = os.Stdin
cmd.Stdout = stdout
cmd.Stderr = stderr
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...>` 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, args ...string) (stdout, stderr string, err error) {
var outBuf, errBuf bytes.Buffer
runErr := Exec(ctx, &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
}
214 changes: 214 additions & 0 deletions internal/azureconfig/azureconfig.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
package azureconfig

import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"

"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"

"github.com/localstack/lstk/internal/azurecli"
"github.com/localstack/lstk/internal/output"
)

const (
CloudName = "LocalStack"
DefaultCloudName = "AzureCloud"
AzureSubdomain = "azure"

// Dummy service principal credentials. The LocalStack Azure emulator does
// not validate these — any values that look like a service principal login
// are accepted.
servicePrincipalUser = "any-app"
servicePrincipalPass = "any-pass"
servicePrincipalTenant = "anytenant"
)

// BuildEndpoint returns the LocalStack Azure endpoint URL for the given host:port.
// The endpoint must use the "azure." subdomain so the LocalStack proxy routes
// requests to the Azure backend.
func BuildEndpoint(host string) string {
return "https://" + AzureSubdomain + "." + host
}

// IsRunning probes the LocalStack health endpoint at the given Azure endpoint.
func IsRunning(ctx context.Context, endpointURL string) error {
ctx, span := otel.Tracer("github.com/localstack/lstk/internal/azureconfig").Start(ctx, "azureconfig.IsRunning")
defer span.End()

url := strings.TrimRight(endpointURL, "/") + "/_localstack/health"
span.SetAttributes(attribute.String("azure.health_url", url))

req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status %d", resp.StatusCode)
}
return nil
}

// cloudExists reports whether the LocalStack cloud is already registered with `az`.
func cloudExists(ctx context.Context) (bool, error) {
stdout, _, err := azurecli.Run(ctx, "cloud", "list", "--query", "[].name", "--output", "json")
if err != nil {
return false, err
}
var names []string
if err := json.Unmarshal([]byte(stdout), &names); err != nil {
return false, fmt.Errorf("parsing az cloud list output: %w", err)
}
for _, n := range names {
if n == CloudName {
return true, nil
}
}
return false, nil
}

// buildCloudConfig produces the --cloud-config JSON payload for `az cloud register|update`.
// Trailing slashes match the format produced by the official Azure cloud config so
// that az appends paths without producing concatenated URLs.
func buildCloudConfig(endpointURL string) ([]byte, error) {
trimmed := strings.TrimRight(endpointURL, "/")
withSlash := trimmed + "/"
cfg := struct {
Endpoints map[string]string `json:"endpoints"`
}{
Endpoints: map[string]string{
"activeDirectory": trimmed,
"activeDirectoryResourceId": trimmed,
"activeDirectoryGraphResourceId": trimmed,
"management": withSlash,
"microsoftGraphResourceId": withSlash,
"resourceManager": withSlash,
"logAnalyticsResourceId": trimmed,
},
}
return json.Marshal(cfg)
}

// registerOrUpdate registers (or updates if it already exists) the LocalStack cloud.
func registerOrUpdate(ctx context.Context, endpointURL string, alreadyExists bool) error {
verb := "register"
if alreadyExists {
verb = "update"
}
cloudConfigJSON, err := buildCloudConfig(endpointURL)
if err != nil {
return err
}
_, _, err = azurecli.Run(ctx, "cloud", verb,
"--name", CloudName,
"--cloud-config", string(cloudConfigJSON),
)
return err
}

func setActiveCloud(ctx context.Context, name string) error {
_, _, err := azurecli.Run(ctx, "cloud", "set", "--name", name, "--only-show-errors")
return err
}

func disableInstanceDiscovery(ctx context.Context) error {
_, _, err := azurecli.Run(ctx, "config", "set", "core.instance_discovery=false", "--only-show-errors")
return err
}

func loginServicePrincipal(ctx context.Context) error {
_, _, err := azurecli.Run(ctx, "login", "--service-principal",
"-u", servicePrincipalUser,
"-p", servicePrincipalPass,
"--tenant", servicePrincipalTenant,
"--only-show-errors",
)
return err
}

// Setup runs the full Azure custom cloud setup flow against the given LocalStack
// Azure endpoint. It expects the emulator to be running and prompts the user for
// confirmation before mutating the local Azure CLI configuration.
func Setup(ctx context.Context, sink output.Sink, endpointURL string) error {
ctx, span := otel.Tracer("github.com/localstack/lstk/internal/azureconfig").Start(ctx, "azureconfig.Setup")
defer span.End()

if err := IsRunning(ctx, endpointURL); err != nil {
sink.Emit(output.MessageEvent{
Severity: output.SeverityWarning,
Text: fmt.Sprintf(
"LocalStack Azure emulator not reachable at %s. Start it with 'lstk start' before running 'lstk setup azure'.",
endpointURL,
),
})
return fmt.Errorf("emulator not reachable at %s: %w", endpointURL, err)
}

responseCh := make(chan output.InputResponse, 1)
sink.Emit(output.UserInputRequestEvent{
Prompt: "Set up the LocalStack Azure cloud in `az`?",
Options: []output.InputOption{{Key: "y", Label: "Y"}, {Key: "n", Label: "n"}},
ResponseCh: responseCh,
})

select {
case resp := <-responseCh:
if resp.Cancelled {
return nil
}
if resp.SelectedKey == "n" {
sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: "Skipped Azure cloud setup."})
return nil
}
case <-ctx.Done():
return ctx.Err()
}

exists, err := cloudExists(ctx)
if err != nil {
sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("could not list Azure clouds: %v", err)})
return err
}

verb := "Registering"
if exists {
verb = "Updating"
}
sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: fmt.Sprintf("%s %q cloud...", verb, CloudName)})
if err := registerOrUpdate(ctx, endpointURL, exists); err != nil {
sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("could not register Azure cloud: %v", err)})
return err
}

sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: "Activating cloud and disabling instance discovery..."})
if err := setActiveCloud(ctx, CloudName); err != nil {
sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("could not activate Azure cloud: %v", err)})
return err
}
if err := disableInstanceDiscovery(ctx); err != nil {
sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("could not disable instance discovery: %v", err)})
return err
}

sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: "Logging in with dummy service-principal credentials..."})
if err := loginServicePrincipal(ctx); err != nil {
sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("could not log in to Azure CLI: %v", err)})
return err
}

sink.Emit(output.MessageEvent{
Severity: output.SeveritySuccess,
Text: fmt.Sprintf("Set up %q Azure cloud at %s.", CloudName, endpointURL),
})
return nil
}
Loading
Loading