Skip to content

feat(azure): add the localstack-azure-client tool and Azure lifecycle support - #86

Merged
HarshCasper merged 19 commits into
mainfrom
feat/azure-client
Oct 12, 2026
Merged

HarshCasper merged 19 commits into
mainfrom
feat/azure-client

Conversation

@DrisDary

@DrisDary DrisDary commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

What

Adds localstack-azure-client, which runs Azure CLI (az) commands against the LocalStack for Azure emulator. It also adds service: "azure" to localstack-management (start, stop, restart and status).

  • Profile: the tool runs the user's own az with a profile of its own (LOCALSTACK_AZ_CONFIG_DIR), logged in to the emulator with a dummy account. It never uses or changes the user's Azure login.
  • Commands:
    • Commands are split without a shell.
    • A short deny list refuses commands that would re-point or log out the profile, install software, or open browsers, shells and streams.
    • .. path traversal is refused, as in the AWS client.
    • A leading ~ means the user's home.
  • rest:
    • A management.azure.com URL becomes a relative path.
    • Any other absolute URL must name a local host.
  • Extensions come from the user's own extension directory.
  • Stopping az: it is stopped, with everything it started, on a timeout and on a client cancel.
  • Settings:
    • LOCALSTACK_AZURE_IMAGE_NAME, LOCALSTACK_AZ_PATH, LOCALSTACK_AZ_CONFIG_DIR, LOCALSTACK_AZ_WORKDIR and LOCALSTACK_AZ_TIMEOUT_SECONDS.
    • The tool reaches the emulator through LOCALSTACK_HOSTNAME and LOCALSTACK_PORT, as the other tools do. The README and server.json now document both.
  • Docker: the image does not include az yet. Inside it, the tool says so.

Tests

  • Unit tests for:
    • the policy and tokenizer, the settings and finding az;
    • the child environment, the runner and the profile bootstrap;
    • the emulator checks, the output, the preflights and the runtime status;
    • the tool itself, and service: azure in localstack-management.
  • tests/mcp/azure-offline.spec.mjs runs in mcp-direct-tests, without an emulator.
  • .github/workflows/azure-smoke.yml runs on demand and weekly. It starts the emulator with localstack-management and runs a short tour of az commands through the tool.

Not in this PR

Each of these comes later as its own small PR, with steps that reproduce the case it handles. The earlier full version is parked in draft #88, not for merge, and the follow-ups take their code from there.

  • Egress guard: keeps az from reaching hosts other than the emulator, with the threat model written down.
  • Docker image: az, Bicep and a loopback forwarder, so the Azure tool works inside the image.
  • requireStack: a clear answer when a tool meets another stack's emulator (AWS, Snowflake or Azure).
  • Lifecycle extras: the AWS and Azure emulators side by side, and settings kept across a restart.
  • Server stop: an az still running is stopped when the server itself stops.
  • Shared profile: servers that share one profile set it up one at a time.
  • Warm worker: faster az calls, with a benchmark script.
  • Shared tokenizer: one tokenizer for the AWS and Azure clients, and file containment if the team wants it.

Adds localstack-azure-client, a tool that runs the Azure CLI against the
LocalStack for Azure emulator (never real Azure), and extends
localstack-management to start, stop, restart and report the Azure
emulator.

- The tool runs the host's az (2.85 or newer) in an isolated CLI profile
  that is logged in to the emulator only. A policy refuses shell syntax,
  logins, profile changes, extension installs and commands that open a
  browser, shell or tunnel, and keeps file arguments inside the working
  directory and out of the user's credential folders. An egress guard
  rewrites absolute management.azure.com URLs and blocks every other
  host. Failures come back classified, with a hint. An experimental warm
  worker (LOCALSTACK_AZ_RUNNER=worker) keeps az imports loaded.
- localstack-management: the Azure stack's container spec and port
  checks. A restart carries an externally started container's own
  settings, and refuses a container this machine cannot recreate.
- Setup follows the Snowflake tool: the user installs the Azure CLI, and
  a missing az answers with the install commands. A new command,
  install-azure-addons, installs the pinned Azure CLI extensions and
  Bicep the tool uses. The init wizard is unchanged. The setup and the
  LOCALSTACK_AZ_* settings are documented in README.md and
  docs/DOCKER.md.
- The Docker image bundles az 2.90, the 26 pinned extensions and Bicep,
  with image assertions and a size gate.
- Tests: unit tests with an Azure coverage gate (90 % of lines), a live
  command matrix, the official Azure samples replayed through the tool,
  and model evals. New CI workflows: azure-live.yml and azure-weekly.yml.
- CODEOWNERS requests @localstack/smurf and @HarshCasper on the
  Azure-only paths. .gitattributes keeps shell scripts LF and .cmd
  files CRLF on every checkout.
Comments, docs and fixtures state facts without citing internal plans, reviews, checks, test runs or benchmarks. build-corpus.py now builds the samples corpus from the in-repo extract.py and bash_argv.py output (the same 736 cases), and scripts/extract-leak-commands.mjs is removed: its input was never in the repository. tests/azure/README.md defines the test layers the CI jobs name.
The README's Azure section keeps its notes on using the emulator with the other tools. The E2 evals describe their builders, variants and seven answer-reading pitfalls on their own terms, and recorded fixtures use neutral resource names.
…he token

Removes the weekly E2 job, the only one that needed an Anthropic API key. The emulator start stops as soon as the container exits and prints the licence reason. The ~/.azure fingerprint is taken before the emulator starts, the live path filter covers core, cli and the Azure fixtures, the weekly token is set per step, and the matrix YAML is pinned to LF.
File paths are checked as the OS opens them, analytics drop values stuck to short options, and the egress guard allows only the emulator's ports. The AWS client runs in the Snowflake container, stop and restart handle the Azure emulator beside an AWS one, and the MCP env block wins on restart. A venv Python in LOCALSTACK_AZ_PATH is run by its own path, and a Windows az behind any WSL mount is refused. Also fixes the warm worker's log level and cancel, LOCALSTACK_HOSTNAME and port hints, the tool description, server.json and fixture redaction, and removes internal references.
…cret

The Azure jobs and steps take their token from LOCALSTACK_AUTH_TOKEN_AZURE, else LOCALSTACK_AUTH_TOKEN. The smoke test runs its AWS and Snowflake stages with LOCALSTACK_AUTH_TOKEN and the Azure stage on its own with the Azure token. Also repairs four comments that an earlier cleanup left with stray punctuation.
… work

The Azure emulator shares files with the containers it starts for Function and Web Apps from /var/lib/localstack, and refuses to unless that folder is a bind mount. The CI start script and the internal-network job now bind-mount a runner folder there. Starting the Azure emulator on a named volume, as when this server runs in Docker, now says that app deployments need LOCALSTACK_VOLUME_DIR, and the Docker docs and README say so too.
One token whose licence covers AWS, Snowflake and Azure, with no fallback, so a gap in its licence fails the run instead of hiding behind LOCALSTACK_AUTH_TOKEN.
Its licence covers AWS, Snowflake and Azure, so the separate LOCALSTACK_AUTH_TOKEN_AZURE secret is gone.
@DrisDary
DrisDary requested review from a team, HarshCasper, paolosalvatori and remotesynth and removed request for HarshCasper October 5, 2026 12:37
@DrisDary DrisDary self-assigned this Oct 5, 2026
@DrisDary

DrisDary commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

Why #86 fails now when the same code passed on 29 Sep:

Nothing in our code changed. #86 runs the same commit as #79's last green run (1f12bf3), and main after the revert is byte-for-byte what it was before #79. Two things outside the repo changed:

  1. A new high-severity advisory for @grpc/grpc-js (GHSA-m9gg-hp2v-232j) was published on 30 Sep, the day after the green run. Our lockfile pins 1.14.4 (pulled in by dockerode), so the audit-ci --high step now fails. main pins the same version, so every PR hits this. Fix: bump it to 1.14.5 in yarn.lock, as a small separate PR to main.

  2. The outside service behind the docs-search tool (CrawlChat) isn't answering today, so the two tests that call it time out after 15 seconds: the MCP Server Tester docs test and the Docker smoke docs scenario. Nothing to fix in our code; they'll pass again once CrawlChat is back.

Everything else passes, including all the Azure live tests and the AWS and Snowflake scenarios.

audit-ci --high now fails on this advisory, published on 30 Sep. It covers 1.14.0 to 1.14.4, which dockerode pulled in. Lockfile only.
localstack-docs calls an external search service. When that service times out, refuses, or returns a 5xx, the direct test is skipped and the image harness warns, each with the tool's answer. A 4xx or any other wrong answer still fails.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The review found a test parse failure, analytics data-exposure risk, and an unbounded warm-worker output path.

Review effort: Balanced
Findings: 2 High severity

Open (2)
What changed in this PR

Adds Azure emulator tooling, lifecycle management, containment, installation helpers, and extensive multi-layer validation.

Changes:

  • Adds localstack-azure-client and Azure-aware management/preflight behavior.
  • Adds isolated Azure CLI/Bicep execution with policy and egress controls.
  • Expands unit, live, drift, Docker, sample, and evaluation coverage.
File Description
src/​lib/​azure/​* Azure client runtime, policy, isolation, installation, and tests
src/​tools/​localstack-*.ts Stack-aware preflight checks for existing tools
src/​cli/​* Azure add-ons command and help
src/​lib/​wizard/​* Azure extension/Bicep setup helpers and tests
src/​lib/​cli/​argv.ts Shared CLI tokenizer
src/​lib/​aws/​aws-cli-sanitizer.ts Uses shared tokenizer
src/​core/​analytics.ts Azure analytics fields
src/​tools-tests/​localstack-management-ports.test.ts Port-safe lifecycle tests
tests/​azure/​** Live matrix, drift, samples, evals, and utilities
tests/​fixtures/​azure/​** Azure CLI, worker, corpus, Bicep, and drift fixtures
tests/​mcp/​** Azure MCP catalogue, offline, and Gemini tests
tests/​docker/​** Image contents and size gates
scripts/​ci/​** Azure CI startup, catalogue merge, and secret scanning
data/​sample-azure/​** Azure harness sample resources
data/​evals/​gemini-azure.json Azure tool-trigger dataset
.github/​workflows/​ci.yml Cross-platform tests, coverage, and spawn smoke tests
.github/​actions/​azure-live-setup/​action.yml Reusable Azure live-test setup
.github/​CODEOWNERS Azure path ownership
server.json Azure environment configuration
manifest.json Azure tool and prompt registration
package.json Azure test scripts and development dependencies
playwright.config.mjs Isolated offline Azure MCP project
jest.config.js Azure coverage and test TypeScript configuration
jest.azure-live.config.js Azure live-test projects
tsconfig.tests.json Test-code type checking
docker/​azure-extensions.txt Pinned Azure CLI extensions
docker/​image-size.json Recorded image-size baseline
.gitattributes Cross-platform line-ending rules
.gitignore Generated Azure test artifact exclusions
.prettierignore Generated Azure fixture exclusions

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/lib/azure/policy.test.ts Outdated
Comment thread src/lib/azure/policy.ts Outdated
command_path took every leading word-shaped token, so a positional value (az find <term>) or an unknown command could reach analytics. It now keeps a word only if az's command table has it. scripts/gen-az-file-args.py --command-words generates the list (az 2.90.0 with the 26 curated extensions: 7,048 commands, 1,592 words), and DR4 checks it on every pin move. The property test now also tries secrets passed as positional values.

@HarshCasper HarshCasper left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I read the whole diff rather than sampling it, so this first round is about scope only. The reason #79 was reverted still applies here: at +77,071 / -264 across 224 files I can't review this change in a way that means anything. For comparison, the AWS client landed in about 330 lines (#3) and the Snowflake client in 235 (#21), and all of src/ on main is 14,695 lines. The tool itself is a good idea and parts of the implementation are careful. The problem is everything built around it.

What I'm asking for in this round: cut the PR down, on this same branch, to the tool, the service: "azure" change in localstack-management, and their unit tests. Under 3,000 lines including tests is the target. Put everything that comes out on a separate branch or a draft PR so it isn't lost; most of it can come back later in smaller PRs, each with the evidence it needs. Round 2 will be an ordinary correctness review of what remains.

What stays

Most of this already exists in the PR and only needs trimming.

  • src/tools/localstack-azure-client.ts without the version special case, the test envelope and requireStack.
  • bootstrap.ts keeps CLI_CONFIG, DUMMY_LOGIN, cloudConfigJson, the five az calls and the marker file. The lock directory, the lease files, the version-check seed and the self-heal can go.
  • runner.ts and child-env.ts keep the spawn core (it fixes real problems in command-runner.ts) and the allow-list environment. The semaphore, the Windows command-line length pre-check and the kill-step hooks can go.
  • resolve-az.ts keeps LOCALSTACK_AZ_PATH, a PATH walk, the az.cmd to python.exe mapping and the version probe. The launcher-text parsing per installer, the WSL guard and the local az version answer can go.
  • policy.ts keeps the shared tokenizer, an exact-match deny list of command prefixes and flags (login, logout, account clear, cloud, config, extension, upgrade, interactive, find, feedback, the browser and tunnel commands), the management.azure.com rewrite, and the file rule for existing files under the workdir. The two generated JSON tables, scripts/gen-az-file-args.py and leak-commands.json can go.
  • emulator.ts and runtime-status.ts as they are, minus the guard-off branch.
  • output.ts cut down to: stdout on exit 0; otherwise stderr with the traceback frames stripped, a size cap, and the not-implemented and missing-extension hints. About 80 lines instead of 794 plus 1,100 lines of fixtures.
  • The tool's configuration moved from src/core/config.ts into src/lib/azure, with these settings only: LOCALSTACK_AZURE_PORT, LOCALSTACK_AZURE_ENDPOINT, LOCALSTACK_AZURE_IMAGE_NAME, LOCALSTACK_AZ_PATH, LOCALSTACK_AZ_CONFIG_DIR, LOCALSTACK_AZ_WORKDIR, LOCALSTACK_AZ_TIMEOUT_SECONDS.
  • In localstack-management: the image name, forwarding of LS_AZURE_*, the volume note.
  • Unit tests for the above, trimmed with the code, plus tests/mcp/azure-offline.spec.mjs.
  • README: a short setup section and rows for the seven settings.

What comes out, and why

  1. Tests of the emulator, az and the samples repo. tests/azure/matrix (393 cases over 29 provider files, 61 dated known_gap entries, an operation catalogue the README says is for the portal), the drift gates, the samples shim and replay, the 16,831-line corpus, the egress-internal experiment, and the E2 harness in tests/azure/evals (11,888 lines; a Claude agent loop with a spend cap). All of these measure the emulator, az, the samples repo or model behaviour. The PR description makes the point that new emulator coverage reaches users without a code change here; DR6 then requires a new YAML file in this repo for every provider the emulator adds, and the weekly job opens an issue when one is missing. That dependency points the wrong way. A per-service matrix, if the team wants one, belongs in the emulator repo and should drive az directly. What this repo needs is one short live smoke, around ten commands through the tool, on workflow_dispatch and a weekly schedule.

  2. Snapshots of az internals that only the weekly job keeps honest. az-command-words.generated.json and az-file-args.generated.json (2,926 lines generated from azure-cli internals), 26 extension pins taken against 2.90.0, the Bicep version and sha256 repeated in the Dockerfile, ci.yml, the composite action, docker.yml and bicep-install.ts, 43 stderr fixtures recorded on Windows from one az version, and about 35 regexes over az stderr wording behind 25 failure classes in output.ts. The AWS client has two regexes. Every one of these goes stale as soon as az changes, and nothing in the normal CI run catches it.

  3. Changes to code every AWS user runs.

    • src/core/preflight.ts and src/core/config.ts now import from src/lib/azure, and every tool imports preflight, so each tool's lazy chunk carries the Azure library. I built both commits: per-tool chunks go from about 190 KB to 320 KB and the unpacked package from 3.4 MB to 5.6 MB. The Azure preflights and config need to live under src/lib/azure.
    • requireStack in twelve tools. For the Azure tool it repeats the edition check emulator.ts already does, through a second HTTP client. For the AWS tools I couldn't construct a case where it helps; it was behind one of the HIGH findings on #79; and with an AWS container running it now refuses localstack-snowflake-client, where main would at least run snow. Take it out of this PR. If there's a case for it, it can be a PR of its own.
    • The container lookup changes (stack filtering, the published-port rule) and the side-by-side mode (LOCALSTACK_AZURE_PORT, a restart that carries an external container's env, the unmountable-bind check). Side-by-side also has a loop today: with LOCALSTACK_AZURE_PORT set and no Azure emulator, status and the tool both say to run start, and start refuses with "Conflicting Azure port settings". Each of these can come back on its own with a scenario that fails without it.
  4. CI and process. ci.yml moves every PR to a three-OS matrix with a 90% coverage gate. azure-live.yml starts a real emulator on each PR (11 minutes, plus 16 for the samples job on this run). azure-weekly.yml has eight jobs and files issues assigned to me. CODEOWNERS adds the team and me to every Azure path. And the push job in docker.yml now depends on the one LOCALSTACK_AUTH_TOKEN being entitled for Azure, so no image publishes when it isn't. For this PR: ci.yml stays as on main, one dispatch-plus-weekly smoke job replaces the two workflows, no CODEOWNERS, no auto-filed issues.

  5. The Docker image goes from 232 MB to 418 MB compressed, 110 MB of it the Bicep binary, for every user of the image. Whether to bundle az and Bicep at all is a separate decision. Take it out of this PR together with the loopback forwarder (306 lines), which exists only because the health checks dial 127.0.0.1; inside the image the upstream host can be LOCALSTACK_HOSTNAME, as for the other tools.

  6. The warm worker: worker-runner.ts, worker-script.ts and the fake-worker fixture, 1,074 lines with Python kept inside a TypeScript string. It is off by default, no CI job runs it against a real az, and the README's "about 5x faster" has no benchmark in the repo. It can come back with one.

  7. install-azure-addons and what sits behind it: extension-install.ts, bicep-install.ts, the wizard steps, scripts/install-azure-extensions.mjs, about 2,200 lines in all. The tool creates its own need for this: child-env.ts points AZURE_EXTENSION_DIR at a private directory and the bootstrap sets extension.use_dynamic_install=no, so whatever the user has installed with az extension add is invisible. Let the child inherit the user's extension directory read-only, keep the hint map in extension-map.ts (about 95 lines), and document Bicep with brew, winget or az bicep install.

  8. The egress guard, egress-proxy.ts (676 lines plus 953 of tests). The profile holds a dummy login and every cloud endpoint points at the emulator, so a request that did reach real Azure would carry no credential. A guard can still come in later, as its own PR, in the CONNECT-only form (around 180 lines) and with the threat model written down.

Smaller items that leave with the above: LOCALSTACK_AZ_DENYLIST_FILE, _MAX_HELP_CHARS, _RUNNER, _PYCACHE_DIR, _TEST_ENVELOPE, _FORWARD_TARGET, _EGRESS_GUARD, _BICEP_ENV, _EXTENSION_DIR and _BICEP_PATH, which takes server.json from 37 settings to about 27; the test-only envelope in the handler, which only the live suites read; and the tool description, which is 1,634 characters (the next largest tool's is 261) and embeds the server's absolute working directory. Three or four static sentences are enough there.

After the cut

Round 2 is a normal review of what's left. The rest can follow as separate PRs in whatever order suits the team: the egress guard with its threat model, the extension and Bicep handling, image bundling, the lifecycle extras, requireStack, and the worker with a benchmark. Each should be small enough to review in one sitting. If anything in this list is unclear, ask here and I'll expand on it.

@DrisDary

DrisDary commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor Author

I read the whole diff rather than sampling it, so this first round is about scope only. The reason #79 was reverted still applies here: at +77,071 / -264 across 224 files I can't review this change in a way that means anything. For comparison, the AWS client landed in about 330 lines (#3) and the Snowflake client in 235 (#21), and all of src/ on main is 14,695 lines. The tool itself is a good idea and parts of the implementation are careful. The problem is everything built around it.

What I'm asking for in this round: cut the PR down, on this same branch, to the tool, the service: "azure" change in localstack-management, and their unit tests. Under 3,000 lines including tests is the target. Put everything that comes out on a separate branch or a draft PR so it isn't lost; most of it can come back later in smaller PRs, each with the evidence it needs. Round 2 will be an ordinary correctness review of what remains.

What stays

Most of this already exists in the PR and only needs trimming.

  • src/tools/localstack-azure-client.ts without the version special case, the test envelope and requireStack.
  • bootstrap.ts keeps CLI_CONFIG, DUMMY_LOGIN, cloudConfigJson, the five az calls and the marker file. The lock directory, the lease files, the version-check seed and the self-heal can go.
  • runner.ts and child-env.ts keep the spawn core (it fixes real problems in command-runner.ts) and the allow-list environment. The semaphore, the Windows command-line length pre-check and the kill-step hooks can go.
  • resolve-az.ts keeps LOCALSTACK_AZ_PATH, a PATH walk, the az.cmd to python.exe mapping and the version probe. The launcher-text parsing per installer, the WSL guard and the local az version answer can go.
  • policy.ts keeps the shared tokenizer, an exact-match deny list of command prefixes and flags (login, logout, account clear, cloud, config, extension, upgrade, interactive, find, feedback, the browser and tunnel commands), the management.azure.com rewrite, and the file rule for existing files under the workdir. The two generated JSON tables, scripts/gen-az-file-args.py and leak-commands.json can go.
  • emulator.ts and runtime-status.ts as they are, minus the guard-off branch.
  • output.ts cut down to: stdout on exit 0; otherwise stderr with the traceback frames stripped, a size cap, and the not-implemented and missing-extension hints. About 80 lines instead of 794 plus 1,100 lines of fixtures.
  • The tool's configuration moved from src/core/config.ts into src/lib/azure, with these settings only: LOCALSTACK_AZURE_PORT, LOCALSTACK_AZURE_ENDPOINT, LOCALSTACK_AZURE_IMAGE_NAME, LOCALSTACK_AZ_PATH, LOCALSTACK_AZ_CONFIG_DIR, LOCALSTACK_AZ_WORKDIR, LOCALSTACK_AZ_TIMEOUT_SECONDS.
  • In localstack-management: the image name, forwarding of LS_AZURE_*, the volume note.
  • Unit tests for the above, trimmed with the code, plus tests/mcp/azure-offline.spec.mjs.
  • README: a short setup section and rows for the seven settings.

What comes out, and why

  1. Tests of the emulator, az and the samples repo. tests/azure/matrix (393 cases over 29 provider files, 61 dated known_gap entries, an operation catalogue the README says is for the portal), the drift gates, the samples shim and replay, the 16,831-line corpus, the egress-internal experiment, and the E2 harness in tests/azure/evals (11,888 lines; a Claude agent loop with a spend cap). All of these measure the emulator, az, the samples repo or model behaviour. The PR description makes the point that new emulator coverage reaches users without a code change here; DR6 then requires a new YAML file in this repo for every provider the emulator adds, and the weekly job opens an issue when one is missing. That dependency points the wrong way. A per-service matrix, if the team wants one, belongs in the emulator repo and should drive az directly. What this repo needs is one short live smoke, around ten commands through the tool, on workflow_dispatch and a weekly schedule.

  2. Snapshots of az internals that only the weekly job keeps honest. az-command-words.generated.json and az-file-args.generated.json (2,926 lines generated from azure-cli internals), 26 extension pins taken against 2.90.0, the Bicep version and sha256 repeated in the Dockerfile, ci.yml, the composite action, docker.yml and bicep-install.ts, 43 stderr fixtures recorded on Windows from one az version, and about 35 regexes over az stderr wording behind 25 failure classes in output.ts. The AWS client has two regexes. Every one of these goes stale as soon as az changes, and nothing in the normal CI run catches it.

  3. Changes to code every AWS user runs.

    • src/core/preflight.ts and src/core/config.ts now import from src/lib/azure, and every tool imports preflight, so each tool's lazy chunk carries the Azure library. I built both commits: per-tool chunks go from about 190 KB to 320 KB and the unpacked package from 3.4 MB to 5.6 MB. The Azure preflights and config need to live under src/lib/azure.
    • requireStack in twelve tools. For the Azure tool it repeats the edition check emulator.ts already does, through a second HTTP client. For the AWS tools I couldn't construct a case where it helps; it was behind one of the HIGH findings on feat(azure): add the localstack-azure-client tool and Azure lifecycle support #79; and with an AWS container running it now refuses localstack-snowflake-client, where main would at least run snow. Take it out of this PR. If there's a case for it, it can be a PR of its own.
    • The container lookup changes (stack filtering, the published-port rule) and the side-by-side mode (LOCALSTACK_AZURE_PORT, a restart that carries an external container's env, the unmountable-bind check). Side-by-side also has a loop today: with LOCALSTACK_AZURE_PORT set and no Azure emulator, status and the tool both say to run start, and start refuses with "Conflicting Azure port settings". Each of these can come back on its own with a scenario that fails without it.
  4. CI and process. ci.yml moves every PR to a three-OS matrix with a 90% coverage gate. azure-live.yml starts a real emulator on each PR (11 minutes, plus 16 for the samples job on this run). azure-weekly.yml has eight jobs and files issues assigned to me. CODEOWNERS adds the team and me to every Azure path. And the push job in docker.yml now depends on the one LOCALSTACK_AUTH_TOKEN being entitled for Azure, so no image publishes when it isn't. For this PR: ci.yml stays as on main, one dispatch-plus-weekly smoke job replaces the two workflows, no CODEOWNERS, no auto-filed issues.

  5. The Docker image goes from 232 MB to 418 MB compressed, 110 MB of it the Bicep binary, for every user of the image. Whether to bundle az and Bicep at all is a separate decision. Take it out of this PR together with the loopback forwarder (306 lines), which exists only because the health checks dial 127.0.0.1; inside the image the upstream host can be LOCALSTACK_HOSTNAME, as for the other tools.

  6. The warm worker: worker-runner.ts, worker-script.ts and the fake-worker fixture, 1,074 lines with Python kept inside a TypeScript string. It is off by default, no CI job runs it against a real az, and the README's "about 5x faster" has no benchmark in the repo. It can come back with one.

  7. install-azure-addons and what sits behind it: extension-install.ts, bicep-install.ts, the wizard steps, scripts/install-azure-extensions.mjs, about 2,200 lines in all. The tool creates its own need for this: child-env.ts points AZURE_EXTENSION_DIR at a private directory and the bootstrap sets extension.use_dynamic_install=no, so whatever the user has installed with az extension add is invisible. Let the child inherit the user's extension directory read-only, keep the hint map in extension-map.ts (about 95 lines), and document Bicep with brew, winget or az bicep install.

  8. The egress guard, egress-proxy.ts (676 lines plus 953 of tests). The profile holds a dummy login and every cloud endpoint points at the emulator, so a request that did reach real Azure would carry no credential. A guard can still come in later, as its own PR, in the CONNECT-only form (around 180 lines) and with the threat model written down.

Smaller items that leave with the above: LOCALSTACK_AZ_DENYLIST_FILE, _MAX_HELP_CHARS, _RUNNER, _PYCACHE_DIR, _TEST_ENVELOPE, _FORWARD_TARGET, _EGRESS_GUARD, _BICEP_ENV, _EXTENSION_DIR and _BICEP_PATH, which takes server.json from 37 settings to about 27; the test-only envelope in the handler, which only the live suites read; and the tool description, which is 1,634 characters (the next largest tool's is 261) and embeds the server's absolute working directory. Three or four static sentences are enough there.

After the cut

Round 2 is a normal review of what's left. The rest can follow as separate PRs in whatever order suits the team: the egress guard with its threat model, the extension and Bicep handling, image bundling, the lifecycle extras, requireStack, and the worker with a benchmark. Each should be small enough to review in one sitting. If anything in this list is unclear, ask here and I'll expand on it.

Thanks @HarshCasper for the thorough review, I'm happy to cut the PR down to the scope you described but there are 4 points I'd like to settle first:

  1. LOCALSTACK_AZURE_PORT: with side-by-side mode gone, a second port setting is what causes the status/start loop you found. I'd drop it and have the Azure tool use LOCALSTACK_PORT, as the Snowflake tool does (one emulator at a time). If you'd rather keep it, I'll make start launch the Azure emulator on that port so status and start agree.

  2. Analytics: the generated command-word list is what keeps command_path free of user values (Copilot's finding on this PR: az find <term> recorded the term). Without it, I'd drop command_path and keep flag_names and policy_outcome, which are value-free.

  3. File rule: keeping only "existing files under the workdir" leaves downloads and other write targets unchecked, since the file doesn't exist yet (e.g. storage blob download --file <anywhere>, download-batch --destination <anywhere>). I'd keep a short hand-reviewed list of those write flags (about ten), not generated from az, so they stay inside the workdir. OK, or would you rather leave that for round 2?

  4. Shared tokenizer: keeping it also keeps the AWS sanitizer calling it (+7/-58 in aws-cli-sanitizer.ts, behaviour unchanged for AWS). Keep that in this PR, or leave the AWS file as on main?

Everything that comes out goes to a parked branch with a draft PR, so it can come back in smaller PRs with evidence.

@HarshCasper

Copy link
Copy Markdown
Member

Thanks, all four are fair questions. Here is where I land on each.

  1. Drop LOCALSTACK_AZURE_PORT. The Azure tool reads LOCALSTACK_PORT, as the Snowflake tool does, one emulator at a time. That makes it six settings instead of seven, and the status/start loop goes away on its own. The setting can come back with side-by-side mode in its own PR. While you are in there, the health URL can be built from LOCALSTACK_HOSTNAME and LOCALSTACK_PORT like LOCALSTACK_BASE_URL in config.ts, instead of 127.0.0.1. That is also what makes the forwarder unnecessary inside the image.

  2. Record command the way the AWS client does: the same allow-list entry in analytics.ts, the same 200-character cut. That drops command_path, flag_names and policy_outcome, and the word list with them. The value-free guarantee was stricter than anything the repo does for the other clients, and I would rather have one rule for both clients than a catalogue of az internals to keep in sync. Copilot's finding goes away with the field.

  3. No write-flag list in this PR. For file arguments, match the AWS client: refuse .. traversal and otherwise let az do what the user asked. That also replaces the existing-file rule I listed under what stays, so the policy module gets smaller than I described. If the team wants read or write containment later, it comes as its own PR with the cases it defends against.

  4. Leave aws-cli-sanitizer.ts as on main. Put the tokenizer the Azure tool needs under src/lib/azure for now, with the three options it turns on. Once this PR is in, send the extraction as its own PR. With the AWS test file unchanged it is a quick review, and both clients share one tokenizer from then on.

The parked branch with a draft PR is the right way to keep the rest. Go ahead with the cut.

@DrisDary
DrisDary marked this pull request as draft October 8, 2026 19:26
@DrisDary

DrisDary commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks @HarshCasper, the cut is pushed. This PR is now the Azure tool, service: "azure" in localstack-management, and their tests: +3,062 −25 across 42 files, a third of it tests. 44 of those lines (the lockfile bumps and the docs-outage tolerance) are also in a separate PR to main, so they drop out once that merges, leaving about 3,040.

Per your review

  • CI is back to main's workflows. The live, weekly and per-service jobs are gone. A small azure-smoke.yml (on demand and weekly) starts the emulator through localstack-management and runs about ten az commands through the tool.
  • The Dockerfile, docs/DOCKER.md and the image harness are main's. The image ships the tool but no az yet, and the tool says so there.
  • core/preflight.ts, core/config.ts, docker.client.ts, the CLI and the 11 non-Azure tools are main's again, and requireStack is gone. The Azure settings and preflights live in src/lib/azure/, so the other tools' chunks are back to their previous size.
  • Removed: the egress guard, the warm worker, the extension and Bicep installers and pins, the generated tables, the corpus, leak and spawn tests, the live suites and fixtures, the evals and the Anthropic SDK.
  • D1: one emulator at a time. LOCALSTACK_AZURE_PORT is gone. The tool uses LOCALSTACK_PORT, and its health URL is built from LOCALSTACK_HOSTNAME and LOCALSTACK_PORT like LOCALSTACK_BASE_URL. Six settings remain.
  • D2: analytics records command with the same allow-list entry as the AWS client.
  • D3: no write-flag list and no existing-file rule. .. traversal is refused as in the AWS client; the rest is up to az.
  • D4: aws-cli-sanitizer.ts is untouched. The Azure tokenizer is src/lib/azure/argv.ts, with its three options always on.
  • Extensions come from the user's own extension directory (az extension add). A command from a missing extension gets a hint naming it.

Small things I kept, so you can push back on them

  • A Windows az found through WSL is skipped. WSL doesn't forward AZURE_CONFIG_DIR, so the bootstrap would rewrite the user's real Windows profile.
  • LOCALSTACK_AZ_CONFIG_DIR may not overlap ~/.azure or AZURE_CONFIG_DIR, for the same reason.
  • The emulator check resolves the endpoint's name once per emulator session. Without the guard, az resolves *.localhost.localstack.cloud itself, and some routers block those answers (DNS-rebinding protection), so the check names the cause instead of a TLS or connection error.
  • status with service: snowflake now also names a running Azure container, as it already did for an AWS one.
  • An az still running when the server stops is killed with it: on exit (the client closed stdin) and, on POSIX, on SIGTERM, SIGINT or SIGHUP while a command runs. Without it, a command the client gave up on keeps running and changing the emulator: start a long command (group wait --created --timeout 120), close the client, and the az process is still there.
  • Servers that share the default config dir (two clients, or several sessions) set the profile up one at a time, behind a lock directory. az cloud and az config rewrite the same config file, so concurrent bootstraps drop each other's settings: start three servers on one fresh profile and send each a command, and most fail at az cloud register or az login. The bootstrap now also registers or updates the cloud in one step, so it makes one az call fewer.

These two and their tests are why the total sits about 40 lines over 3,000 once the main PR merges. If you'd rather keep this PR under it, I'll move them into the first follow-up.

Parked: the full version is in draft #88, not for merge.

Still to do after this merges
Follow-ups, each its own small PR with steps that reproduce the case it handles:

  1. The egress guard (CONNECT-only), with the threat model written down: a connection string given in a command can send az's data-plane requests, with the file's content, to any host.
  2. The Docker image with az, Bicep and the loopback forwarder. Inside the image, the ARM and data-plane names resolve to 127.0.0.1, and the emulator's certificate isn't valid for host.docker.internal. Plus the image-size trade-off.
  3. requireStack, as tests that fail on main: with only the Azure emulator running, the AWS client answers "Unknown error"; with an AWS emulator running, the Snowflake client reports a licence error.
  4. Lifecycle extras (side-by-side mode, carrying env across a restart), each with the scenario that fails without it.
  5. The warm worker, with a benchmark script in the PR.
  6. One tokenizer shared by the AWS and Azure clients, and file containment if the team wants it.

@DrisDary
DrisDary marked this pull request as ready for review October 8, 2026 19:42
@DrisDary
DrisDary requested a review from HarshCasper October 8, 2026 19:42

@HarshCasper HarshCasper left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 2. I read the whole diff again and ran the tool, the management actions and the unit and MCP suites on 78fc944. The trimmed PR is what round 1 asked for: the eight groups are out, the four decisions are in, the shared files are main's apart from the two Azure branches, and the per-tool chunks are back to their previous size. Thanks for the quick turnaround and for listing what you kept.

This round has three parts: things to fix before merge, pieces that are not ready to ship in this PR, and smaller items. The inline comments carry the details and line numbers; this body is the summary plus the items that have no line to attach to.

Fix before merge

  1. rest is an open HTTP client. The policy rewrites management.azure.com and passes every other absolute URL through, and with .. as the only file rule, rest --method post --url http://<host>/x --body @/absolute/path posts any readable file to any host. I ran it with az 2.70, an isolated profile and a local listener: the policy allowed it, az exited 0, the listener received the file. No emulator and no credential involved. A short check in the rest branch closes it, and openWorldHint should match whichever way you go. This is not the parked egress guard; it is the rest rule finishing what it started. (policy.ts:163)
  2. The HTTPS readiness probe still dials 127.0.0.1 while the health URL now follows LOCALSTACK_HOSTNAME. In the Docker setup from docs/DOCKER.md, status service: azure reports the HTTPS endpoint as not ready on every call and start waits the extra 10 s. (emulator.ts:144)
  3. The container lookup does not recognise the image this PR launches. The regex at docker.client.ts:158 knows localstack-azure-alpha only, so an Azure container not named localstack-main is invisible to stop, restart and the "other stack is running" message. One word, localstack-azure(?:-alpha)?, plus one image in the docker.client test. It is a one-word fix in the line main added for Azure, so I am fine with it touching that file.
  4. LOCALSTACK_AZURE_ENDPOINT accepts localhost, 127.0.0.1 and ::1, and the README and the test use https://localhost:4566 as the example. The gateway certificate carries localhost.localstack.cloud names only, and the child environment drops AZURE_CLI_DISABLE_CONNECTION_VERIFICATION, so those values pass every check and then every az call fails TLS with no way out. With one emulator at a time there is no configuration in which the setting does something the default does not. Take it out of this PR until there is one. (config.ts:39)
  5. stderr is capped from the head and az prints its ERROR: line last, so with --debug the answer is 4,000 characters of log and no error. (output.ts:41)
  6. ~ in a command argument resolves under the tool's private HOME, so --template-file ~/infra/main.json fails and nothing says why. (child-env.ts:56)

Not ready to ship in this PR

Each of these has a defect, has no real test, or misbehaves in a working setup. They are better off in their own PRs, each with the scenario and the test that go with it:

  • The bootstrap lock directory. It breaks a live lock mid-bootstrap and can spin forever on a lock it cannot remove (inline comment).
  • The 2.85 floor. I ran every bootstrap call on az 2.70 and all of them work; nothing in the PR names a 2.85 feature, and the floor refuses working installs. The az version probe can go with it if you like.
  • The installed-extensions scan and its plumbing, which cannot change the hint (inline comment). The map itself is a copy of az 2.90's command table that nothing in CI keeps current; the inline comment has a simpler alternative.
  • LOCALSTACK_AZURE_ENDPOINT (item 4 above).
  • The exit and signal hooks, as you offered. They work, but their only test passes with the kill removed, and no other tool in the server does this.
  • Progress notifications, which no test drives.
  • The hunks that are also in #89, by rebasing on it, so they are reviewed once and cannot drift.

Of the things you kept, the WSL skip, the config-dir overlap check, the DNS check and the one-step cloud register stay; each is small and prevents something real. The second Playwright project stays too: the tester fixture reads its config from the project, so it is the only way to give the offline spec a dummy token and a free port.

Smaller items

Inline where there is a line: the .. rule with attached short-option values, the flag abbreviation rule, three missing deny entries, --version, the truncated-output title, the "within 0 s" wording, the child PATH when az is found off PATH, the LS_AZURE_ prefix, the volume note, the offline spec's skip guard, and the smoke workflow's az install. Without a line to attach to:

  • conflictResponse in localstack.utils.ts:355 still calls an Azure container "likely the AWS emulator"; stackFromImage gives the right label.
  • services.ts, runtime-status.ts and preflight.ts have no unit tests and the tool test mocks all three, so the version probe's failure and retry, the isRunning mapping, the licence refusal and the Docker-image message run under no test. A small test file for each covers it.
  • The bootstrap and the version probe do not receive extra.signal, so a cancelled first call leaves az login running for up to the timeout.
  • README and server.json never document LOCALSTACK_PORT and LOCALSTACK_HOSTNAME, which the Azure rows and the tool's own advice now lean on. Two rows, or reword the rows.

After this round

If the six fixes land and the pieces above move to their own PRs, round 3 is an approval pass. The follow-up list in your comment stands as written; the lock and the hooks join it with the reproductions you described.

Comment thread src/lib/azure/policy.ts
Comment thread src/lib/azure/policy.ts
Comment thread src/lib/azure/policy.ts Outdated
Comment thread src/lib/azure/policy.ts Outdated
Comment thread src/lib/azure/policy.ts Outdated
Comment thread src/lib/localstack/localstack.utils.ts Outdated
Comment thread src/lib/localstack/container-spec.logic.ts Outdated
Comment thread tests/docker/validate-image.mjs
Comment thread tests/mcp/azure-offline.spec.mjs Outdated
Comment thread .github/workflows/azure-smoke.yml Outdated
rest calls local hosts only; the HTTPS probe dials LOCALSTACK_HOSTNAME; the container lookup knows the Azure image; LOCALSTACK_AZURE_ENDPOINT is gone; stderr keeps its end; a leading ~ is the user's home.

Moved out to follow-ups: the bootstrap lock, the 2.85 floor, the extension scan and map, the exit and signal hooks, progress notifications. New tests for services, runtime-status and preflight.
@DrisDary

Copy link
Copy Markdown
Contributor Author

Thanks @HarshCasper, round 2 is pushed.

Fix before merge: all six are done.

  1. rest only calls local hosts; management.azure.com is still rewritten to a relative path.
  2. The HTTPS probe dials LOCALSTACK_HOSTNAME.
  3. The container lookup matches localstack-azure.
  4. LOCALSTACK_AZURE_ENDPOINT is removed.
  5. stderr keeps its end, so the ERROR: line survives --debug.
  6. A leading ~ means the user's home.

Moved out:

  • the lock;
  • the 2.85 floor (I kept the az version probe, because the profile is keyed on the version);
  • the extension scan and map;
  • the exit and signal hooks;
  • progress notifications.

The #89 hunks drop out when I merge main after #89 lands.

One exception: I kept detached. The timeout and cancel kills use the process group, and the Debian az is a bash script that starts Python as a child. Without the group, a timeout leaves Python running. The new runner test checks this and fails without detached.

Smaller items: all done, plus tests for services, runtime-status and preflight. Those tests are why the PR grew to about 3,260 lines (after #89).

@DrisDary
DrisDary requested a review from HarshCasper October 12, 2026 11:44
@DrisDary

DrisDary commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor Author

Hey @HarshCasper , round 2 on #86 is pushed and CI is green: #86
All your comments are addressed. The one open thread is detached in runner.ts, where I explained why I kept it. #89 is merged and #86 now sits on top of main. Ready for another look when you have time.

@HarshCasper HarshCasper left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 3. I read the diff again and ran it: the unit suite and the offline MCP spec pass here, and for each of the six fixes I reverted it in a scratch copy and watched its test fail, so the tests are real. Everything from round 2 is in: the six fixes, the pieces moved out, the smaller items, and the three new test files. The exception you kept, detached, is right: the Homebrew and Debian launchers are bash scripts that start Python without exec, and your new test shows the grandchild only dies with the process group. Thank you for the careful turnaround; this is the last round.

Before I approve

  1. The live smoke has never executed. The workflow is new, so GitHub only offers its manual trigger once the file is on main, and none of the three rounds had an emulator. It is the only test that drives the bootstrap against the emulator, the session keying, the dummy login and the rewritten rest URL. Please run yarn build && npx playwright test -c playwright.config.mjs tests/mcp/azure-smoke.spec.mjs once with an Azure-entitled token and paste the step output here. One command, and the only evidence the end-to-end path works.
  2. A narrow race in the runner: the timeout timer is not cleared when az exits, so a command that finishes inside the drain window just before the deadline, with a grandchild holding stdout, is reported as timed out and its output dropped. Reproduced with a stand-in; one line, inline.

Smaller items, inline

The -f~/x spelling the ~ expansion misses, two probe wordings, a few sentences of documentation (the tool description, the README hostname row, one about data-plane endpoints), four small test additions, and two test strings. Without a line to attach to: docs/DOCKER.md should say the image has no Azure CLI, so the Azure tool needs npx or source; only the README and the runtime answer say so today, and the image now lists 15 tools.

After this

On the next push I approve. The branch carries its whole history after the merge of main, so I will squash-merge. The follow-up list in your earlier comment stands; the data-plane egress guard stays the first of them, as agreed.

Comment thread .github/workflows/azure-smoke.yml
Comment thread src/lib/azure/runner.ts
Comment thread src/lib/azure/policy.ts
Comment thread src/lib/azure/services.ts Outdated
Comment thread src/tools/localstack-azure-client.ts
Comment thread src/lib/azure/runner.test.ts
Comment thread src/lib/azure/policy.test.ts Outdated
Comment thread src/lib/localstack/localstack.utils.test.ts Outdated
Comment thread src/lib/azure/child-env.ts Outdated
Comment thread src/lib/azure/bootstrap.ts
The runner's timeout no longer fires after az has exited; ~ after a short option is expanded; clearer az version probe answers; docs on ~, rest, data-plane endpoints, the hostname and the Docker image; more tests.
@DrisDary

Copy link
Copy Markdown
Contributor Author

Thanks @HarshCasper.

  1. Smoke run below: Linux, az 2.91, a fresh profile, and the emulator was already running. It also passes on Windows.
  2. The timer race is fixed, and your test is added.

The smaller items are done.

Smoke output
$ yarn build && npx playwright test -c playwright.config.mjs tests/mcp/azure-smoke.spec.mjs

Running 1 test using 1 worker

     1.1 [localstack-mcp-server] › tests/mcp/azure-smoke.spec.mjs:12:1 › az commands through the Azure tool › start the emulator (1.7s)
     1.2 [localstack-mcp-server] › tests/mcp/azure-smoke.spec.mjs:12:1 › az commands through the Azure tool › resource group (21.4s)
     1.3 [localstack-mcp-server] › tests/mcp/azure-smoke.spec.mjs:12:1 › az commands through the Azure tool › storage account, container and blob (20.5s)
     1.4 [localstack-mcp-server] › tests/mcp/azure-smoke.spec.mjs:12:1 › az commands through the Azure tool › key vault secret (2.5s)
     1.5 [localstack-mcp-server] › tests/mcp/azure-smoke.spec.mjs:12:1 › az commands through the Azure tool › rest with a management.azure.com URL (850ms)
     1.6 [localstack-mcp-server] › tests/mcp/azure-smoke.spec.mjs:12:1 › az commands through the Azure tool › a refused command (8ms)
     1.7 [localstack-mcp-server] › tests/mcp/azure-smoke.spec.mjs:12:1 › az commands through the Azure tool › delete the resource group (3.9s)
  ✓  1 [localstack-mcp-server] › tests/mcp/azure-smoke.spec.mjs:12:1 › az commands through the Azure tool (56.1s)

  1 passed (1.0m)

@DrisDary
DrisDary requested a review from HarshCasper October 12, 2026 15:49

@HarshCasper HarshCasper left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just last few comments!

Comment thread README.md Outdated
Comment thread README.md Outdated
@DrisDary

Copy link
Copy Markdown
Contributor Author

Thanks @HarshCasper. Both are done: line 47 is removed, and the Azure section is now docs/AZURE.md, linked from the README.

@DrisDary
DrisDary requested a review from HarshCasper October 12, 2026 17:13
@HarshCasper
HarshCasper merged commit adf003e into main Oct 12, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants