diff --git a/.github/workflows/azure-smoke.yml b/.github/workflows/azure-smoke.yml new file mode 100644 index 0000000..2c12447 --- /dev/null +++ b/.github/workflows/azure-smoke.yml @@ -0,0 +1,47 @@ +name: Azure smoke + +# The Azure tool against the LocalStack Azure emulator, weekly and on demand. +on: + workflow_dispatch: + schedule: + - cron: "0 6 * * 1" + +permissions: + contents: read + +jobs: + azure-smoke: + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + # The token's licence must include LocalStack for Azure, or the emulator refuses to start. + LOCALSTACK_AUTH_TOKEN: ${{ secrets.LOCALSTACK_AUTH_TOKEN }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Use Node.js 22 + uses: actions/setup-node@v4 + with: + node-version: 22.x + + - name: Install dependencies + run: yarn + + # ubuntu-latest ships the Azure CLI. + - name: Show the Azure CLI version + run: az version + + - name: Pull the LocalStack Azure emulator image + run: docker pull localstack/localstack-azure:latest + + - name: Run az commands through the Azure tool + run: yarn build && npx playwright test -c playwright.config.mjs tests/mcp/azure-smoke.spec.mjs + + - name: Keep the test results of a failed run + if: failure() + uses: actions/upload-artifact@v4 + with: + name: azure-smoke-results + path: .mcp-test-results + if-no-files-found: ignore diff --git a/README.md b/README.md index 4b22ac0..17a2f8c 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ Once the server is configured, talk to LocalStack through your agent in natural - "My Lambda calls are failing. Read the LocalStack logs, find the permission errors, and generate an IAM policy that fixes them." - "Inject 500ms of latency into DynamoDB and confirm my retry logic still works." - "Search the LocalStack docs for how to enable S3 event notifications and summarize the steps." +- "Start the LocalStack Azure emulator, create a resource group and a storage account, and upload `./data/sample.csv` to a new blob container." ## How it works @@ -42,7 +43,7 @@ The server connects MCP-compatible apps directly to your local LocalStack enviro This server eliminates custom scripts and manual LocalStack management. Your agent can: -- Start, stop, restart, and monitor LocalStack for AWS container status with built-in auth. +- Start, stop, restart, and monitor the LocalStack for AWS, Snowflake and Azure emulators with built-in auth. - Deploy CDK, Terraform, and SAM projects with automatic configuration detection. - Search LocalStack documentation for guides, API references, and configuration details. - Parse logs, catch errors, and auto-generate IAM policies from violations. @@ -64,7 +65,7 @@ This server provides your AI with dedicated tools for managing your LocalStack e | Tool Name | Description | Key Features | | :-------------------------------------------------------------------------------- | :------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [`localstack-management`](./src/tools/localstack-management.ts) | Manages LocalStack runtime operations for AWS and Snowflake stacks | - Execute start, stop, restart, and status checks
- Integrate LocalStack authentication tokens
- Inject custom environment variables
- Verify real-time status and perform health monitoring | +| [`localstack-management`](./src/tools/localstack-management.ts) | Manages LocalStack runtime operations for AWS, Snowflake and Azure stacks | - Execute start, stop, restart, and status checks
- Integrate LocalStack authentication tokens
- Inject custom environment variables
- Verify real-time status and perform health monitoring | | [`localstack-deployer`](./src/tools/localstack-deployer.ts) | Handles infrastructure deployment to LocalStack for AWS environments | - Automatically run CDK, Terraform, and SAM tooling to deploy infrastructure locally
- Enable parameterized deployments with variable support
- Process and present deployment results
- Requires you to have [`cdklocal`](https://github.com/localstack/aws-cdk-local), [`tflocal`](https://github.com/localstack/terraform-local), or [`samlocal`](https://github.com/localstack/aws-sam-cli-local) installed in your system path | | [`localstack-logs-analysis`](./src/tools/localstack-logs-analysis.ts) | Analyzes LocalStack for AWS logs for troubleshooting and insights | - Offer multiple analysis options including summaries, errors, requests, and raw data
- Filter by specific services and operations
- Generate API call metrics and failure breakdowns
- Group errors intelligently and identify patterns | | [`localstack-iam-policy-analyzer`](./src/tools/localstack-iam-policy-analyzer.ts) | Handles IAM policy management and violation remediation | - Set IAM enforcement levels including `enforced`, `soft`, and `disabled` modes
- Search logs for permission-related violations
- Generate IAM policies automatically from detected access failures
- Requires a valid LocalStack Auth Token | @@ -74,6 +75,7 @@ This server provides your AI with dedicated tools for managing your LocalStack e | [`localstack-extensions`](./src/tools/localstack-extensions.ts) | Installs, uninstalls, lists, and discovers LocalStack Extensions | - Manage installed extensions (`list`, `install`, `uninstall`) inside the running container
- Browse the LocalStack Extensions marketplace (`available`)
- Requires a valid LocalStack Auth Token | | [`localstack-ephemeral-instances`](./src/tools/localstack-ephemeral-instances.ts) | Manages cloud-hosted LocalStack Ephemeral Instances | - Create temporary cloud-hosted LocalStack instances and get an endpoint URL
- List available ephemeral instances, fetch logs, and delete instances
- Supports lifetime, extension preload, Cloud Pod preload, and custom env vars on create
- Requires a valid LocalStack Auth Token | | [`localstack-aws-client`](./src/tools/localstack-aws-client.ts) | Runs AWS CLI commands inside the LocalStack for AWS container | - Executes commands via `awslocal` inside the running container
- Sanitizes commands to block shell chaining
- Auto-detects LocalStack coverage errors and links to docs | +| [`localstack-azure-client`](./src/tools/localstack-azure-client.ts) | Runs Azure CLI (`az`) commands against the LocalStack for Azure emulator | - Runs the Azure CLI installed on your machine with its own profile, never your Azure login
- Blocks shell syntax and commands that would change the profile, install software or never end
- Uses the extensions you installed with `az extension add`
- Requires the Azure CLI and a valid LocalStack Auth Token | | [`localstack-aws-replicator`](./src/tools/localstack-aws-replicator.ts) | Replicates external AWS resources into a running LocalStack instance | - Start single-resource replication jobs with a resource type and identifier or ARN
- Start batch replication jobs, such as SSM parameters under a path prefix
- Poll job status by job ID and list existing jobs
- List resource types supported by the running Replicator extension
- Reads source AWS credentials from the MCP server environment and supports optional target account or region overrides | | [`localstack-app-inspector`](./src/tools/localstack-app-inspector.ts) | Inspects LocalStack application traces, spans, events, and IAM evaluations | - Enable or disable App Inspector for the running LocalStack instance
- List and inspect traces to understand AWS service-to-service flows
- Drill into spans, events, payload metadata, and IAM policy evaluation events
- Filter by service, region, operation, resource, ARN, status, and time range
- Requires a valid LocalStack Auth Token and the App Inspector feature in the connected LocalStack license | | [`localstack-docs`](./src/tools/localstack-docs.ts) | Searches LocalStack documentation through CrawlChat | - Queries LocalStack docs through a public CrawlChat collection
- Returns focused snippets with source links only
- Helps answer coverage, configuration, and setup questions without requiring LocalStack runtime | @@ -124,6 +126,7 @@ Run `npx -y @localstack/localstack-mcp-server init --help` for all options. - Docker installed and running. The MCP server manages the LocalStack container directly through the Docker API. - [`cdklocal`](https://github.com/localstack/aws-cdk-local), [`tflocal`](https://github.com/localstack/terraform-local), or [`samlocal`](https://github.com/localstack/aws-sam-cli-local) installed in your system path if you want to deploy CDK, Terraform, or SAM projects - Snowflake CLI (`snow`) installed in your system path if you want to use the Snowflake tool +- Azure CLI (`az`) installed in your system path if you want to use the Azure tool (see [docs/AZURE.md](./docs/AZURE.md)) - A [valid LocalStack Auth Token](https://docs.localstack.cloud/aws/getting-started/auth-token/) configured as `LOCALSTACK_AUTH_TOKEN` (**required for all MCP tools**) - [Node.js v20](https://nodejs.org/en/download/) or higher installed in your system path @@ -209,6 +212,8 @@ See **[docs/DOCKER.md](./docs/DOCKER.md)** for the run command, MCP client confi | Variable Name | Description | Default Value | | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- | | `LOCALSTACK_AUTH_TOKEN` (**required**) | The LocalStack Auth Token to use for the MCP server | None | +| `LOCALSTACK_HOSTNAME` | Host name of the LocalStack gateway the tools call; in Docker, `host.docker.internal`. The Azure tool always calls `azure.localhost.localstack.cloud` (127.0.0.1), so it needs a local gateway. | `localhost` | +| `LOCALSTACK_PORT` | Port of the LocalStack gateway the tools call. | `4566` | | `MAIN_CONTAINER_NAME` | The explicit LocalStack container name to use for Docker-based tools. When unset, the server auto-detects standard LocalStack containers such as `localstack-main` and `localstack-aws`, then LocalStack Docker images. | Auto-detect | | `LOCALSTACK_IMAGE_NAME` / `IMAGE_NAME` | Docker image the `start` action launches for the AWS stack. `LOCALSTACK_IMAGE_NAME` wins when both are set (beware: bare `IMAGE_NAME` is a common CI variable). | `localstack/localstack-pro:latest` | | `LOCALSTACK_VOLUME_DIR` | Host directory mounted at `/var/lib/localstack` in the LocalStack container. Defaults to the same per-OS cache path the LocalStack CLI used (state carries over); inside Docker it defaults to the `localstack-mcp` named volume. | Per-OS cache dir | @@ -221,6 +226,13 @@ See **[docs/DOCKER.md](./docs/DOCKER.md)** for the run command, MCP client confi | `AWS_ACCESS_KEY_ID` (**required for AWS Replicator tool**) | Source AWS access key used by AWS Replicator to read external AWS resources | None | | `AWS_SECRET_ACCESS_KEY` (**required for AWS Replicator tool**) | Source AWS secret access key used by AWS Replicator to read external AWS resources | None | | `AWS_DEFAULT_REGION` (**required for AWS Replicator tool**) | Source AWS region used by AWS Replicator | None | +| `LOCALSTACK_AZURE_IMAGE_NAME` | Docker image the `start` action launches for the Azure stack (default `localstack/localstack-azure:latest`). | Latest Azure image | +| `LOCALSTACK_AZ_PATH` | The `az` launcher, or the Azure CLI's Python, for the Azure tool. | Found on `PATH` | +| `LOCALSTACK_AZ_CONFIG_DIR` | The Azure tool's own Azure CLI profile directory (default `~/.localstack/azure/mcp-config-`); it must not overlap your `~/.azure`. | Under `~/.localstack/azure` | +| `LOCALSTACK_AZ_WORKDIR` | The directory the Azure tool runs `az` in; relative file paths resolve against it. | The server's working directory | +| `LOCALSTACK_AZ_TIMEOUT_SECONDS` | Time limit for one Azure CLI command (5–3600). | `300` | + +See **[docs/AZURE.md](./docs/AZURE.md)** for how the Azure tool runs the Azure CLI. ### Migration notes (CLI-free lifecycle) diff --git a/data/sample-azure/hello.txt b/data/sample-azure/hello.txt new file mode 100644 index 0000000..6b85822 --- /dev/null +++ b/data/sample-azure/hello.txt @@ -0,0 +1 @@ +hello from the LocalStack MCP server Azure stage diff --git a/docs/AZURE.md b/docs/AZURE.md new file mode 100644 index 0000000..37d0944 --- /dev/null +++ b/docs/AZURE.md @@ -0,0 +1,11 @@ +# Using the Azure tool + +`localstack-azure-client` runs the Azure CLI (`az`) installed on your machine against the LocalStack for Azure emulator; start the emulator with `localstack-management` (`service: azure`). Its settings are listed in the [README](../README.md#localstack-configuration). + +- The tool keeps its own Azure CLI profile (`LOCALSTACK_AZ_CONFIG_DIR`), logged in to the emulator with a dummy account, so your own Azure login is never used or changed. +- It uses your Azure CLI extensions: install one with `az extension add --name `. +- Bicep templates need the `bicep` binary on your `PATH` (for example `brew install bicep` or `winget install Microsoft.Bicep`). +- Relative file paths in commands resolve against `LOCALSTACK_AZ_WORKDIR`, by default the directory the MCP client starts the server in. +- `rest` calls the emulator: give it a relative URL. An absolute URL must name a local host; a `management.azure.com` URL is turned into a relative one. +- Only `rest` URLs are checked: an endpoint given in a command, such as `--blob-endpoint` or a connection string, is used as it is. +- The Docker image does not include the Azure CLI yet: run the server with npx or from source to use the Azure tool. diff --git a/docs/DOCKER.md b/docs/DOCKER.md index 0bff983..96657c8 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -125,6 +125,10 @@ alias covers bootstrap asset uploads. on the host even if your editor restarts the MCP container — reconnecting finds your stack still up. State persists in the `localstack-mcp` named volume (or `LOCALSTACK_VOLUME_DIR` if you set one). +- **No Azure CLI.** The image lists all 15 tools, but it does not include the Azure + CLI, so `localstack-azure-client` answers that `az` is not available. To use the + Azure tool, run the server with npx or from source. `localstack-management` can + still start and stop the Azure emulator (`service: azure`) from the image. ## Troubleshooting diff --git a/manifest.json b/manifest.json index 75afc29..c8ac9b6 100644 --- a/manifest.json +++ b/manifest.json @@ -67,6 +67,10 @@ "name": "localstack-aws-client", "description": "Runs AWS CLI commands inside the running LocalStack container" }, + { + "name": "localstack-azure-client", + "description": "Runs Azure CLI (az) commands against the LocalStack for Azure emulator" + }, { "name": "localstack-aws-replicator", "description": "Replicates external AWS resources into a running LocalStack instance using the AWS Replicator HTTP API" @@ -156,6 +160,12 @@ "arguments": ["description"], "text": "Please query the LocalStack container for ${arguments.description}." }, + { + "name": "localstack-azure-client", + "description": "Runs Azure CLI commands against the LocalStack Azure emulator", + "arguments": ["description"], + "text": "Please query the LocalStack Azure emulator for ${arguments.description}." + }, { "name": "aws-replicator-start", "description": "Start an AWS Replicator job", diff --git a/package.json b/package.json index 5878b32..88ba437 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "prepack": "yarn build", "format": "prettier --write .", "test": "jest", - "test:mcp:direct": "yarn build && playwright test -c playwright.config.mjs tests/mcp/direct.spec.mjs", + "test:mcp:direct": "yarn build && playwright test -c playwright.config.mjs tests/mcp/direct.spec.mjs tests/mcp/azure-offline.spec.mjs", "test:mcp:evals": "yarn build && playwright test -c playwright.config.mjs tests/mcp/evals-gemini.spec.mjs", "test:mcp": "yarn test:mcp:direct && yarn test:mcp:evals" }, diff --git a/playwright.config.mjs b/playwright.config.mjs index f1123b7..d8f07d8 100644 --- a/playwright.config.mjs +++ b/playwright.config.mjs @@ -1,10 +1,19 @@ import { defineConfig } from "@playwright/test"; +import net from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; const mcpCommand = process.env.MCP_TEST_COMMAND || "node"; const mcpArgs = process.env.MCP_TEST_ARGS ? process.env.MCP_TEST_ARGS.split(" ").filter(Boolean) : ["dist/cli.js"]; +// A port where nothing listens, for the Azure tool's tests without an emulator. +const probe = net.createServer(); +await new Promise((resolve) => probe.listen(0, "127.0.0.1", resolve)); +const freePort = probe.address().port; +await new Promise((resolve) => probe.close(resolve)); + export default defineConfig({ testDir: "./tests/mcp", timeout: 120000, @@ -23,6 +32,7 @@ export default defineConfig({ projects: [ { name: "localstack-mcp-server", + testIgnore: /azure-offline\.spec\.mjs$/, use: { mcpConfig: { transport: "stdio", @@ -40,5 +50,30 @@ export default defineConfig({ }, }, }, + { + // The Azure tool with no emulator: a dummy token, a port where nothing listens and a + // config dir of its own. + name: "azure-offline", + testMatch: /azure-offline\.spec\.mjs$/, + use: { + mcpConfig: { + transport: "stdio", + command: mcpCommand, + args: mcpArgs, + cwd: process.cwd(), + quiet: true, + connectTimeoutMs: 30000, + requestTimeoutMs: 300000, + callTimeoutMs: 300000, + env: { + ...process.env, + LOCALSTACK_AUTH_TOKEN: "ls-dummy-token-for-offline-tests", + LOCALSTACK_PORT: String(freePort), + LOCALSTACK_AZ_CONFIG_DIR: join(tmpdir(), "lsmcp-azure-offline"), + MCP_ANALYTICS_DISABLED: "1", + }, + }, + }, + }, ], }); diff --git a/server.json b/server.json index 88bbe54..ddd5473 100644 --- a/server.json +++ b/server.json @@ -157,6 +157,55 @@ "format": "string", "isSecret": false, "name": "EXTERNAL_SERVICE_PORTS_END" + }, + { + "description": "Host name of the LocalStack gateway the tools call. Defaults to localhost; when the server runs in Docker, host.docker.internal.", + "isRequired": false, + "format": "string", + "isSecret": false, + "name": "LOCALSTACK_HOSTNAME" + }, + { + "description": "Port of the LocalStack gateway the tools call. Defaults to 4566.", + "isRequired": false, + "format": "string", + "isSecret": false, + "name": "LOCALSTACK_PORT" + }, + { + "description": "Docker image the management start action launches for service: azure. Defaults to localstack/localstack-azure:latest.", + "isRequired": false, + "format": "string", + "isSecret": false, + "name": "LOCALSTACK_AZURE_IMAGE_NAME" + }, + { + "description": "The az launcher, or the Azure CLI's Python, for localstack-azure-client. Defaults to the first az on PATH.", + "isRequired": false, + "format": "string", + "isSecret": false, + "name": "LOCALSTACK_AZ_PATH" + }, + { + "description": "The Azure tool's own Azure CLI profile directory; it must not overlap ~/.azure. Defaults to ~/.localstack/azure/mcp-config-.", + "isRequired": false, + "format": "string", + "isSecret": false, + "name": "LOCALSTACK_AZ_CONFIG_DIR" + }, + { + "description": "The directory localstack-azure-client runs az in; relative file paths resolve against it. Defaults to the server's working directory.", + "isRequired": false, + "format": "string", + "isSecret": false, + "name": "LOCALSTACK_AZ_WORKDIR" + }, + { + "description": "Time limit in seconds for one Azure CLI command (5-3600). Defaults to 300.", + "isRequired": false, + "format": "string", + "isSecret": false, + "name": "LOCALSTACK_AZ_TIMEOUT_SECONDS" } ] } diff --git a/src/core/analytics.ts b/src/core/analytics.ts index 522b50e..c3a5090 100644 --- a/src/core/analytics.ts +++ b/src/core/analytics.ts @@ -19,6 +19,7 @@ const SHUTDOWN_TIMEOUT_MS = 1000; export const TOOL_ARG_ALLOWLIST: Record = { "localstack-aws-client": ["command"], + "localstack-azure-client": ["command"], "localstack-aws-replicator": [ "action", "replication_type", diff --git a/src/lib/azure/argv.ts b/src/lib/azure/argv.ts new file mode 100644 index 0000000..2a55162 --- /dev/null +++ b/src/lib/azure/argv.ts @@ -0,0 +1,84 @@ +/** + * Quote-aware splitting of an `az` command string into an argv array. No shell is ever involved: + * the result goes to spawn as an argument vector, so this reproduces POSIX word splitting for a + * safe subset and refuses anything that would need a shell (pipes, chaining, redirects, + * substitutions). + * + * It is the AWS client's splitter with three changes that `az` commands need (JMESPath queries, + * empty values, JSON in double quotes): + * - newline, CR and backtick are refused only outside quotes; inside quotes they are data; + * - `""` and `''` produce an empty argument instead of disappearing; + * - inside double quotes, `\$`, `` \` `` and `\` behave as in bash. + * + * As there, an unquoted backslash is literal (Windows paths work), `$VAR` is passed literally, and + * an unquoted `#word` is an argument. + */ + +export class AzSyntaxError extends Error { + constructor(message: string) { + super(message); + this.name = "AzSyntaxError"; + } +} + +export function splitAzArgs(command: string): string[] { + const args: string[] = []; + let current = ""; + // True once the current word has started, even if it holds no characters yet (""). + let wordStarted = false; + let quote: "'" | '"' | undefined; + + const endWord = () => { + if (wordStarted) args.push(current); + current = ""; + wordStarted = false; + }; + + for (let index = 0; index < command.length; index++) { + const character = command[index]; + + if (!quote && (character === "\n" || character === "\r" || character === "`")) { + throw new AzSyntaxError("Command contains forbidden shell syntax."); + } + + if (character === "\\" && quote === '"') { + const escaped = command[index + 1]; + if (escaped === '"' || escaped === "\\" || escaped === "$" || escaped === "`") { + current += escaped; + index++; + } else if (escaped === "\n") { + index++; // a line continuation: bash drops both characters + } else { + current += character; + } + continue; + } + + if ((character === "'" || character === '"') && (!quote || quote === character)) { + quote = quote ? undefined : character; + wordStarted = true; + continue; + } + + if (!quote) { + if ( + ";&|<>".includes(character) || + command.startsWith("$(", index) || + command.startsWith("${", index) + ) { + throw new AzSyntaxError("Command contains forbidden shell syntax."); + } + if (/\s/.test(character)) { + endWord(); + continue; + } + } + + current += character; + wordStarted = true; + } + + if (quote) throw new AzSyntaxError("Command contains an unterminated quote."); + endWord(); + return args; +} diff --git a/src/lib/azure/bootstrap.test.ts b/src/lib/azure/bootstrap.test.ts new file mode 100644 index 0000000..d3bcf41 --- /dev/null +++ b/src/lib/azure/bootstrap.test.ts @@ -0,0 +1,116 @@ +import { mkdtempSync, readFileSync } from "fs"; +import os from "os"; +import path from "path"; +import { + BootstrapError, + CLI_CONFIG, + ensureProfile, + MARKER_FILE, + type BootstrapTarget, +} from "./bootstrap"; +import type { AzRunOptions, AzRunResult } from "./runner"; + +const ok = (stdout = ""): AzRunResult => ({ + exitCode: 0, + stdout, + stderr: "", + timedOut: false, + aborted: false, + truncated: false, +}); + +function fakeAz(answers: Record = {}) { + const calls: string[][] = []; + const key = (argv: string[]) => (argv[0] === "login" ? "login" : argv.slice(0, 2).join(" ")); + const run = jest.fn(async (argv: string[]) => { + calls.push(argv); + return answers[key(argv)] ?? ok(); + }); + return { run, calls, steps: () => calls.map(key) }; +} + +const target = (over: Partial = {}): BootstrapTarget => ({ + configDir: mkdtempSync(path.join(os.tmpdir(), "lsmcp-bootstrap-")), + endpoint: "https://azure.localhost.localstack.cloud:4566", + sessionId: "s1", + azVersion: "2.91.0", + cwd: os.tmpdir(), + timeoutMs: 60_000, + ...over, +}); + +describe("ensureProfile", () => { + test("four az calls point the profile at the emulator, then the marker is written", async () => { + const az = fakeAz(); + const t = target(); + await ensureProfile(t, az.run); + expect(az.steps()).toEqual(["cloud register", "cloud set", "config set", "login"]); + const register = az.calls[0]; + expect( + JSON.parse(register[register.indexOf("--cloud-config") + 1]).endpoints.resourceManager + ).toBe(`${t.endpoint}/`); + expect(az.calls[2]).toEqual(["config", "set", ...CLI_CONFIG, "--only-show-errors"]); + expect(az.calls[3]).toContain("--service-principal"); + expect(JSON.parse(readFileSync(path.join(t.configDir, MARKER_FILE), "utf8"))).toEqual({ + endpoint: t.endpoint, + sessionId: "s1", + azVersion: "2.91.0", + }); + }); + + test("a cloud registered already (by an earlier run or another server) is updated", async () => { + const taken = { + ...ok(), + exitCode: 1, + stderr: "ERROR: The cloud 'LocalStack' is already registered.", + }; + const az = fakeAz({ "cloud register": taken }); + await ensureProfile(target(), az.run); + expect(az.steps()).toEqual([ + "cloud register", + "cloud update", + "cloud set", + "config set", + "login", + ]); + }); + + test("concurrent calls share one run; it runs again only for another session", async () => { + const t = target(); + const az = fakeAz(); + await Promise.all([ensureProfile(t, az.run), ensureProfile(t, az.run)]); + await ensureProfile(t, az.run); + expect(az.calls).toHaveLength(4); + await ensureProfile({ ...t, sessionId: "s2" }, az.run); + expect(az.calls).toHaveLength(8); + }); + + test("a cancelled call stops the run it started; a call sharing it that did not cancel runs it again", async () => { + const t = target(); + const cancel = new AbortController(); + const run = jest.fn(async (_argv: string[], opts: AzRunOptions): Promise => { + if (opts.signal !== cancel.signal) return ok(); + cancel.abort(); // the client cancels while the first step runs + return { ...ok(), exitCode: null, aborted: true }; + }); + const first = ensureProfile(t, run, cancel.signal).catch((error) => error); + const second = ensureProfile(t, run); + expect(await first).toMatchObject({ step: "cloud register", result: { aborted: true } }); + await second; + expect(run).toHaveBeenCalledTimes(5); // the cancelled step, then a whole run for the second call + expect(readFileSync(path.join(t.configDir, MARKER_FILE), "utf8")).toContain("s1"); + }); + + test("a failed step throws with az's output and leaves no marker, so the next call retries", async () => { + const t = target(); + const failed = { ...ok(), exitCode: 1, stderr: "ERROR: bad" }; + const az = fakeAz({ login: failed }); + const error = await ensureProfile(t, az.run).catch((e) => e); + expect(error).toBeInstanceOf(BootstrapError); + expect(error).toMatchObject({ step: "login", result: failed }); + expect(() => readFileSync(path.join(t.configDir, MARKER_FILE))).toThrow(); + const retry = fakeAz(); + await ensureProfile(t, retry.run); + expect(retry.calls).toHaveLength(4); + }); +}); diff --git a/src/lib/azure/bootstrap.ts b/src/lib/azure/bootstrap.ts new file mode 100644 index 0000000..8f83b93 --- /dev/null +++ b/src/lib/azure/bootstrap.ts @@ -0,0 +1,150 @@ +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; +import path from "path"; +import type { AzRunOptions, AzRunResult } from "./runner"; + +/** + * The tool's own Azure CLI profile. Four `az` calls point its config dir at the emulator: + * `cloud register` (or `update`), `cloud set`, `config set` and a dummy `login`. The bootstrap is + * their only caller; the policy refuses them to the agent. A marker records the endpoint, emulator + * session and az version the profile was made for; when one changes (a restarted emulator has + * lost the dummy login), the bootstrap runs again. Concurrent calls in one server share one run. + */ + +export const LOCALSTACK_CLOUD = "LocalStack"; + +/** The CLI settings, set in one call. */ +export const CLI_CONFIG = [ + "core.instance_discovery=false", + "core.collect_telemetry=false", + "output.show_survey_link=no", + // A command from an extension that is not installed fails instead of installing it. + "extension.use_dynamic_install=no", + // az sends failed command names, with their parameter names, to app.aladdin.microsoft.com. + "core.error_recommendation=off", + "core.output=json", + // Removes `vm create`'s region cost notice. + "core.display_region_identified=false", + // Bicep from PATH, and no aka.ms version lookups or downloads. + "bicep.use_binary_from_path=true", + "bicep.check_version=false", + "auto-upgrade.enable=no", +]; + +export const DUMMY_LOGIN = + "login --service-principal -u any-app -p any-pass --tenant anytenant --only-show-errors".split( + " " + ); + +export function cloudConfigJson(endpoint: string): string { + return JSON.stringify({ + endpoints: { + activeDirectory: endpoint, + activeDirectoryResourceId: endpoint, + activeDirectoryGraphResourceId: endpoint, + management: `${endpoint}/`, + microsoftGraphResourceId: `${endpoint}/`, + resourceManager: `${endpoint}/`, + logAnalyticsResourceId: endpoint, + }, + }); +} + +export const MARKER_FILE = "localstack-mcp-bootstrap.json"; + +export interface BootstrapTarget { + configDir: string; + endpoint: string; + sessionId?: string; + azVersion?: string; + cwd: string; + timeoutMs: number; +} + +export type RunAz = (argv: string[], opts: AzRunOptions) => Promise; + +/** A bootstrap step failed; `result` carries az's output. */ +export class BootstrapError extends Error { + constructor( + readonly step: string, + readonly result: AzRunResult + ) { + super( + `Setting up the tool's Azure CLI profile failed at \`az ${step}\` (exit ${result.exitCode}).` + ); + this.name = "BootstrapError"; + } +} + +const markerFor = (target: BootstrapTarget) => + JSON.stringify({ + endpoint: target.endpoint, + sessionId: target.sessionId ?? null, + // An upgraded az sets the profile up again, in case it reads an older az's profile differently. + azVersion: target.azVersion ?? null, + }); + +function readMarker(configDir: string): string | undefined { + try { + return readFileSync(path.join(configDir, MARKER_FILE), "utf8"); + } catch { + return undefined; + } +} + +const inFlight = new Map>(); + +/** + * Sets the profile up for this target, unless its marker says it already is. Concurrent calls + * share one run, which stops when the call that started it is cancelled; the others start again. + */ +export async function ensureProfile( + target: BootstrapTarget, + run: RunAz, + signal?: AbortSignal +): Promise { + mkdirSync(target.configDir, { recursive: true, mode: 0o700 }); + const marker = markerFor(target); + const key = `${target.configDir}\n${marker}`; + for (;;) { + if (readMarker(target.configDir) === marker) return; + let job = inFlight.get(key); + if (!job) { + job = runBootstrap(target, run, marker, signal).finally(() => inFlight.delete(key)); + inFlight.set(key, job); + } + try { + return await job; + } catch (error) { + const cancelled = error instanceof BootstrapError && error.result.aborted; + if (!cancelled || signal?.aborted) throw error; + } + } +} + +async function runBootstrap( + target: BootstrapTarget, + run: RunAz, + marker: string, + signal?: AbortSignal +): Promise { + const opts: AzRunOptions = { timeoutMs: target.timeoutMs, cwd: target.cwd, signal }; + // The marker is written last, so a partial bootstrap leaves none and the next call starts over. + rmSync(path.join(target.configDir, MARKER_FILE), { force: true }); + const step = async (name: string, argv: string[]) => { + const result = await run(argv, opts); + if (result.exitCode !== 0) throw new BootstrapError(name, result); + }; + // The cloud exists after an earlier bootstrap, or when another server registered it a moment ago. + const cloud = ["--name", LOCALSTACK_CLOUD, "--cloud-config", cloudConfigJson(target.endpoint)]; + const registered = await run(["cloud", "register", ...cloud, "--only-show-errors"], opts); + if (registered.exitCode !== 0) { + if (!/is already registered/.test(registered.stderr)) { + throw new BootstrapError("cloud register", registered); + } + await step("cloud update", ["cloud", "update", ...cloud, "--only-show-errors"]); + } + await step("cloud set", ["cloud", "set", "--name", LOCALSTACK_CLOUD, "--only-show-errors"]); + await step("config set", ["config", "set", ...CLI_CONFIG, "--only-show-errors"]); + await step("login", DUMMY_LOGIN); + writeFileSync(path.join(target.configDir, MARKER_FILE), marker, { mode: 0o600 }); +} diff --git a/src/lib/azure/child-env.test.ts b/src/lib/azure/child-env.test.ts new file mode 100644 index 0000000..2992b0f --- /dev/null +++ b/src/lib/azure/child-env.test.ts @@ -0,0 +1,72 @@ +import { buildAzChildEnv } from "./child-env"; + +const config = { + configDir: "/cfg", + homeDir: "/cfg/home", + tmpDir: "/cfg/tmp", + extensionDir: "/home/u/.azure/cliextensions", +}; +const launcher = { file: "/usr/bin/az", prefixArgs: [] }; + +describe("buildAzChildEnv", () => { + test("an allow-list: nothing that points az elsewhere reaches it", () => { + const parent = { + PATH: "/usr/bin", + LANG: "en_US.UTF-8", + LC_TIME: "C", + HOME: "/home/u", + AZURE_CONFIG_DIR: "/home/u/.azure", + AZURE_CLIENT_SECRET: "s", + ARM_SUBSCRIPTION_ID: "x", + HTTPS_PROXY: "http://proxy:3128", + LOCALSTACK_AUTH_TOKEN: "ls-secret", + }; + expect(buildAzChildEnv(parent, "linux", config, launcher)).toEqual({ + PATH: "/usr/bin", + LANG: "en_US.UTF-8", + LC_TIME: "C", + HOME: "/cfg/home", + USERPROFILE: "/cfg/home", + TEMP: "/cfg/tmp", + TMP: "/cfg/tmp", + TMPDIR: "/cfg/tmp", + AZURE_CONFIG_DIR: "/cfg", + AZURE_EXTENSION_DIR: "/home/u/.azure/cliextensions", + AZURE_CORE_COLLECT_TELEMETRY: "no", + AZURE_CORE_NO_COLOR: "1", + }); + }); + + test("a launcher without a UTF-8 locale gets C.UTF-8", () => { + expect(buildAzChildEnv({}, "linux", config, launcher).LC_ALL).toBe("C.UTF-8"); + const python = { file: "/opt/az/bin/python3", prefixArgs: ["-IBm"] }; + expect(buildAzChildEnv({}, "linux", config, python).LC_ALL).toBeUndefined(); + }); + + test("az found off PATH: its directory is added, so bicep beside it is found", () => { + const brew = { file: "/opt/homebrew/bin/az", prefixArgs: [] }; + expect(buildAzChildEnv({ PATH: "/usr/bin:/bin" }, "darwin", config, brew).PATH).toBe( + "/usr/bin:/bin:/opt/homebrew/bin" + ); + expect(buildAzChildEnv({}, "darwin", config, brew).PATH).toBe("/opt/homebrew/bin"); + }); + + test("Windows: case-insensitive names, the whole private home set, and AZ_INSTALLER", () => { + const home = "C:\\cfg\\home"; + const env = buildAzChildEnv( + { Path: "C:\\Windows", SystemRoot: "C:\\Windows", APPDATA: "C:\\Users\\u\\AppData" }, + "win32", + { ...config, homeDir: home, tmpDir: "C:\\cfg\\tmp" }, + { file: "C:\\az\\python.exe", prefixArgs: ["-IBm", "azure.cli"], azInstaller: "MSI" } + ); + expect(env).toMatchObject({ + PATH: "C:\\Windows", + SYSTEMROOT: "C:\\Windows", + USERPROFILE: home, + HOMEDRIVE: "C:", + HOMEPATH: "\\cfg\\home", + APPDATA: `${home}\\AppData\\Roaming`, + AZ_INSTALLER: "MSI", + }); + }); +}); diff --git a/src/lib/azure/child-env.ts b/src/lib/azure/child-env.ts new file mode 100644 index 0000000..a7916d2 --- /dev/null +++ b/src/lib/azure/child-env.ts @@ -0,0 +1,89 @@ +import { mkdirSync } from "fs"; +import path from "path"; +import type { AzureConfig } from "./config"; +import type { AzExecutable } from "./resolve-az"; + +/** + * The environment `az` runs with. It is built from an allow-list, never from a copy of the + * server's environment: `AZURE_*`, `ARM_*`, `MSI_*` and the proxy and CA variables would point + * `az` at real Azure or at another profile. Its home and temp directories are private ones inside + * the tool's config dir, so commands that write to `~` (`--generate-ssh-keys`, + * `aks get-credentials`) write there instead of into the user's home. + */ + +type ChildConfig = Pick; + +const INHERITED = ["PATH", "SYSTEMROOT", "SYSTEMDRIVE", "WINDIR", "COMSPEC", "LANG"]; +const INHERITED_WINDOWS = [ + "PATHEXT", + "USERNAME", + "COMPUTERNAME", + "NUMBER_OF_PROCESSORS", + "PROCESSOR_ARCHITECTURE", +]; + +const appData = (homeDir: string) => ({ + roaming: path.win32.join(homeDir, "AppData", "Roaming"), + local: path.win32.join(homeDir, "AppData", "Local"), +}); + +/** The config dir and the private home and temp in it, owner-only. */ +export function ensurePrivateDirs(config: ChildConfig, platform: NodeJS.Platform): void { + const dirs = [config.configDir, config.homeDir, config.tmpDir]; + if (platform === "win32") dirs.push(...Object.values(appData(config.homeDir))); + for (const dir of dirs) mkdirSync(dir, { recursive: true, mode: 0o700 }); +} + +export function buildAzChildEnv( + parent: NodeJS.ProcessEnv, + platform: NodeJS.Platform, + config: ChildConfig, + az: Pick +): Record { + const env: Record = {}; + const win = platform === "win32"; + for (const name of win ? [...INHERITED, ...INHERITED_WINDOWS] : INHERITED) { + const key = win ? Object.keys(parent).find((k) => k.toUpperCase() === name) : name; + const value = key === undefined ? undefined : parent[key]; + if (value !== undefined) env[name] = value; + } + // POSIX: `az` found off PATH (LOCALSTACK_AZ_PATH, or an install location when a GUI client starts + // the server with a short PATH): its directory joins PATH, so `bicep` installed beside it is found. + if (!win) { + const dir = path.posix.dirname(az.file); + const entries = (env.PATH ?? "").split(":").filter(Boolean); + if (!entries.includes(dir)) env.PATH = [...entries, dir].join(":"); + } + for (const [name, value] of Object.entries(parent)) { + if (/^LC_[A-Z]+$/.test(name) && value !== undefined) env[name] = value; + } + + // Always set: on Windows libuv copies HOMEDRIVE, HOMEPATH, TEMP, USERPROFILE and the rest of + // its required set from the parent when the block lacks them, which would undo the private home. + env.HOME = config.homeDir; + env.USERPROFILE = config.homeDir; + env.TEMP = config.tmpDir; + env.TMP = config.tmpDir; + if (win) { + env.HOMEDRIVE = path.win32.parse(config.homeDir).root.replace(/[\\/]$/, ""); + env.HOMEPATH = config.homeDir.slice(env.HOMEDRIVE.length) || "\\"; + env.APPDATA = appData(config.homeDir).roaming; + env.LOCALAPPDATA = appData(config.homeDir).local; + } else { + env.TMPDIR = config.tmpDir; + } + + env.AZURE_CONFIG_DIR = config.configDir; + env.AZURE_EXTENSION_DIR = config.extensionDir; + env.AZURE_CORE_COLLECT_TELEMETRY = "no"; + env.AZURE_CORE_NO_COLOR = "1"; + if (az.azInstaller) env.AZ_INSTALLER = az.azInstaller; + // A launcher run as it is gets no `-X utf8`: ask for UTF-8 through the locale instead. + if ( + az.prefixArgs.length === 0 && + !["LC_ALL", "LC_CTYPE", "LANG"].some((k) => /utf-?8/i.test(env[k] ?? "")) + ) { + env.LC_ALL = "C.UTF-8"; + } + return env; +} diff --git a/src/lib/azure/config.test.ts b/src/lib/azure/config.test.ts new file mode 100644 index 0000000..07fffa6 --- /dev/null +++ b/src/lib/azure/config.test.ts @@ -0,0 +1,72 @@ +import { getAzureConfig, isLocalHost } from "./config"; + +const HOME = "/home/u"; +const deps = { + homedir: () => HOME, + cwd: () => "/work", + platform: "linux" as const, + isDirectory: (p: string) => p === "/work" || p === "/home/u/proj", +}; +const config = (env: NodeJS.ProcessEnv) => getAzureConfig(env, deps); + +describe("getAzureConfig", () => { + test("defaults: the gateway every tool uses, the emulator's ARM host, a per-port profile", () => { + expect(config({})).toEqual({ + healthBaseUrl: "http://localhost:4566", + port: 4566, + endpoint: "https://azure.localhost.localstack.cloud:4566", + configDir: "/home/u/.localstack/azure/mcp-config-4566", + homeDir: "/home/u/.localstack/azure/mcp-config-4566/home", + tmpDir: "/home/u/.localstack/azure/mcp-config-4566/tmp", + extensionDir: "/home/u/.azure/cliextensions", + azPath: undefined, + timeoutMs: 300_000, + workdir: "/work", + errors: [], + }); + }); + + test("LOCALSTACK_HOSTNAME and LOCALSTACK_PORT move the gateway and the endpoint's port", () => { + const c = config({ LOCALSTACK_HOSTNAME: "127.0.0.1", LOCALSTACK_PORT: "4666" }); + expect(c.healthBaseUrl).toBe("http://127.0.0.1:4666"); + expect(c.endpoint).toBe("https://azure.localhost.localstack.cloud:4666"); + expect(config({ LOCALSTACK_PORT: "abc" }).errors[0]).toContain("LOCALSTACK_PORT"); + }); + + test("the extension dir is the user's own: AZURE_EXTENSION_DIR, else their config dir's", () => { + expect(config({ AZURE_EXTENSION_DIR: "~/ext" }).extensionDir).toBe("/home/u/ext"); + expect(config({ AZURE_CONFIG_DIR: "/cfg" }).extensionDir).toBe("/cfg/cliextensions"); + }); + + test("isLocalHost: LocalStack's names for 127.0.0.1 and the loopback names only", () => { + for (const host of ["localhost", "127.0.0.1", "[::1]", "Azure.LocalHost.LocalStack.Cloud."]) { + expect(isLocalHost(host)).toBe(true); + } + for (const host of ["example.com", "localhost.localstack.cloud.evil.example", "10.0.0.5"]) { + expect(isLocalHost(host)).toBe(false); + } + }); + + test.each(["~/.azure", "~/.azure/sub", "~", "/cfg/inner"])( + "refuses a config dir that overlaps an Azure CLI profile: %s", + (dir) => { + const c = config({ LOCALSTACK_AZ_CONFIG_DIR: dir, AZURE_CONFIG_DIR: "/cfg" }); + expect(c.errors.join(" ")).toContain("overlaps your Azure CLI profile"); + } + ); + + test("the workdir must exist; ~ is expanded", () => { + expect(config({ LOCALSTACK_AZ_WORKDIR: "~/proj" })).toMatchObject({ + workdir: "/home/u/proj", + errors: [], + }); + expect(config({ LOCALSTACK_AZ_WORKDIR: "/nope" }).errors[0]).toContain("LOCALSTACK_AZ_WORKDIR"); + }); + + test("the timeout is a whole number of seconds from 5 to 3600", () => { + expect(config({ LOCALSTACK_AZ_TIMEOUT_SECONDS: "60" }).timeoutMs).toBe(60_000); + for (const value of ["4", "3601", "1.5", "x"]) { + expect(config({ LOCALSTACK_AZ_TIMEOUT_SECONDS: value }).errors).toHaveLength(1); + } + }); +}); diff --git a/src/lib/azure/config.ts b/src/lib/azure/config.ts new file mode 100644 index 0000000..97a090c --- /dev/null +++ b/src/lib/azure/config.ts @@ -0,0 +1,122 @@ +import { statSync } from "fs"; +import os from "os"; +import path from "path"; + +/** The Azure client tool's settings, read from the server's environment. */ +export interface AzureConfig { + /** The emulator's gateway, built like LOCALSTACK_BASE_URL: LOCALSTACK_HOSTNAME and LOCALSTACK_PORT. */ + healthBaseUrl: string; + port: number; + /** The ARM endpoint `az` talks to: https://azure.localhost.localstack.cloud:. */ + endpoint: string; + /** The tool's own Azure CLI config dir; `az` never sees the user's ~/.azure. */ + configDir: string; + /** The child's private home and temp dir, inside the config dir. */ + homeDir: string; + tmpDir: string; + /** The user's own extension dir: AZURE_EXTENSION_DIR, else /cliextensions. */ + extensionDir: string; + /** An explicit `az` launcher or the CLI's Python (LOCALSTACK_AZ_PATH). */ + azPath?: string; + timeoutMs: number; + /** The working directory `az` runs in (LOCALSTACK_AZ_WORKDIR, default the server's). */ + workdir: string; + /** Settings the tool refuses to run with, until they are fixed. */ + errors: string[]; +} + +export interface AzureConfigDeps { + homedir?: () => string; + cwd?: () => string; + platform?: NodeJS.Platform; + isDirectory?: (p: string) => boolean; +} + +/** + * The hosts an absolute `rest` URL may name: LocalStack's public DNS names for 127.0.0.1 + * (localhost.localstack.cloud and its subdomains) and the loopback names and addresses. + */ +export function isLocalHost(host: string): boolean { + const h = host + .toLowerCase() + .replace(/\.$/, "") + .replace(/^\[(.*)\]$/, "$1"); + return ( + ["localhost", "127.0.0.1", "::1", "localhost.localstack.cloud"].includes(h) || + h.endsWith(".localhost.localstack.cloud") + ); +} + +function isDirectory(p: string): boolean { + try { + return statSync(p).isDirectory(); + } catch { + return false; + } +} + +export function getAzureConfig( + env: NodeJS.ProcessEnv = process.env, + deps: AzureConfigDeps = {} +): AzureConfig { + const platform = deps.platform ?? process.platform; + const P = platform === "win32" ? path.win32 : path.posix; + const home = (deps.homedir ?? os.homedir)(); + const cwd = (deps.cwd ?? process.cwd)(); + const errors: string[] = []; + const setting = (name: string) => env[name]?.trim() || undefined; + // MCP client configs pass values without a shell, so a leading `~` is expanded here. + const userPath = (value: string) => + P.resolve(cwd, value === "~" || /^~[\\/]/.test(value) ? P.join(home, value.slice(1)) : value); + const wholeNumber = (name: string, fallback: string, min: number, max: number) => { + const text = setting(name) ?? fallback; + const value = /^\d+$/.test(text) ? Number(text) : NaN; + if (!(value >= min && value <= max)) { + errors.push(`${name} must be a whole number from ${min} to ${max}; got "${text}".`); + } + return value; + }; + const fold = (p: string) => (platform === "win32" || platform === "darwin" ? p.toLowerCase() : p); + const inside = (child: string, parent: string) => { + const rel = P.relative(fold(parent), fold(child)); + return rel === "" || (!rel.startsWith("..") && !P.isAbsolute(rel)); + }; + + const port = wholeNumber("LOCALSTACK_PORT", "4566", 1, 65535); + + const userProfile = userPath(setting("AZURE_CONFIG_DIR") ?? P.join(home, ".azure")); + const configSetting = setting("LOCALSTACK_AZ_CONFIG_DIR"); + const configDir = configSetting + ? userPath(configSetting) + : P.join(home, ".localstack", "azure", `mcp-config-${port}`); + // The bootstrap rewrites the config dir's cloud, settings and login: it must never be (or hold) + // the user's own Azure CLI profile. + for (const profile of new Set([P.join(home, ".azure"), userProfile])) { + if (inside(configDir, profile) || inside(profile, configDir)) { + errors.push( + `LOCALSTACK_AZ_CONFIG_DIR (${configDir}) overlaps your Azure CLI profile (${profile}). The tool keeps its own profile so it never changes your Azure CLI login; choose another directory.` + ); + } + } + + const workdir = userPath(setting("LOCALSTACK_AZ_WORKDIR") ?? cwd); + if (!(deps.isDirectory ?? isDirectory)(workdir)) { + errors.push( + `LOCALSTACK_AZ_WORKDIR (${workdir}) is not an existing directory: set it to the directory that holds your templates and files.` + ); + } + + return { + healthBaseUrl: `http://${setting("LOCALSTACK_HOSTNAME") ?? "localhost"}:${port}`, + port, + endpoint: `https://azure.localhost.localstack.cloud:${port}`, + configDir, + homeDir: P.join(configDir, "home"), + tmpDir: P.join(configDir, "tmp"), + extensionDir: userPath(setting("AZURE_EXTENSION_DIR") ?? P.join(userProfile, "cliextensions")), + azPath: setting("LOCALSTACK_AZ_PATH"), + timeoutMs: (wholeNumber("LOCALSTACK_AZ_TIMEOUT_SECONDS", "300", 5, 3600) || 300) * 1000, + workdir, + errors, + }; +} diff --git a/src/lib/azure/emulator.test.ts b/src/lib/azure/emulator.test.ts new file mode 100644 index 0000000..6fa631b --- /dev/null +++ b/src/lib/azure/emulator.test.ts @@ -0,0 +1,101 @@ +import { getAzureEmulatorStatus, resetEmulatorStatusCache, type EmulatorDeps } from "./emulator"; + +const config = { + healthBaseUrl: "http://localhost:4566", + endpoint: "https://azure.localhost.localstack.cloud:4566", +}; +const AZURE_HEALTH = { edition: "azure-alpha", license: true }; + +function deps(over: Partial & { health?: unknown; https?: boolean[] } = {}) { + const https = [...(over.https ?? [true])]; + let now = 0; + const fake: EmulatorDeps = { + getJson: jest.fn(async (url: string) => + url.endsWith("/health") ? over.health : { session_id: "s1", version: "4.9.0" } + ), + httpsReady: jest.fn(async () => https.shift() ?? false), + lookup: jest.fn(async () => ["127.0.0.1"]), + sleep: jest.fn(async (ms: number) => { + now += ms; + }), + now: () => now, + ...over, + }; + return fake; +} + +describe("getAzureEmulatorStatus", () => { + beforeEach(() => resetEmulatorStatusCache()); + + test("ready: the Azure edition, HTTPS answering, the name on loopback", async () => { + const d = deps({ health: AZURE_HEALTH }); + expect(await getAzureEmulatorStatus(config, d)).toEqual({ + ok: true, + edition: "azure-alpha", + sessionId: "s1", + version: "4.9.0", + }); + expect(d.httpsReady).toHaveBeenCalledWith( + "127.0.0.1", + 4566, + "azure.localhost.localstack.cloud", + 5000 + ); + expect(d.getJson).toHaveBeenCalledWith("http://localhost:4566/_localstack/health", 5000); + }); + + test("nothing answering, another emulator on the port, or no licence", async () => { + const status = await getAzureEmulatorStatus(config, deps({ health: undefined })); + expect(status).toMatchObject({ ok: false, problem: "not-running" }); + expect(status.message).toContain("not running at http://localhost:4566"); + expect(status.message).toContain("service: azure"); + const aws = await getAzureEmulatorStatus(config, deps({ health: { edition: "pro" } })); + expect(aws).toMatchObject({ problem: "wrong-edition", edition: "pro" }); + const unlicensed = { health: { edition: "azure-alpha", license: false } }; + expect((await getAzureEmulatorStatus(config, deps(unlicensed))).problem).toBe("license"); + }); + + test("waits up to 10 s for HTTPS, then says it is still starting", async () => { + const late = deps({ health: AZURE_HEALTH, https: [false, false, true] }); + expect((await getAzureEmulatorStatus(config, late)).ok).toBe(true); + resetEmulatorStatusCache(); // session s1 passed: forget it + const never = deps({ health: AZURE_HEALTH, https: [] }); + const status = await getAzureEmulatorStatus(config, never); + expect(status).toMatchObject({ ok: false, problem: "https-not-ready" }); + expect(never.sleep).toHaveBeenCalledTimes(20); + expect(status.message).toContain("did not become ready within 10 s"); + const once = deps({ health: AZURE_HEALTH, https: [] }); + const single = await getAzureEmulatorStatus(config, once, { httpsWaitMs: 0 }); + expect(once.httpsReady).toHaveBeenCalledTimes(1); + expect(single.message).toContain("is not ready yet"); + }); + + test("HTTPS is checked on the gateway LOCALSTACK_HOSTNAME names, with the endpoint as SNI", async () => { + const d = deps({ health: AZURE_HEALTH }); + await getAzureEmulatorStatus( + { ...config, healthBaseUrl: "http://host.docker.internal:4566" }, + d + ); + expect(d.httpsReady).toHaveBeenCalledWith( + "host.docker.internal", + 4566, + "azure.localhost.localstack.cloud", + 5000 + ); + }); + + test("the name must resolve to loopback; a session that passed is not checked again", async () => { + const blocked = deps({ health: AZURE_HEALTH, lookup: jest.fn(async () => []) }); + expect((await getAzureEmulatorStatus(config, blocked)).message).toContain("did not resolve"); + const elsewhere = deps({ health: AZURE_HEALTH, lookup: jest.fn(async () => ["10.0.0.5"]) }); + expect((await getAzureEmulatorStatus(config, elsewhere)).message).toContain( + "resolved to 10.0.0.5" + ); + // Once a session has passed, HTTPS and DNS are not checked again. + const d = deps({ health: AZURE_HEALTH, https: [true, true] }); + await getAzureEmulatorStatus(config, d); + await getAzureEmulatorStatus(config, d); + expect(d.httpsReady).toHaveBeenCalledTimes(1); + expect(d.lookup).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/azure/emulator.ts b/src/lib/azure/emulator.ts new file mode 100644 index 0000000..d965de1 --- /dev/null +++ b/src/lib/azure/emulator.ts @@ -0,0 +1,183 @@ +import { promises as dns } from "dns"; +import https from "https"; +import type { AzureConfig } from "./config"; + +/** + * Is the LocalStack Azure emulator usable? Its health and info are read over plain HTTP from the + * gateway every tool uses. The ARM endpoint's HTTPS listener comes up seconds after it, and is + * checked on that same gateway with the endpoint's name as SNI. That name must also resolve to + * 127.0.0.1, which some routers' DNS-rebinding protection blocks. + */ + +export type EmulatorProblem = + "not-running" | "wrong-edition" | "license" | "https-not-ready" | "dns"; + +export interface AzureEmulatorStatus { + ok: boolean; + problem?: EmulatorProblem; + /** The user-facing explanation when `ok` is false. */ + message?: string; + edition?: string; + version?: string; + /** Changes whenever the emulator restarts; the profile is set up again on a change. */ + sessionId?: string; +} + +export interface EmulatorDeps { + /** GET a JSON document over plain HTTP; undefined when nothing answers. */ + getJson(url: string, timeoutMs: number): Promise; + /** True when an HTTPS server answers at address:port for `servername`. */ + httpsReady( + address: string, + port: number, + servername: string, + timeoutMs: number + ): Promise; + lookup(host: string): Promise; + sleep(ms: number): Promise; + now(): number; +} + +const HEALTH_TIMEOUT_MS = 5000; +const HTTPS_READY_WAIT_MS = 10_000; +const HTTPS_RETRY_MS = 500; +const START_ADVICE = + "Start it with `localstack-management` (action: start, service: azure), then retry. If it runs on another port, set LOCALSTACK_PORT."; + +async function getJson(url: string, timeoutMs: number): Promise { + try { + const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); + return await response.json().catch(() => ({})); + } catch { + return undefined; + } +} + +function httpsReady( + address: string, + port: number, + servername: string, + timeoutMs: number +): Promise { + return new Promise((resolve) => { + const request = https.request( + { + host: address, + port, + servername, + path: "/_localstack/health", + headers: { Host: `${servername}:${port}` }, + agent: false, + timeout: timeoutMs, + // Only readiness is checked and nothing is sent; az verifies the certificate on every call. + rejectUnauthorized: false, + }, + (response) => { + response.resume(); + resolve(true); + } + ); + request.on("timeout", () => request.destroy()); + request.on("error", () => resolve(false)); + request.end(); + }); +} + +export const defaultEmulatorDeps: EmulatorDeps = { + getJson, + httpsReady, + lookup: async (host) => (await dns.lookup(host, { all: true })).map((a) => a.address), + sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + now: () => Date.now(), +}; + +const isLoopback = (address: string) => + /^127\./.test(address) || address === "::1" || /^::ffff:127\./i.test(address); + +/** The emulator session whose HTTPS endpoint and DNS name were checked. */ +let verifiedSession: string | undefined; + +export function resetEmulatorStatusCache(): void { + verifiedSession = undefined; +} + +export async function getAzureEmulatorStatus( + config: Pick, + deps: EmulatorDeps = defaultEmulatorDeps, + /** `localstack-management` polls on its own, so it asks for a single HTTPS probe. */ + opts: { httpsWaitMs?: number } = {} +): Promise { + const base = config.healthBaseUrl; + const health = (await deps.getJson(`${base}/_localstack/health`, HEALTH_TIMEOUT_MS)) as + { edition?: unknown; license?: unknown } | undefined; + if (!health) { + return { + ok: false, + problem: "not-running", + message: `The LocalStack Azure emulator is not running at ${base}. ${START_ADVICE}`, + }; + } + const edition = typeof health.edition === "string" ? health.edition : undefined; + if (!edition || !/azure/i.test(edition)) { + return { + ok: false, + problem: "wrong-edition", + edition, + message: `The emulator at ${base} is not the LocalStack Azure emulator (edition: ${edition ?? "unknown"}). ${START_ADVICE}`, + }; + } + if (health.license === false) { + return { + ok: false, + problem: "license", + edition, + message: `The LocalStack Azure emulator at ${base} reports no active license. Start it with a LOCALSTACK_AUTH_TOKEN whose license includes LocalStack for Azure.`, + }; + } + + const info = (await deps.getJson(`${base}/_localstack/info`, HEALTH_TIMEOUT_MS)) as + { session_id?: unknown; version?: unknown } | undefined; + const sessionId = typeof info?.session_id === "string" ? info.session_id : undefined; + const version = typeof info?.version === "string" ? info.version : undefined; + const fields = { edition, sessionId, version }; + + // Once per emulator session: HTTPS comes up a few seconds after plain HTTP. + if (sessionId && sessionId === verifiedSession) return { ok: true, ...fields }; + const url = new URL(config.endpoint); + const host = url.hostname; + const port = Number(url.port) || 443; + // The gateway LOCALSTACK_HOSTNAME names (host.docker.internal in Docker); localhost as 127.0.0.1. + const gateway = new URL(base).hostname.replace(/^\[|\]$/g, ""); + const address = gateway === "localhost" ? "127.0.0.1" : gateway; + const waitMs = opts.httpsWaitMs ?? HTTPS_READY_WAIT_MS; + const deadline = deps.now() + waitMs; + let ready = await deps.httpsReady(address, port, host, HEALTH_TIMEOUT_MS); + while (!ready && deps.now() < deadline) { + await deps.sleep(HTTPS_RETRY_MS); + ready = await deps.httpsReady(address, port, host, HEALTH_TIMEOUT_MS); + } + if (!ready) { + const notReady = + waitMs > 0 + ? `did not become ready within ${Math.round(waitMs / 1000)} s` + : "is not ready yet"; + return { + ok: false, + problem: "https-not-ready", + ...fields, + message: `The LocalStack Azure emulator answers on ${base}, but its HTTPS endpoint ${config.endpoint} ${notReady}. It may still be starting; retry in a few seconds.`, + }; + } + // az resolves the endpoint's name itself. + const addresses = await deps.lookup(host).catch(() => []); + if (!addresses.length || !addresses.every(isLoopback)) { + return { + ok: false, + problem: "dns", + ...fields, + message: `${host} ${addresses.length ? `resolved to ${addresses.join(", ")}` : "did not resolve"}, not to 127.0.0.1. It is a public DNS name for 127.0.0.1, which some routers and corporate resolvers block (DNS-rebinding protection): allow localhost.localstack.cloud there, or add \`127.0.0.1 ${host}\` to your hosts file.`, + }; + } + if (sessionId) verifiedSession = sessionId; + return { ok: true, ...fields }; +} diff --git a/src/lib/azure/output.test.ts b/src/lib/azure/output.test.ts new file mode 100644 index 0000000..536ad63 --- /dev/null +++ b/src/lib/azure/output.test.ts @@ -0,0 +1,105 @@ +import { formatAzResult, MAX_OUTPUT_CHARS, prepareStderr } from "./output"; +import type { AzRunResult } from "./runner"; + +const result = (over: Partial): AzRunResult => ({ + exitCode: 0, + stdout: "", + stderr: "", + timedOut: false, + aborted: false, + truncated: false, + ...over, +}); +const opts = { notes: [], timeoutSeconds: 300, healthBaseUrl: "http://localhost:4566" }; +const text = (r: { content: Array<{ text: string }> }) => r.content[0].text; + +describe("formatAzResult", () => { + test("success: az's output, the policy's notes first, warnings after", () => { + const answer = formatAzResult( + result({ stdout: '[\r\n "rg"\r\n]\r\n', stderr: "WARNING: x\n" }), + { + ...opts, + notes: ["Rewrote the URL."], + } + ); + expect(text(answer)).toBe('Rewrote the URL.\n\n[\n "rg"\n]\n\nWARNING: x'); + expect(text(formatAzResult(result({}), opts))).toBe( + "The command succeeded and printed no output." + ); + }); + + test("failure: the exit code in the title, az's error, a traceback reduced to its ERROR line", () => { + const stderr = + 'Traceback (most recent call last):\n File "x.py", line 1\nKeyError: 1\nERROR: The command failed.\n'; + const answer = text(formatAzResult(result({ exitCode: 1, stderr }), opts)); + expect(answer).toBe( + "āŒ **Command Failed (exit 1)**\n\n(Python traceback omitted)\nERROR: The command failed." + ); + }); + + test("an operation the emulator does not implement links its coverage list", () => { + const stderr = + "ERROR: (NotImplemented) The API operation 'GET /subscriptions/x/providers/Microsoft.Foo/bars' is not yet implemented in LocalStack."; + const answer = text(formatAzResult(result({ exitCode: 1, stderr }), opts)); + expect(answer).toContain( + "does not implement `GET /subscriptions/x/providers/Microsoft.Foo/bars` yet" + ); + expect(answer).toContain("http://localhost:4566/_localstack/coverage"); + }); + + test("an unknown command group points at the user's own az extension add", () => { + const stderr = "ERROR: 'graph' is misspelled or not recognized by the system."; + const answer = text(formatAzResult(result({ exitCode: 2, stderr }), opts)); + expect(answer).toContain("If `graph` comes from an Azure CLI extension"); + expect(answer).toContain("az extension add --name "); + }); + + test("a confirmation prompt asks for --yes; a missing bicep points at PATH", () => { + const hint = (stderr: string) => text(formatAzResult(result({ exitCode: 1, stderr }), opts)); + expect( + hint("ERROR: Unable to prompt for confirmation as no tty available. Use --yes.") + ).toContain("Re-run it with `--yes`."); + expect(hint('ERROR: Could not find the "bicep" executable on PATH.')).toContain( + "The tool runs Bicep from PATH." + ); + }); + + test("timeout, cancel and a spawn failure", () => { + expect(text(formatAzResult(result({ exitCode: null, timedOut: true }), opts))).toMatch( + /^āŒ \*\*Command Timed Out\*\*\n\n`az` did not finish within 300 s/ + ); + expect(text(formatAzResult(result({ aborted: true }), opts))).toContain("Command Cancelled"); + expect(text(formatAzResult(result({ spawnError: "ENOENT: x" }), opts))).toContain("ENOENT: x"); + }); + + test("the capture cap: az was stopped, so the answer has its own title and the output so far", () => { + // What the runner returns at the cap: the killed az has no exit code. + const big = text( + formatAzResult( + result({ exitCode: null, stdout: "y".repeat(MAX_OUTPUT_CHARS + 10), truncated: true }), + opts + ) + ); + expect(big).toMatch(/^āŒ \*\*Output Too Large\*\*\n\n`az` printed more than the tool captures/); + expect(big).toContain("[... 10 more characters of output cut]"); + expect(big).not.toContain("exit null"); + expect(big).not.toContain("exited without an error message"); + }); +}); + +describe("prepareStderr", () => { + test("keeps the end, where az prints its ERROR: line after a long --debug log", () => { + const debug = Array.from({ length: 200 }, (_, i) => `DEBUG: cli.knack: line ${i}`).join("\n"); + const stderr = `${debug}\nERROR: Please run 'az login' to setup account.\nINFO: telemetry: done`; + const prepared = prepareStderr(stderr); + expect(prepared).toMatch(/^\[\.\.\. [\d,]+ earlier characters of stderr cut\]\n/); + expect(prepared).toContain("ERROR: Please run 'az login' to setup account."); + expect(prepared.endsWith("INFO: telemetry: done")).toBe(true); + }); + + test("caps without splitting a surrogate pair", () => { + const capped = prepareStderr("a".repeat(100) + "šŸ˜€" + "b".repeat(3_999)); + expect(capped.endsWith("b".repeat(3_999))).toBe(true); + expect(capped).not.toMatch(/[\udc00-\udfff]b/); + }); +}); diff --git a/src/lib/azure/output.ts b/src/lib/azure/output.ts new file mode 100644 index 0000000..519a25b --- /dev/null +++ b/src/lib/azure/output.ts @@ -0,0 +1,136 @@ +import { ResponseBuilder } from "../../core/response-builder"; +import type { AzRunResult } from "./runner"; + +/** How much of az's output a response carries, in characters. */ +export const MAX_OUTPUT_CHARS = 30_000; +export const MAX_STDERR_CHARS = 4_000; + +type ToolResponse = ReturnType; + +export interface FormatOptions { + /** The policy's notes, such as a rewritten URL. */ + notes: string[]; + timeoutSeconds: number; + /** The emulator's gateway, for the coverage link. */ + healthBaseUrl: string; +} + +/** The first `max` characters, never ending inside a surrogate pair, with a note on the cut. */ +function cap(text: string, max: number, what: string): string { + if (text.length <= max) return text; + const end = /[\ud800-\udbff]/.test(text[max - 1]) ? max - 1 : max; + return `${text.slice(0, end)}\n[... ${(text.length - end).toLocaleString("en-US")} more characters of ${what} cut]`; +} + +/** The last `max` characters, never starting inside a surrogate pair, with a note on the cut. */ +function capTail(text: string, max: number, what: string): string { + if (text.length <= max) return text; + const start = text.length - max + (/[\udc00-\udfff]/.test(text[text.length - max]) ? 1 : 0); + return `[... ${start.toLocaleString("en-US")} earlier characters of ${what} cut]\n${text.slice(start)}`; +} + +/** + * az's stderr without Python traceback frames (its ERROR: lines stay), capped from the front: az + * prints its ERROR: line last, after any `--debug` log. + */ +export function prepareStderr(raw: string): string { + const kept: string[] = []; + let inTraceback = false; + for (const line of raw.replace(/\r\n/g, "\n").split("\n")) { + if (/^Traceback \(most recent call last\):\s*$/.test(line)) { + if (!inTraceback) kept.push("(Python traceback omitted)"); + inTraceback = true; + } else if (!inTraceback || line.startsWith("ERROR:")) { + kept.push(line); + } + } + return capTail(kept.join("\n").trim(), MAX_STDERR_CHARS, "stderr"); +} + +function failureHint(stderr: string, opts: FormatOptions): string | undefined { + const notImplemented = + /The API operation '([A-Z]+) ([^']+)' is not yet implemented in LocalStack\./.exec(stderr); + if (notImplemented || /\(NotImplemented\)|"code": "NotImplemented"/.test(stderr)) { + const what = notImplemented + ? `\`${notImplemented[1]} ${notImplemented[2]}\`` + : "this operation"; + return ( + `The LocalStack Azure emulator does not implement ${what} yet, so retrying will not help. ` + + `The implemented operations are listed at ${opts.healthBaseUrl}/_localstack/coverage.` + ); + } + const unknown = /'([\w-]+)' is misspelled or not recognized by the system\./.exec(stderr); + if (unknown) { + return ( + `If \`${unknown[1]}\` comes from an Azure CLI extension, add the extension with your own ` + + "Azure CLI (`az extension list-available` lists them, `az extension add --name ` " + + "adds one): the tool loads the extensions in your extension directory. Meanwhile `rest` " + + "with a relative URL usually works." + ); + } + if (/Unable to prompt for confirmation as no tty available\. Use --yes\./.test(stderr)) { + return "This command asks for confirmation. Re-run it with `--yes`."; + } + if (/Could not find the "bicep" executable on PATH/.test(stderr)) { + // az's own advice (config set, az bicep install) is refused here. + return "The tool runs Bicep from PATH. Install it (for example `brew install bicep` or `winget install Microsoft.Bicep`), then restart this MCP server."; + } + return undefined; +} + +export function formatAzResult(result: AzRunResult, opts: FormatOptions): ToolResponse { + if (result.spawnError) { + return ResponseBuilder.error("Azure CLI Could Not Start", result.spawnError); + } + if (result.aborted) { + return ResponseBuilder.error( + "Command Cancelled", + "The client cancelled the call, so `az` was stopped." + ); + } + const stderr = prepareStderr(result.stderr); + if (result.timedOut) { + const advice = + `\`az\` did not finish within ${opts.timeoutSeconds} s and was stopped; it may have changed ` + + "the emulator already. For long operations raise LOCALSTACK_AZ_TIMEOUT_SECONDS, or use " + + "`--no-wait` where the command has it."; + return ResponseBuilder.error( + "Command Timed Out", + [advice, stderr].filter(Boolean).join("\n\n") + ); + } + const stdout = cap( + result.stdout.replace(/\r\n/g, "\n").replace(/\s+$/, ""), + MAX_OUTPUT_CHARS, + "output" + ); + if (result.truncated) { + const advice = + "`az` printed more than the tool captures, so it was stopped and its output is " + + "incomplete; it may have changed the emulator already. Narrow it with `--query` or `-o tsv`."; + return ResponseBuilder.error( + "Output Too Large", + [advice, stdout && `Output so far:\n${stdout}`, stderr, ...opts.notes] + .filter(Boolean) + .join("\n\n") + ); + } + if (result.exitCode === 0) { + const blocks = [ + ...opts.notes, + stdout || "The command succeeded and printed no output.", + stderr, + ]; + return ResponseBuilder.markdown(blocks.filter(Boolean).join("\n\n")); + } + const blocks = [ + stderr || "`az` exited without an error message.", + stdout && `Output:\n${stdout}`, + failureHint(result.stderr, opts), + ...opts.notes, + ]; + return ResponseBuilder.error( + `Command Failed (exit ${result.exitCode})`, + blocks.filter(Boolean).join("\n\n") + ); +} diff --git a/src/lib/azure/policy.test.ts b/src/lib/azure/policy.test.ts new file mode 100644 index 0000000..bc06230 --- /dev/null +++ b/src/lib/azure/policy.test.ts @@ -0,0 +1,176 @@ +import { AzSyntaxError, splitAzArgs } from "./argv"; +import { evaluateAzCommand } from "./policy"; + +const allowed = (command: string) => { + const result = evaluateAzCommand(command); + if (!result.ok) throw new Error(`refused: ${result.message}`); + return result; +}; +const refusal = (command: string) => { + const result = evaluateAzCommand(command); + if (result.ok) throw new Error(`allowed: ${command}`); + return result; +}; + +describe("splitAzArgs", () => { + test("quotes, empty arguments, data inside quotes and literal backslashes", () => { + expect( + splitAzArgs(`x -n "my group" --tags 'a=b c' --v "" --q "\`a\`" C:\\tmp\\a $NAME`) + ).toEqual([ + "x", + "-n", + "my group", + "--tags", + "a=b c", + "--v", + "", + "--q", + "`a`", + "C:\\tmp\\a", + "$NAME", + ]); + expect(splitAzArgs('x "\\$HOME \\` \\" \\\\" "a\\\nb"')).toEqual(["x", '$HOME ` " \\', "ab"]); + }); + + test.each(["a; b", "a && b", "a | b", "a > f", "a $(b)", "a ${b}", "a `b`", "a\nb", 'a "open'])( + "refuses %j", + (command) => expect(() => splitAzArgs(command)).toThrow(AzSyntaxError) + ); +}); + +describe("evaluateAzCommand", () => { + test("strips one leading az and passes the argv through", () => { + expect(allowed("az group list -o table")).toEqual({ + ok: true, + argv: ["group", "list", "-o", "table"], + notes: [], + }); + expect(allowed("--help").argv).toEqual(["--help"]); + }); + + test.each([ + ["", "No command given"], + ["az az group list", "Extra executable"], + ["azlocal group list", "Extra executable"], + ["--debug group list", "Not a valid command"], + ["group list; rm -rf /", "Command not understood"], + ])("refuses %j (%s)", (command, title) => { + expect(refusal(command).title).toContain(title); + }); + + test("refuses --version, which asks PyPI for updates, and allows the offline version", () => { + expect(refusal("--version").message).toContain("use `version`"); + expect(allowed("version").argv).toEqual(["version"]); + }); + + test.each([ + "login", + "logout", + "account clear", + "cloud set --name AzureCloud", + "config set core.output=table", + "extension add --name fleet", + "upgrade", + "bicep install", + "aks install-cli", + "storage copy -s a -d b", + "storage fs directory upload -f fs -s ./dir --account-name a", + "storage fs directory download -f fs -s dir -d ./out --account-name a", + "find vm", + "webapp ssh -n app -g rg", + "ssh vm -n vm1 -g rg", + ])("denies %j", (command) => { + expect(refusal(command).message).toMatch(/is not allowed here: /); + }); + + test("allows config get, and commands that only share a word with a denied one", () => { + expect(allowed("config get core.output").argv[0]).toBe("config"); + expect(allowed("cloud show").argv).toEqual(["cloud", "show"]); + expect(allowed("extension list").argv).toEqual(["extension", "list"]); + }); + + test("denied flags, matched exactly", () => { + expect(refusal("container logs -n c -g rg --follow").title).toBe("Flag not allowed"); + expect(refusal("webapp up -n app -b").message).toContain( + "`az webapp up` cannot be run with `-b`" + ); + expect(allowed("group list -b x").argv).toContain("-b"); // -b is denied only on webapp up + // az reads these as --location and --force-deletion, not as the denied flags they start. + expect(allowed("group create -n rg --lo westeurope").argv).toContain("--lo"); + expect(allowed("vm delete -n vm1 -g rg --fo --yes").argv).toContain("--fo"); + }); + + test.each([ + "storage blob upload -f ../secret.txt -c c -n n", + "storage blob upload -f../secret.txt -c c -n n", + "storage blob upload -f..\\secret.txt -c c -n n", + "deployment group create -g rg --template-file=..\\up\\main.json", + "group create -n x --tags @../tags.json", + "rest --url /a/../b", + ])("refuses path traversal: %j", (command) => { + expect(refusal(command).message).toContain("path traversal"); + }); + + test("leaves dots that are not a path segment alone", () => { + expect(allowed("group list --query \"[?name=='a..b']\"").argv).toHaveLength(4); + expect(allowed("storage blob upload -f ./data/hello.txt").argv).toContain("./data/hello.txt"); + }); + + test("rewrites a management.azure.com URL given to rest into a relative path", () => { + const rest = allowed("rest --url https://management.azure.com/subscriptions?api-version=1"); + expect(rest.argv).toEqual(["rest", "--url", "/subscriptions?api-version=1"]); + expect(rest.notes[0]).toContain("relative path `/subscriptions?api-version=1`"); + expect(allowed("rest --uri=HTTPS://Management.Azure.Com:443/x").argv[1]).toBe("--uri=/x"); + expect(allowed("rest -u https://management.azure.com").argv[2]).toBe("/"); + }); + + test("rest calls local hosts only: a file given as the body would go wherever the URL points", () => { + for (const command of [ + "rest --method post --url http://10.1.2.3:8080/x --body @/etc/hosts", + "rest --uri=HTTP://evil.example/x", + "rest -uhttps://evil.example/x", + "rest --ur https://evil.example/x", // az finds --ur ambiguous; refused all the same + "rest --url http:evil.example/x", + "rest --url https://management.azure.com.evil.example/x", + "rest --url https://localhost@evil.example/x", + ]) { + expect(refusal(command).title).toBe("URL not allowed"); + } + for (const url of [ + "https://azure.localhost.localstack.cloud:4566/x", + "http://localhost:4566/_localstack/health", + "http://127.0.0.1:4566/x", + "/subscriptions?api-version=2022-12-01", + "//evil.example/x", // no scheme: az puts the emulator's endpoint before it + ]) { + expect(allowed(`rest --url ${url}`).argv[2]).toBe(url); + } + expect(allowed("group list --query https://management.azure.com/x").notes).toEqual([]); + }); + + test("a leading ~ is the user's home, not the tool's private one", () => { + const linux = { home: "/home/u", platform: "linux" as NodeJS.Platform }; + const argv = (command: string, user = linux) => { + const result = evaluateAzCommand(command, user); + if (!result.ok) throw new Error(result.message); + return result.argv; + }; + expect(argv("deployment group create -g rg --template-file ~/infra/main.json")[6]).toBe( + "/home/u/infra/main.json" + ); + expect(argv("rest --url /x --body @~/b.json")[4]).toBe("@/home/u/b.json"); + expect(argv("bicep build --file=~/main.bicep")[2]).toBe("--file=/home/u/main.bicep"); + expect(argv("storage blob upload -f~/a.txt -c c")[3]).toBe("-f/home/u/a.txt"); + expect(argv("storage blob upload -f=~/a.txt -c c")[3]).toBe("-f=/home/u/a.txt"); + expect(argv("group list --query ~x --tags a=~/b")).toEqual([ + "group", + "list", + "--query", + "~x", + "--tags", + "a=~/b", + ]); + const windows = { home: "C:\\Users\\u", platform: "win32" as const }; + expect(argv("storage blob upload -f ~\\a.txt", windows)[4]).toBe("C:\\Users\\u\\a.txt"); + }); +}); diff --git a/src/lib/azure/policy.ts b/src/lib/azure/policy.ts new file mode 100644 index 0000000..cc3340e --- /dev/null +++ b/src/lib/azure/policy.ts @@ -0,0 +1,219 @@ +import os from "os"; +import { AzSyntaxError, splitAzArgs } from "./argv"; +import { isLocalHost } from "./config"; + +/** + * The Azure command policy: a decision over one `az` command string, made before anything runs. + * It refuses commands that would break the tool's own profile, change this machine or never end + * (the deny list), refuses `..` path traversal as the AWS client does, and keeps `rest` on local + * hosts: a management.azure.com URL becomes the relative path the emulator serves, and any other + * absolute URL must name a local host. A leading `~` becomes the user's home, which is what `az` + * would make of it without the tool's private HOME. Everything else is left to `az`. + */ + +export type PolicyResult = + { ok: true; argv: string[]; notes: string[] } | { ok: false; title: string; message: string }; + +const deny = (reason: string, ...prefixes: string[][]) => + prefixes.map((match) => ({ match, reason })); + +/** Denied command groups and verbs, matched on the leading command words. */ +export const DENIED: Array<{ match: string[]; reason: string }> = [ + ...deny("the CLI is already logged in to the emulator with a dummy account", ["login"]), + ...deny( + "this would break routing to the emulator", + ["logout"], + ["account", "clear"], + ["cloud", "register"], + ["cloud", "unregister"], + ["cloud", "update"], + ["cloud", "set"] + ), + // `config get` survives this (ALLOWED_EXCEPTIONS); every other `config` rewrites the profile. + ...deny("the tool manages the CLI configuration", ["config"], ["configure"], ["init"]), + ...deny( + "the tool uses the extensions you installed with your own `az extension add`", + ["extension", "add"], + ["extension", "update"], + ["extension", "remove"] + ), + ...deny( + "this downloads or installs software on this machine", + ["upgrade"], + ["bicep", "install"], + ["bicep", "upgrade"], + ["bicep", "uninstall"], + ["aks", "install-cli"], // on Windows it also rewrites the user's PATH with `setx` + ["storage", "copy"], // these download azcopy first + ["storage", "remove"], + ["storage", "blob", "sync"], + ["storage", "fs", "directory", "upload"], + ["storage", "fs", "directory", "download"] + ), + ...deny("this calls a Microsoft web service", ["find"], ["feedback"], ["survey"]), + ...deny("this needs an interactive session", ["interactive"], ["self-test"]), + ...deny( + "this opens a browser, a shell, a tunnel or a live stream on this machine", + ["aks", "browse"], + ["webapp", "browse"], + ["containerapp", "browse"], + ["webapp", "ssh"], + ["webapp", "create-remote-connection"], + ["webapp", "log", "tail"], + ["container", "exec"], + ["container", "attach"], + ["containerapp", "exec"], + ["network", "bastion", "ssh"], + ["network", "bastion", "rdp"], + ["network", "bastion", "tunnel"], + ["ssh"] // the ssh extension's `ssh vm` and `ssh arc` + ), +]; + +const ALLOWED_EXCEPTIONS = [["config", "get"]]; + +/** Flags that are refused, everywhere or on one command. */ +export const DENIED_FLAGS: Array<{ command?: string[]; flag: string; reason: string }> = [ + { flag: "--follow", reason: "it streams output until killed" }, + { flag: "--login-with-github", reason: "it opens a browser login" }, + { command: ["webapp", "up"], flag: "--launch-browser", reason: "it opens a browser" }, + { command: ["webapp", "up"], flag: "-b", reason: "it opens a browser" }, + { command: ["webapp", "up"], flag: "--logs", reason: "it streams logs until killed" }, +]; + +const START_TOKEN = /^[a-z][a-z0-9-]*$/; +/** A `..` path segment in a value, an `@file` or a `--flag=value`, as the AWS client refuses it. */ +const TRAVERSAL = /(^|[\\/=@])\.\.([\\/]|$)/; +const REST_URL_FLAGS = ["--url", "--uri", "-u"]; +const MANAGEMENT_URL = /^https?:\/\/management\.azure\.com(:443)?(?=[/?#]|$)/i; + +const hasPrefix = (argv: string[], prefix: string[]) => + prefix.length <= argv.length && prefix.every((word, index) => argv[index] === word); + +/** A token's flag and the value packed into it (`--flag=value`, `-fvalue`), if any. */ +function parseOption(token: string): { flag: string; value?: string } { + if (token.startsWith("--")) { + const eq = token.indexOf("="); + return eq < 0 ? { flag: token } : { flag: token.slice(0, eq), value: token.slice(eq + 1) }; + } + const rest = token.slice(2); + return { flag: token.slice(0, 2), value: rest ? rest.replace(/^=/, "") : undefined }; +} + +/** A `rest` URL flag, or a prefix of one (`--ur`): az finds those ambiguous, and so are they here. */ +const isRestUrlFlag = (flag: string) => + REST_URL_FLAGS.some( + (full) => flag === full || (flag.startsWith("--") && flag.length >= 4 && full.startsWith(flag)) + ); + +/** `az rest` sends a URL with a scheme as it is, and puts the emulator's endpoint before any other. */ +const hasScheme = (url: string) => url.includes("://") || /^https?:/i.test(url); + +/** + * `~`, `~/x`, `@~/x`, `--flag=~/x` and `-f~/x` with the user's home in place of the `~`, in any + * argument: the policy cannot tell which values are paths. + */ +function expandHome(token: string, home: string, platform: NodeJS.Platform): string { + const tilde = + platform === "win32" + ? /^(--[^=]+=|-[A-Za-z]=?)?(@?)~(?=$|[\\/])/ + : /^(--[^=]+=|-[A-Za-z]=?)?(@?)~(?=$|\/)/; + return token.replace(tilde, (_match, flag = "", at = "") => `${flag}${at}${home}`); +} + +const refuse = (title: string, message: string): PolicyResult => ({ ok: false, title, message }); + +export function evaluateAzCommand( + command: string, + user: { home?: string; platform?: NodeJS.Platform } = {} +): PolicyResult { + // One leading `az` is optional. + const body = command.trim().replace(/^az(\s+|$)/, ""); + + let argv: string[]; + try { + argv = splitAzArgs(body); + } catch (error) { + const message = error instanceof AzSyntaxError ? error.message : "Command could not be parsed."; + return refuse("Command not understood", message); + } + if (argv.length === 0) { + return refuse("No command given", "Give one `az` command, for example `group list`."); + } + if (argv[0] === "az" || argv[0] === "azlocal") { + return refuse( + "Extra executable in the command", + "Give the command without the `az` or `azlocal` executable, for example `group list`." + ); + } + if (argv[0] === "--version") { + return refuse( + "Not a valid command", + "`--version` asks PyPI for a newer Azure CLI; use `version`, which makes no network call." + ); + } + if (!START_TOKEN.test(argv[0]) && !["--help", "-h"].includes(argv[0])) { + return refuse( + "Not a valid command", + "Start with a command group or verb, for example `group list`." + ); + } + // A short option's attached value (`-f../x`) is checked on its own too. + const values = argv.flatMap((t) => (/^-[^-]/.test(t) ? [t, parseOption(t).value ?? ""] : [t])); + if (values.some((value) => TRAVERSAL.test(value))) { + return refuse("Path not allowed", "Command contains forbidden path traversal (`..`)."); + } + + const isException = ALLOWED_EXCEPTIONS.some((prefix) => hasPrefix(argv, prefix)); + for (const entry of DENIED) { + if (!hasPrefix(argv, entry.match)) continue; + if (isException && entry.match.join(" ") === "config") continue; + return refuse( + "Command not allowed", + `\`az ${entry.match.join(" ")}\` is not allowed here: ${entry.reason}.` + ); + } + const flags = argv.filter((token) => token.startsWith("-")).map((t) => parseOption(t).flag); + for (const rule of DENIED_FLAGS) { + if (rule.command && !hasPrefix(argv, rule.command)) continue; + if (flags.includes(rule.flag)) { + const scope = rule.command ? `\`az ${rule.command.join(" ")}\` ` : ""; + return refuse( + "Flag not allowed", + `${scope}cannot be run with \`${rule.flag}\` here: ${rule.reason}.` + ); + } + } + + // `rest` would send an absolute URL, and a file given as its body, to any host. + const notes: string[] = []; + if (argv[0] === "rest") { + for (let i = 1; i < argv.length; i++) { + if (!argv[i].startsWith("-")) continue; + const { flag, value } = parseOption(argv[i]); + if (!isRestUrlFlag(flag)) continue; + const index = value === undefined ? i + 1 : i; + const url = value ?? argv[index]; + if (url === undefined) continue; + if (MANAGEMENT_URL.test(url)) { + const relative = url.replace(MANAGEMENT_URL, "").replace(/^(?!\/)/, "/"); + argv[index] = + value === undefined ? relative : `${argv[i].slice(0, -value.length)}${relative}`; + notes.push( + `Rewrote the management.azure.com URL to the relative path \`${relative}\`, which the emulator serves.` + ); + continue; + } + const host = URL.canParse(url) ? new URL(url).hostname : undefined; + if (hasScheme(url) && !(host && isLocalHost(host))) { + return refuse( + "URL not allowed", + `\`rest\` calls the emulator only: give a relative URL such as \`/subscriptions?api-version=2022-12-01\`, or one on a local host. ${host ? `"${host}" is not a local host.` : `"${url}" is not a valid URL.`}` + ); + } + } + } + const home = user.home ?? os.homedir(); + const platform = user.platform ?? process.platform; + return { ok: true, argv: argv.map((token) => expandHome(token, home, platform)), notes }; +} diff --git a/src/lib/azure/preflight.test.ts b/src/lib/azure/preflight.test.ts new file mode 100644 index 0000000..c6990e8 --- /dev/null +++ b/src/lib/azure/preflight.test.ts @@ -0,0 +1,107 @@ +import { isRunningInDocker } from "../localstack/localstack.utils"; +import { BootstrapError } from "./bootstrap"; +import { getAzureEmulatorStatus } from "./emulator"; +import { + requireAzureCli, + requireAzureCliConfigured, + requireAzureConfig, + requireAzureEmulatorRunning, +} from "./preflight"; +import { AzResolveError } from "./resolve-az"; +import type { AzRunResult } from "./runner"; +import { + AzCancelledError, + azCli, + azureConfig, + ensureProfile, + recordEmulatorSession, +} from "./services"; + +jest.mock("../localstack/localstack.utils", () => ({ isRunningInDocker: jest.fn(() => false) })); +jest.mock("./emulator", () => ({ getAzureEmulatorStatus: jest.fn() })); +jest.mock("./services", () => ({ + ...jest.requireActual("./services"), + azCli: jest.fn(), + azureConfig: jest.fn(() => ({ errors: [] })), + ensureProfile: jest.fn(), + recordEmulatorSession: jest.fn(), +})); + +const text = (r: { content: Array<{ text?: string }> } | null) => r?.content[0].text ?? ""; +const failed = (over: Partial): AzRunResult => ({ + exitCode: 1, + stdout: "", + stderr: "", + timedOut: false, + aborted: false, + truncated: false, + ...over, +}); + +beforeEach(() => jest.clearAllMocks()); + +test("requireAzureConfig lists every setting the tool cannot run with", () => { + expect(requireAzureConfig()).toBeNull(); + jest.mocked(azureConfig).mockReturnValueOnce({ errors: ["A is bad.", "B is bad."] } as never); + expect(text(requireAzureConfig())).toContain("- A is bad.\n- B is bad."); +}); + +test("requireAzureEmulatorRunning records the session and passes the emulator's explanation on", async () => { + jest.mocked(getAzureEmulatorStatus).mockResolvedValueOnce({ ok: true, sessionId: "s1" }); + expect(await requireAzureEmulatorRunning()).toBeNull(); + expect(recordEmulatorSession).toHaveBeenCalledWith("s1"); + jest + .mocked(getAzureEmulatorStatus) + .mockResolvedValueOnce({ ok: false, problem: "not-running", message: "Start it." }); + const answer = text(await requireAzureEmulatorRunning()); + expect(answer).toContain("LocalStack Azure Emulator Not Ready"); + expect(answer).toContain("Start it."); +}); + +describe("requireAzureCli", () => { + test("an az that runs: no answer, the call goes on", async () => { + jest.mocked(azCli).mockResolvedValueOnce({ file: "/usr/bin/az", prefixArgs: [] }); + expect(await requireAzureCli()).toBeNull(); + }); + + test("a missing az: the install advice and what was skipped; in Docker, the image's limit", async () => { + const missing = new AzResolveError("The Azure CLI (az) was not found.", ["/mnt/c/az: Windows"]); + jest.mocked(azCli).mockRejectedValue(missing); + const answer = text(await requireAzureCli()); + expect(answer).toContain("Azure CLI Not Available"); + expect(answer).toContain("Skipped:\n- /mnt/c/az: Windows"); + jest.mocked(isRunningInDocker).mockReturnValueOnce(true); + expect(text(await requireAzureCli())).toContain("Docker image does not include the Azure CLI"); + }); + + test("passes the signal on; a cancelled check is a cancelled call", async () => { + const signal = new AbortController().signal; + jest.mocked(azCli).mockRejectedValueOnce(new AzCancelledError()); + expect(text(await requireAzureCli(signal))).toContain("Command Cancelled"); + expect(azCli).toHaveBeenCalledWith(signal); + }); +}); + +describe("requireAzureCliConfigured", () => { + test("a profile that is set up: no answer, the call goes on", async () => { + jest.mocked(ensureProfile).mockResolvedValueOnce(undefined); + expect(await requireAzureCliConfigured()).toBeNull(); + }); + + test("a failed step names it and shows az's error", async () => { + const error = new BootstrapError("login", failed({ stderr: "ERROR: AADSTS7000215" })); + jest.mocked(ensureProfile).mockRejectedValueOnce(error); + const answer = text(await requireAzureCliConfigured()); + expect(answer).toContain("Azure CLI Profile Setup Failed"); + expect(answer).toContain("failed at `az login`"); + expect(answer).toContain("ERROR: AADSTS7000215"); + }); + + test("a cancelled setup is a cancelled call", async () => { + const error = new BootstrapError("cloud register", failed({ exitCode: null, aborted: true })); + jest.mocked(ensureProfile).mockRejectedValueOnce(error); + const signal = new AbortController().signal; + expect(text(await requireAzureCliConfigured(signal))).toContain("Command Cancelled"); + expect(ensureProfile).toHaveBeenCalledWith(signal); + }); +}); diff --git a/src/lib/azure/preflight.ts b/src/lib/azure/preflight.ts new file mode 100644 index 0000000..2a3c79e --- /dev/null +++ b/src/lib/azure/preflight.ts @@ -0,0 +1,78 @@ +import { ResponseBuilder } from "../../core/response-builder"; +import { isRunningInDocker } from "../localstack/localstack.utils"; +import { BootstrapError } from "./bootstrap"; +import { getAzureEmulatorStatus } from "./emulator"; +import { prepareStderr } from "./output"; +import { AzResolveError } from "./resolve-az"; +import { + AzCancelledError, + azCli, + azureConfig, + ensureProfile, + recordEmulatorSession, +} from "./services"; + +type ToolResponse = ReturnType; + +const cancelled = () => + ResponseBuilder.error("Command Cancelled", "The client cancelled the call, so `az` was stopped."); + +/** Settings the tool cannot run with: a config dir that overlaps ~/.azure, a bad timeout, ... */ +export function requireAzureConfig(): ToolResponse | null { + const { errors } = azureConfig(); + if (errors.length === 0) return null; + return ResponseBuilder.error( + "Azure Tool Configuration Error", + errors.map((error) => `- ${error}`).join("\n") + ); +} + +export async function requireAzureEmulatorRunning(): Promise { + const status = await getAzureEmulatorStatus(azureConfig()); + recordEmulatorSession(status.sessionId); + return status.ok + ? null + : ResponseBuilder.error("LocalStack Azure Emulator Not Ready", status.message); +} + +export async function requireAzureCli(signal?: AbortSignal): Promise { + try { + await azCli(signal); + return null; + } catch (error) { + if (error instanceof AzCancelledError) return cancelled(); + if (error instanceof AzResolveError && isRunningInDocker()) { + return ResponseBuilder.error( + "Azure CLI Not Available", + "This server's Docker image does not include the Azure CLI yet. To use the Azure tool, run the server with npx on a machine with the Azure CLI installed (see the README)." + ); + } + const reasons = + error instanceof AzResolveError && error.reasons.length > 0 + ? `\n\nSkipped:\n${error.reasons.map((reason) => `- ${reason}`).join("\n")}` + : ""; + const message = error instanceof Error ? error.message : String(error); + return ResponseBuilder.error("Azure CLI Not Available", message + reasons); + } +} + +/** Points the tool's own CLI profile at the emulator; a failed step comes with az's output. */ +export async function requireAzureCliConfigured( + signal?: AbortSignal +): Promise { + try { + await ensureProfile(signal); + return null; + } catch (error) { + if (error instanceof AzCancelledError) return cancelled(); + if (error instanceof BootstrapError && error.result.aborted) return cancelled(); + const details = + error instanceof BootstrapError + ? [error.message, prepareStderr(error.result.stderr), error.result.spawnError] + : [error instanceof Error ? error.message : String(error)]; + return ResponseBuilder.error( + "Azure CLI Profile Setup Failed", + details.filter(Boolean).join("\n\n") + ); + } +} diff --git a/src/lib/azure/resolve-az.test.ts b/src/lib/azure/resolve-az.test.ts new file mode 100644 index 0000000..536ac72 --- /dev/null +++ b/src/lib/azure/resolve-az.test.ts @@ -0,0 +1,74 @@ +import { AzResolveError, locateAz, parseAzVersion, type LocateAzContext } from "./resolve-az"; + +const PREFIX = ["-X", "utf8", "-W", "ignore::SyntaxWarning", "-IBm", "azure.cli"]; + +function locate(files: string[], over: Partial = {}) { + const present = new Set(files); + return locateAz({ + env: {}, + platform: "linux", + homedir: "/home/u", + isFile: (p) => present.has(p), + isExecutable: (p) => present.has(p), + ...over, + }); +} + +describe("locateAz", () => { + test("POSIX: the first az on PATH runs as it is, else a default location", () => { + const env = { PATH: "rel:/opt/a:/opt/b" }; + expect(locate(["/opt/b/az", "/usr/bin/az"], { env })).toEqual({ + file: "/opt/b/az", + prefixArgs: [], + }); + expect(locate(["/opt/homebrew/bin/az"], { env }).file).toBe("/opt/homebrew/bin/az"); + }); + + test("LOCALSTACK_AZ_PATH: a Python gets the module prefix; a bad path is an error", () => { + const azPath = "/opt/az/bin/python3"; + expect(locate([azPath], { azPath })).toEqual({ file: azPath, prefixArgs: PREFIX }); + expect(() => locate([], { azPath: "/missing/az" })).toThrow( + /absolute path to an existing file/ + ); + expect(() => locate(["az"], { azPath: "az" })).toThrow(/absolute path/); + }); + + test("skips a Windows az seen through WSL, which would use the user's own Windows profile", () => { + const wbin = "/mnt/c/Program Files/Microsoft SDKs/Azure/CLI2/wbin"; + const error = (() => { + try { + locate([`${wbin}/az`], { env: { PATH: wbin } }); + } catch (e) { + return e as AzResolveError; + } + })(); + expect(error).toBeInstanceOf(AzResolveError); + expect(error?.reasons[0]).toContain("Windows Azure CLI"); + expect(error?.message).toContain("brew install azure-cli"); + }); + + test("Windows: az.cmd and az.bat map to their Python; the MSI's location without PATH", () => { + const cli = "C:\\PF\\Microsoft SDKs\\Azure\\CLI2"; + const win = (files: string[], env: NodeJS.ProcessEnv) => + locate(files, { platform: "win32", env }); + expect(win([`${cli}\\wbin\\az.cmd`, `${cli}\\python.exe`], { Path: `"${cli}\\wbin"` })).toEqual( + { + file: `${cli}\\python.exe`, + prefixArgs: PREFIX, + azInstaller: "MSI", + } + ); + const venv = ["C:\\venv\\Scripts\\az.bat", "C:\\venv\\Scripts\\python.exe"]; + expect(win(venv, { PATH: "C:\\venv\\Scripts" })).toMatchObject({ + file: venv[1], + azInstaller: "PIP", + }); + expect(win([`${cli}\\python.exe`], { ProgramFiles: "C:\\PF" }).file).toBe(`${cli}\\python.exe`); + expect(() => win(["C:\\x\\az.cmd"], { PATH: "C:\\x" })).toThrow(AzResolveError); + }); +}); + +test("parseAzVersion", () => { + expect(parseAzVersion('{"azure-cli": "2.91.0", "azure-cli-core": "2.91.0"}')).toBe("2.91.0"); + expect(parseAzVersion("not json")).toBeUndefined(); +}); diff --git a/src/lib/azure/resolve-az.ts b/src/lib/azure/resolve-az.ts new file mode 100644 index 0000000..813773e --- /dev/null +++ b/src/lib/azure/resolve-az.ts @@ -0,0 +1,166 @@ +import { accessSync, constants, statSync } from "fs"; +import path from "path"; + +/** + * Finding `az`: LOCALSTACK_AZ_PATH, then PATH in order, then the default install locations. On + * POSIX the launcher runs as it is. On Windows `az.cmd` and `az.bat` cannot be spawned without a + * shell, so each is mapped to the CLI's own Python, run as ` -X utf8 -IBm azure.cli`: + * `-I` keeps a planted `azure/cli/__main__.py` in the working directory from running in place of + * `az`, and `-X utf8` keeps non-ASCII values intact under `-I`. + */ + +export interface AzExecutable { + /** The file spawned: the `az` launcher, or the CLI's own Python. */ + file: string; + /** Arguments before the az argv: the module prefix for a Python, none for a launcher. */ + prefixArgs: string[]; + /** AZ_INSTALLER for a Python spawned directly, which no launcher sets. */ + azInstaller?: "MSI" | "PIP"; + version?: string; +} + +export class AzResolveError extends Error { + constructor( + message: string, + public readonly reasons: string[] = [] + ) { + super(message); + this.name = "AzResolveError"; + } +} + +export const AZURE_CLI_INSTALL_OPTIONS = + "Install the Azure CLI: https://learn.microsoft.com/cli/azure/install-azure-cli\n\n" + + "- Windows: winget install --exact --id Microsoft.AzureCLI\n" + + "- macOS: brew install azure-cli\n" + + "- Debian or Ubuntu: curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash\n\n" + + "If it is installed where this tool does not look, set LOCALSTACK_AZ_PATH to its launcher or its Python."; + +// `-W ignore::SyntaxWarning`: some extensions' code warns when compiled, which would end up in +// every answer's stderr. +const PYTHON_PREFIX = ["-X", "utf8", "-W", "ignore::SyntaxWarning", "-IBm", "azure.cli"]; +const PYTHON_NAME = /^python(\d+(\.\d+)*)?w?(\.exe)?$/i; + +export interface LocateAzContext { + /** LOCALSTACK_AZ_PATH. */ + azPath?: string; + env: NodeJS.ProcessEnv; + platform: NodeJS.Platform; + homedir: string; + isFile(p: string): boolean; + /** POSIX: a regular file with an execute bit. */ + isExecutable(p: string): boolean; +} + +const succeeds = (check: () => boolean) => { + try { + return check(); + } catch { + return false; + } +}; + +export const nodeResolveFs: Pick = { + isFile: (p) => succeeds(() => statSync(p).isFile()), + isExecutable: (p) => succeeds(() => (accessSync(p, constants.X_OK), statSync(p).isFile())), +}; + +/** An environment variable, ignoring case on Windows, where `Path` and `PATH` are one variable. */ +function envValue(env: NodeJS.ProcessEnv, name: string, platform: NodeJS.Platform) { + if (platform !== "win32") return env[name]; + const key = Object.keys(env).find((k) => k.toUpperCase() === name); + return key === undefined ? undefined : env[key]; +} + +/** Windows: the Python behind a launcher, or why there is none. */ +function mapWindows(file: string, ctx: LocateAzContext): AzExecutable | string { + const P = path.win32; + const name = P.basename(file).toLowerCase(); + const dir = P.dirname(file); + if (PYTHON_NAME.test(name)) { + const msi = /microsoft sdks[\\/]azure[\\/]cli2/i.test(file); + return { file, prefixArgs: PYTHON_PREFIX, azInstaller: msi ? "MSI" : "PIP" }; + } + // The MSI's wbin\az.cmd runs ..\python.exe; pip's Scripts\az.bat the python.exe beside it (a + // venv) or one level up. + const candidates = + name === "az.cmd" + ? [P.join(dir, "..", "python.exe")] + : name === "az.bat" + ? [P.join(dir, "python.exe"), P.join(dir, "..", "python.exe")] + : []; + const python = candidates.find((p) => ctx.isFile(p)); + if (python) { + return { + file: python, + prefixArgs: PYTHON_PREFIX, + azInstaller: name === "az.cmd" ? "MSI" : "PIP", + }; + } + return `${file}: no python.exe next to it; set LOCALSTACK_AZ_PATH to the Azure CLI's python.exe`; +} + +/** POSIX: a Python gets the module prefix, a launcher runs as it is. */ +function mapPosix(file: string): AzExecutable | string { + // In WSL a Windows az would not receive AZURE_CONFIG_DIR, and the bootstrap would rewrite the + // user's own Windows profile. + if (/^\/mnt\/[a-z]\//i.test(file) || /\.(exe|cmd|bat)$/i.test(file)) { + return `${file}: a Windows Azure CLI, which would use your own Windows profile; install the Linux Azure CLI`; + } + return { file, prefixArgs: PYTHON_NAME.test(path.posix.basename(file)) ? PYTHON_PREFIX : [] }; +} + +export function locateAz(ctx: LocateAzContext): AzExecutable { + const win = ctx.platform === "win32"; + const P = win ? path.win32 : path.posix; + const map = (file: string) => (win ? mapWindows(file, ctx) : mapPosix(file)); + if (ctx.azPath) { + if (!P.isAbsolute(ctx.azPath) || !ctx.isFile(ctx.azPath)) { + throw new AzResolveError( + `LOCALSTACK_AZ_PATH must be an absolute path to an existing file (the az launcher or the Azure CLI's Python); got "${ctx.azPath}".` + ); + } + const mapped = map(ctx.azPath); + if (typeof mapped === "string") throw new AzResolveError(`LOCALSTACK_AZ_PATH: ${mapped}.`); + return mapped; + } + + const pathDirs = (envValue(ctx.env, "PATH", ctx.platform) ?? "") + .split(win ? ";" : ":") + .map((entry) => entry.trim().replace(/^"(.*)"$/, "$1")) + .filter((entry) => P.isAbsolute(entry)); + const names = win ? ["az.cmd", "az.bat"] : ["az"]; + const known = win + ? ["ProgramFiles", "ProgramFiles(x86)"] + .map((name) => envValue(ctx.env, name.toUpperCase(), ctx.platform)) + .filter((root): root is string => Boolean(root)) + .map((root) => P.join(root, "Microsoft SDKs", "Azure", "CLI2", "python.exe")) + : [ + "/usr/bin/az", + "/usr/local/bin/az", + "/opt/homebrew/bin/az", + P.join(ctx.homedir, ".local/bin/az"), + ]; + const reasons: string[] = []; + for (const file of [...pathDirs.flatMap((dir) => names.map((n) => P.join(dir, n))), ...known]) { + if (!(win ? ctx.isFile(file) : ctx.isExecutable(file))) continue; + const mapped = map(file); + if (typeof mapped !== "string") return mapped; + if (!reasons.includes(mapped)) reasons.push(mapped); + } + throw new AzResolveError( + `The Azure CLI (az) was not found. ${AZURE_CLI_INSTALL_OPTIONS}`, + reasons + ); +} + +/** The azure-cli-core version from `az version -o json`. */ +export function parseAzVersion(stdout: string): string | undefined { + try { + const versions = JSON.parse(stdout) as Record; + const version = versions["azure-cli-core"] ?? versions["azure-cli"]; + return typeof version === "string" ? version : undefined; + } catch { + return undefined; + } +} diff --git a/src/lib/azure/runner.test.ts b/src/lib/azure/runner.test.ts new file mode 100644 index 0000000..2cc4765 --- /dev/null +++ b/src/lib/azure/runner.test.ts @@ -0,0 +1,118 @@ +import os from "os"; +import { runAz } from "./runner"; + +// A Node script stands in for `az`: node -e