diff --git a/.devcontainer/README.md b/.devcontainer/README.md index e3b04445a..04d541e25 100755 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -45,11 +45,15 @@ are installed in the image layer. uv 0.12.23 and gitleaks 8.30.1 are pinned and `amd64` and `arm64` using [the download manifest](download-checksums.json). The existing tool updater does not update these pins; maintain their versions and checksums explicitly. +`post-create.sh` also installs azqr 4.2.0 to `~/.local/bin` with `install-azqr.sh`, verified against the same manifest. +Azure MCP 3.0.0 lists `extension_azqr` only while azqr 3.0.0 or later is on `PATH`. The install is optional: if it +fails, the compliance and cost skills fall back to running `azqr` directly. + ### MCP Servers (auto-configured in `.vscode/mcp.json`) | Server | Transport | Purpose | | ----------------- | ----------------- | ------------------------------------------------ | -| Azure Resource Manager MCP | http | Retail pricing and Azure cost management | +| Azure Resource Manager MCP | http | Azure cost management (retail pricing uses Azure MCP) | | GitHub MCP | http | Copilot-provided GitHub context | | Azure MCP Server | stdio (`npx`) | RBAC-aware Azure context for agents | diff --git a/.devcontainer/configure-mcp.mjs b/.devcontainer/configure-mcp.mjs index ab7374807..2b1d994be 100644 --- a/.devcontainer/configure-mcp.mjs +++ b/.devcontainer/configure-mcp.mjs @@ -10,12 +10,12 @@ const defaults = { "azure-resource-manager-mcp": { type: "http", url: "https://mcp.management.azure.com", - headers: { "x-mcp-toolset": "CostManagement, Pricing" }, + headers: { "x-mcp-toolset": "CostManagement" }, }, "azure-mcp": { type: "stdio", command: "npx", - args: ["-y", "@azure/mcp@2.0.5", "server", "start"], + args: ["-y", "@azure/mcp@3.0.0", "server", "start"], env: { NPM_CONFIG_ALLOW_REMOTE: "all" }, }, }; diff --git a/.devcontainer/download-checksums.json b/.devcontainer/download-checksums.json index 012ef8f4e..f9ab801ba 100644 --- a/.devcontainer/download-checksums.json +++ b/.devcontainer/download-checksums.json @@ -28,5 +28,20 @@ "checksumUrl": "https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_checksums.txt" } } + }, + "azqr": { + "version": "4.2.0", + "artifacts": { + "amd64": { + "url": "https://github.com/Azure/azqr/releases/download/v.4.2.0/azqr-linux-amd64.zip", + "sha256": "1cf2482520e9724761084eb3587100bb68f33cd98e15c065a0ec94d807821ee3", + "checksumUrl": "https://github.com/Azure/azqr/releases/download/v.4.2.0/azqr-linux-amd64.zip.sha256" + }, + "arm64": { + "url": "https://github.com/Azure/azqr/releases/download/v.4.2.0/azqr-linux-arm64.zip", + "sha256": "f7524d77a5b85ea34a4a2230b37404d9d0383824c5a01b1ae6b1c71c883e059d", + "checksumUrl": "https://github.com/Azure/azqr/releases/download/v.4.2.0/azqr-linux-arm64.zip.sha256" + } + } } } diff --git a/.devcontainer/install-azqr.sh b/.devcontainer/install-azqr.sh new file mode 100644 index 000000000..7cc8f9d36 --- /dev/null +++ b/.devcontainer/install-azqr.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Install the pinned azqr release verified against download-checksums.json; requires jq and unzip. +# azqr is optional: Azure MCP lists extension_azqr only when azqr 3.0.0 or later is on PATH. +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +MANIFEST="${SCRIPT_DIR}/download-checksums.json" +INSTALL_DIR="${AZQR_INSTALL_DIR:-${HOME}/.local/bin}" +ARCHITECTURE="$(dpkg --print-architecture)" + +case "$ARCHITECTURE" in + amd64 | arm64) ;; + *) echo "Unsupported architecture: ${ARCHITECTURE}; expected amd64 or arm64" >&2; exit 1 ;; +esac + +version="$(jq -er '.azqr.version' "$MANIFEST")" +if command -v azqr >/dev/null 2>&1 && azqr --version 2>&1 | grep -qF "$version"; then + echo "azqr ${version} already installed" + exit 0 +fi + +url="$(jq -er --arg arch "$ARCHITECTURE" '.azqr.artifacts[$arch].url' "$MANIFEST")" +checksum="$(jq -er --arg arch "$ARCHITECTURE" '.azqr.artifacts[$arch].sha256' "$MANIFEST")" +if [[ ! "$url" =~ ^https://github\.com/Azure/azqr/ || ! "$checksum" =~ ^[0-9a-f]{64}$ ]]; then + echo "Invalid download manifest entry: azqr/${ARCHITECTURE}" >&2 + exit 1 +fi + +WORK_DIR="$(mktemp -d)" +trap 'rm -rf -- "$WORK_DIR"' EXIT + +archive="${WORK_DIR}/azqr.zip" +curl --fail --silent --show-error --location --retry 3 \ + --proto '=https' --proto-redir '=https' --tlsv1.2 \ + --output "$archive" "$url" +printf '%s %s\n' "$checksum" "$archive" | sha256sum --check --strict +unzip -q -o "$archive" -d "${WORK_DIR}/extract" + +# The archive nests the binary under bin/linux_/. +binary="$(find "${WORK_DIR}/extract" -type f -name azqr | head -n 1)" +if [[ -z "$binary" ]]; then + echo "azqr binary not found in the downloaded archive" >&2 + exit 1 +fi +mkdir -p "$INSTALL_DIR" +install -m 0755 "$binary" "${INSTALL_DIR}/azqr" +echo "Installed azqr ${version} to ${INSTALL_DIR}/azqr" diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 33d3203ec..6b05dd9d7 100644 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -56,6 +56,9 @@ elif ! az bicep version --only-show-errors; then az bicep version --only-show-errors fi +printf "Installing pinned azqr for the Azure MCP extension_azqr tool...\n" +bash .devcontainer/install-azqr.sh || printf "azqr install skipped; compliance scans fall back to the azqr CLI.\n" + printf "Adding only missing MCP defaults...\n" node .devcontainer/configure-mcp.mjs node tools/scripts/validate-tool-versions.mjs diff --git a/.devcontainer/post-start.sh b/.devcontainer/post-start.sh index 7fb90be99..3750d28f2 100644 --- a/.devcontainer/post-start.sh +++ b/.devcontainer/post-start.sh @@ -30,4 +30,17 @@ else printf "not authenticated - run 'azd auth login'\n" fi +# An interactive rm/mv/cp alias can hang an agent terminal waiting for a prompt it never gets. +# post-start.sh itself is non-interactive, so .bashrc's aliases must be probed via -i. +interactive_aliases="" +for name in rm mv cp; do + if bash -i -c "alias $name" 2>/dev/null | grep -qE -- '-[a-zA-Z]*i[a-zA-Z]*'; then + interactive_aliases="$interactive_aliases $name" + fi +done +if [ -n "$interactive_aliases" ]; then + printf " WARNING: interactive shell alias(es) found:%s\n" "$interactive_aliases" + printf " Agent terminals inherit these; drop the -i flag (see dotfiles shell/.bashrc).\n" +fi + printf " Container ready\n\n" diff --git a/.github/agents/01-orchestrator.agent.md b/.github/agents/01-orchestrator.agent.md index 2b6078d2a..ee30b7e71 100644 --- a/.github/agents/01-orchestrator.agent.md +++ b/.github/agents/01-orchestrator.agent.md @@ -481,6 +481,10 @@ Do not require a session-state file before attempting recovery of existing work. the applicable approval gate or exact handoff, then stop. Do not ask who should choose the next step. 5. For an in-progress step or review, preserve its sub-step checkpoint in the handoff context; do not skip required review or approval evidence, and do not invoke the next agent directly. +6. **Reopening a completed step** (`start-step --force`) must leave later steps visibly stale. Confirm + `session.steps` marks every dependent step stale and `plan_status` is superseded; otherwise record + both with `apex-recall decide`. Regenerate `00-handoff.md` with `render-session-handoff` so Completed + Steps and carried findings survive; never hand-write a replacement. Report only verified state. **Starting a new chat thread mid-workflow?** The agent auto-detects progress via `apex-recall show --json`. Just invoke the diff --git a/.github/agents/02-requirements.agent.md b/.github/agents/02-requirements.agent.md index 4e5d3f57f..3db6f85bc 100644 --- a/.github/agents/02-requirements.agent.md +++ b/.github/agents/02-requirements.agent.md @@ -318,7 +318,9 @@ Use `askQuestions` for: - **`cost_monitoring_mode`** — surface this prompt **only when the selected environments include `dev` or `sandbox` and exclude `prod`/`staging`**. Options: `enforced` (recommended; full - budget+AG+anomaly), `minimal` (budget only, no AG, no anomaly), or + budget+AG+anomaly), `minimal` (budget only: no cost AG or anomaly alert; operational + alerts remain; budget notices reach only Owner-role holders, so say so when the named + recipient is not an Owner), or `deferred` (no cost-monitoring resources). When `deferred` is chosen, follow up with two required freeform prompts: `cost_monitoring_exception.rationale` and @@ -378,7 +380,9 @@ Use `askQuestions` for: - Additional security measures beyond the baseline with `multiSelect: true`, only when relevant. - Authentication method. - Region, defaulting to `swedencentral` unless service availability requires an exception. - +- Operations and ownership, batched in one call: how operators reach private services, who owns web + sign-in (platform or application team), the budget alert recipient and whether they hold Owner, + the SQL Entra admin principal, and tag values the target policy requires. Unanswered items block Step 1. Apply GDPR and data residency guardrails when relevant: - Flag global services such as Front Door, Entra External ID, Traffic Manager, and Azure DNS for diff --git a/.github/agents/03-architect.agent.md b/.github/agents/03-architect.agent.md index de58b1ee2..affa203a2 100644 --- a/.github/agents/03-architect.agent.md +++ b/.github/agents/03-architect.agent.md @@ -1,6 +1,6 @@ --- name: 03-Architect -description: Expert Architect providing guidance using Azure Well-Architected Framework principles and Microsoft best practices. Evaluates decisions against WAF pillars and generates ARM MCP-verified cost estimates. +description: Expert Architect providing guidance using Azure Well-Architected Framework principles and Microsoft best practices. Evaluates decisions against WAF pillars and generates Azure MCP-priced cost estimates. model: ["Claude Sonnet 5.5 (copilot)"] reasoning-effort: high user-invocable: true @@ -10,7 +10,7 @@ tools: [vscode/askQuestions, execute, read, agent, edit, search, web, 'azure-mcp handoffs: - label: "▶ Refresh Cost Estimate" agent: 03-Architect - prompt: "Re-query Azure Resource Manager MCP through cost-estimate-subagent to update current pricing and totals. Input: agent-output/{project}/02-architecture-assessment.md SKU list. Output: agent-output/{project}/03-des-cost-estimate.md." + prompt: "Re-query Azure MCP pricing through cost-estimate-subagent to update current pricing and totals. Input: agent-output/{project}/02-architecture-assessment.md SKU list. Output: agent-output/{project}/03-des-cost-estimate.md." send: true - label: "▶ Deep Dive WAF Pillar" agent: 03-Architect @@ -73,9 +73,9 @@ chart outputs; missing essential tools/models stop work rather than weakening ch - Without asking: research, WAF scoring, delegated pricing, charts, writing and validating the Step 2 outputs, and the required reviews. -- Needs the user: missing NFR, compliance or budget values; SKU confirmation before committed - pricing; non-standard SKU/tier choices or deviations from Well-Architected guidance; and the - final [Approval Gate](#approval-gate), which alone completes Step 2 and allows handoff. +- Needs the user: missing NFR, compliance or budget values; an over-budget owner decision (ask, never substitute); + SKU confirmation before committed pricing; non-standard SKU/tier choices or deviations from Well-Architected + guidance; and the final [Approval Gate](#approval-gate), which alone completes Step 2 and allows handoff. - Never: advance to the next step without that gate, or hand off directly to the IaC Planner. Deliver the requested Step 2 scope. Raise a better approach in one sentence instead of silently @@ -180,7 +180,7 @@ in a single form: | NFRs | SLA, RTO, RPO, performance targets | | Compliance | Regulatory frameworks | | Budget | Approximate monthly budget | -| Scale | Users, transactions, data volume | +| Scale | Users, transactions, data volume; operator access path, auth owner, alert recipient, SQL admin | ## Session State @@ -240,8 +240,9 @@ the bulk is authored. before pricing; record rejection reasons without requesting irrelevant rates. If one tier is forced, document why and include it in the confirmed full estimate; do not invent alternatives to satisfy a count. 2. **Call `cost-estimate-subagent` in `candidate_sets[]` mode** to price - A-vs-B _before_ committing. This comparison-only mode does not require SKU - approval and cannot write back the manifest or count as approved pricing. + A-vs-B _before_ committing: one call per `decision_id` (own `02-cost-candidates-{decision_id}.json`, no stale `.tmp`), + with exact meter hints and usage per the [candidate-set contract](../skills/apex-azure-defaults/references/cost-estimate-parent-contract.md#candidate-set-calls). + Comparison-only: no SKU approval, no manifest writeback, not approved pricing. Preserve user pins; a worker's cheapest candidate is advice, not an approved choice. 3. **Pick winners** for each decision; never change user-pinned entries (`source: user-pin`) — they are locked. @@ -359,13 +360,16 @@ in your WAF assessment recommendations (still produce the identical artifact str 6b. **VNet planning gate (MANDATORY when trigger contract holds; honor `decisions.vnet_planning_mode`)** — follow the protocol in [`workflow-gates.md`](../skills/apex-azure-defaults/references/workflow-gates.md#architect-step-2--phase-6b-vnet-planning-gate). - Append any priced network resources (Bastion / Firewall / - NAT-Gateway / VPN-Gateway / ER-Gateway / App-Gateway / - App-Gateway-for-Containers) from `subnet_plan` to the Step 7 - resource_list. -7. **Delegate approved pricing** — Send the confirmed manifest or resource list to `cost-estimate-subagent`; - receive verified prices. Precondition guard: refuse to invoke unless - `decisions.sku_confirmation_status == "approved"`. + Append any priced network and supporting non-manifest resources + (Bastion / Firewall / NAT-Gateway / VPN-Gateway / ER-Gateway / + App-Gateway / App-Gateway-for-Containers / Private Endpoints / + Private DNS / Key Vault / Log Analytics / Application Insights / + Alert Rules / Action Groups / VM OS disk / Public IPs) to + `supplemental_resources[]` in the Step 7 `manifest_path` request. +7. **Delegate approved pricing** — Run `node tools/scripts/cost-estimate.mjs preflight` on the request, then send + `manifest_path` + `supplemental_resources[]` + `manifest_usage` in a single `cost-estimate-subagent` call writing + `agent-output/{project}/02-cost-estimate.json`; receive verified prices and manifest writeback. Precondition guard: + refuse to invoke unless `decisions.sku_confirmation_status == "approved"`. 8. **Generate assessment** — Save `02-architecture-assessment.md` with subagent-sourced prices. The **WAF Cost** / **WAF Operational Excellence** sections MUST @@ -410,6 +414,7 @@ in your WAF assessment recommendations (still produce the identical artifact str for architecture and the separate cost estimate before presenting final approval. 14. **Approval gate** — follow [Approval Gate](#approval-gate) and resolve blocking findings before completion and handoff. Budget or SKU approval alone does not complete Step 2. + First archive superseded or `FAILED` files to `_meta/` and delete `02-waf-research.tmp.md`. ## Cost Estimation @@ -600,28 +605,9 @@ Do not narrate routine tool calls. ## Validation Checklist -- [ ] All 5 WAF pillars scored with rationale and confidence level -- [ ] Service Maturity Assessment table included -- [ ] Cost estimate uses worker-verified MCP data or the documented direct-API evidence fallback, with truthful sources -- [ ] **Every dollar figure** in 02 and 03 artifacts traces back to `cost-estimate-subagent` response — no hardcoded prices -- [ ] Line-item totals sum correctly to reported monthly total -- [ ] H2 headings match apex-azure-artifacts templates exactly -- [ ] Region selection justified (default: swedencentral) -- [ ] AVM modules recommended where available -- [ ] No deprecated services recommended (checked against apex-azure-defaults Deprecated Services table) -- [ ] Service retirement timelines verified for any multi-year RI commitments -- [ ] Storage redundancy tier compatible with data residency requirements (no GRS with single-region GDPR) -- [ ] Global/non-regional services (Front Door, Entra, Traffic Manager) flagged for EU Data Boundary compliance -- [ ] SKU zone-redundancy capabilities verified for all services claiming AZ support - -### WAF scoring table format -Illustrative structure only; compute every score and confidence from project evidence, never from examples. - -```markdown -| WAF Pillar | Score | Confidence | Key Factor | -| --- | --- | --- | --- | -| {pillar} | {evidence-based score}/10 | {confidence} | {verified factor} | -``` +Before the Approval Gate, load +[`architect-validation-checklist.md`](../skills/apex-azure-defaults/references/architect-validation-checklist.md) +once and satisfy every item and its WAF scoring table format. ## Completion Handoff diff --git a/.github/agents/04-design.agent.md b/.github/agents/04-design.agent.md index ce9a9068f..561e3c5b8 100644 --- a/.github/agents/04-design.agent.md +++ b/.github/agents/04-design.agent.md @@ -18,7 +18,7 @@ handoffs: send: false - label: "▶ Generate Cost Estimate" agent: 03-Architect - prompt: "Generate a detailed cost estimate through cost-estimate-subagent and Azure Resource Manager MCP. Save it to agent-output/{project}/03-des-cost-estimate.md." + prompt: "Generate a detailed cost estimate through cost-estimate-subagent and Azure MCP pricing. Save it to agent-output/{project}/03-des-cost-estimate.md." send: false - label: "Step 3.5: Governance Discovery" agent: 04g-Governance diff --git a/.github/agents/04g-governance.agent.md b/.github/agents/04g-governance.agent.md index 2e8b8d7f1..b5072421c 100644 --- a/.github/agents/04g-governance.agent.md +++ b/.github/agents/04g-governance.agent.md @@ -165,7 +165,8 @@ Reuse current content; recover missing/changed evidence after compaction or resu 2. Run `apex-recall show --json` to verify project context exists (project name, complexity, decisions) 3. **Read the committed baseline subscription entry when present**. It is comparison evidence, not live policy authority or a prerequisite for live discovery. - A missing baseline routes to live discovery; never fabricate an entry. Determine the target + A missing baseline routes to live discovery; never fabricate an entry. An `alz-corp-default` + baseline has no entry for the target subscription; follow Phase 0.45 instead. Determine the target subscription ID from the architecture, load the entire `.github/data/governance-policy-baseline.json.gz → subscriptions[]` object into context for baseline selection/comparison. The subscription diff --git a/.github/agents/05-iac-planner.agent.md b/.github/agents/05-iac-planner.agent.md index 5d3eeea41..1d911eed1 100644 --- a/.github/agents/05-iac-planner.agent.md +++ b/.github/agents/05-iac-planner.agent.md @@ -153,8 +153,8 @@ and the saved checkpoint; read only the predecessor sections the current phase n ## IaC Track Detection Run `apex-recall show --json` and check `decisions.iac_tool`; ask the user when it is unset. -`"Bicep"` uses the Bicep AVM metadata tools and patterns. `"Terraform"` uses the public Terraform Registry API -and Terraform patterns; no Terraform MCP server is required. +`"Bicep"` uses Bicep AVM metadata tools and patterns. `"Terraform"` uses Azure MCP `azureterraform` lookup +with the public Terraform Registry API as fallback and the AVM module index for existence; plus Terraform patterns. ## Read Skills First @@ -368,7 +368,7 @@ Build **one structured `askQuestions` panel** combining: Single-shot panel: one `askQuestions` call with all questions. Omit any question whose key already appears in `apex-recall show ` -decisions (resume support). +decisions (resume support). Show the architecture's value per question (see reference). Persist each answer (MANDATORY) — one `apex-recall decide` call per key: diff --git a/.github/agents/06t-terraform-codegen.agent.md b/.github/agents/06t-terraform-codegen.agent.md index a77a2aeaf..a29a4e90d 100644 --- a/.github/agents/06t-terraform-codegen.agent.md +++ b/.github/agents/06t-terraform-codegen.agent.md @@ -322,15 +322,17 @@ For EACH resource in `04-iac-contract.json#resources[]` (canonical source; `04-implementation-plan.md` is the prose mirror): 1. Read the approved source and exact version from `04-iac-contract.json#modules.terraform[]`. -2. Query the public Terraform Registry API for that pinned module version; - inspect inputs, outputs, and linked source. No Terraform MCP server is required. +2. Retrieve the pinned module version's README with the Azure MCP `azureterraform` `avm get` command + (`--module-name`, `--module-version`); fall back to the public Terraform Registry API when the tool is + unavailable. Inspect inputs, outputs, and linked source. 3. Cross-check the approved pins and parameter types against the retrieved schema; flag mismatches using the AVM Known Pitfalls in the Terraform patterns skill. 4. Preserve the approved exact `X.Y.Z` module version. Do not replace it with a version range or select a newer release. An unavailable pin or required version change returns to the Planner under the existing plan-lock contract. -5. For non-AVM resources, verify arguments against the pinned provider's official - Registry documentation and `terraform providers schema -json` after isolated +5. For non-AVM resources, verify arguments against the pinned provider's documentation (Azure MCP + `azureterraform` `azurerm get` or `azapi get`, else the official Registry docs) and + `terraform providers schema -json` after isolated `terraform init -backend=false -input=false`. Do not infer missing schemas from memory. 6. Check region limitations 7. Save to `agent-output/{project}/04-preflight-check.md` diff --git a/.github/agents/08-as-built.agent.md b/.github/agents/08-as-built.agent.md index 34ae0c95b..64dba07c4 100644 --- a/.github/agents/08-as-built.agent.md +++ b/.github/agents/08-as-built.agent.md @@ -43,7 +43,7 @@ Done when: - As-built architecture diagram produced as `07-ab-diagram.py` with `.png` and `.svg` siblings through the shared `diagram_io` helper. - Cost estimate values come verbatim from `cost-estimate-subagent` (no - hardcoded prices and no direct ARM MCP pricing calls from this agent). + hardcoded prices and no direct Azure MCP pricing calls from this agent). - Resource inventory matches what Azure Resource Graph reports for the project's resource group(s); no orphan resources, no missing items. - Compliance matrix and backup/DR plan reflect actual deployed configuration, @@ -116,7 +116,7 @@ This agent generates documentation and diagrams only. Deliver the requested as-b raise a better approach in one sentence instead of silently widening, narrowing or transforming the task. - Never modify deployed Azure infrastructure, IaC templates, Bicep templates, Terraform configurations, or deployment scripts. -- Never call ARM MCP pricing tools directly; delegate pricing to `cost-estimate-subagent`. +- Never call Azure MCP pricing tools directly; delegate pricing to `cost-estimate-subagent`. - Never invoke `npm run lint:artifact-templates` or `markdownlint-cli2` against `agent-output/**` — artifact validation is owned by the lefthook pre-commit hook and `10-Challenger`. @@ -129,7 +129,7 @@ raise a better approach in one sentence instead of silently widening, narrowing - If `06-deployment-summary.md` is missing or recall does not show Step 6 complete, STOP and ask the user to finish the deploy step. A human can override after confirming the deployment state in chat. - Hardcoding prices is prohibited: always delegate to `cost-estimate-subagent`. -- Calling ARM MCP pricing tools directly from this agent is prohibited; the +- Calling Azure MCP pricing tools directly from this agent is prohibited; the cost subagent owns all pricing queries. - Record `decisions.diagram_tool=python` before diagram work begins. - Use the shared `diagram_io` helper so both `.png` @@ -247,7 +247,7 @@ The full Step 7 suite still requires every listed output before completion. deployed diagram feel unfinished or improvised - **Hardcoding prices** — ALL prices in `07-ab-cost-estimate.md` MUST originate from `cost-estimate-subagent` responses -- **Calling ARM MCP pricing tools directly** — delegate all pricing to `cost-estimate-subagent` +- **Calling Azure MCP pricing tools directly** — delegate all pricing to `cost-estimate-subagent` ## As-Built Diagram Workflow @@ -416,7 +416,9 @@ As-built variants of the parent contract: - **Resource source (step 1)**: query the **actually deployed** environment via `az resource list` + Azure Resource Graph — never - re-use the planned resource list from Step 4. + re-use the planned resource list from Step 4. Run + `node tools/scripts/cost-estimate.mjs preflight --request ` + before delegating so variable-usage assumptions are verified upfront. - **Output path (step 2)**: `agent-output/{project}/07-ab-cost-estimate.json` - **Checkpoint (step 3)**: `apex-recall checkpoint 7 phase_3_pricing --json` - **Cross-check (step 5)**: also compare `monthly_total` against diff --git a/.github/agents/_subagents/cost-estimate-subagent.agent.md b/.github/agents/_subagents/cost-estimate-subagent.agent.md index 3d0769376..c30791008 100644 --- a/.github/agents/_subagents/cost-estimate-subagent.agent.md +++ b/.github/agents/_subagents/cost-estimate-subagent.agent.md @@ -1,20 +1,20 @@ --- name: cost-estimate-subagent -description: Azure cost estimation subagent. Uses Azure Resource Manager MCP retail pricing and cost data, then returns a structured cost breakdown through a file-based contract. +description: Azure cost estimation subagent. Uses Azure MCP pricing (pricing_get) retail data only, then returns a structured cost breakdown through a file-based contract. model: ["GPT-6 Luna (copilot)"] reasoning-effort: max user-invocable: false disable-model-invocation: false agents: [] -tools: [execute, read, edit, search, "azure-resource-manager-mcp/get_retail_prices", "azure-resource-manager-mcp/query_costs", "azure-resource-manager-mcp/query_aks_costs", "azure-resource-manager-mcp/forecast_costs", "azure-resource-manager-mcp/list_dimensions", "azure-resource-manager-mcp/list_benefit_utilization", "azure-resource-manager-mcp/get_benefit_recommendations"] +tools: [execute, read, edit, search, "azure-mcp/pricing"] --- # cost-estimate-subagent ## Role -Price planned Azure resources with the official Azure Resource Manager MCP -server first; use only the documented public Retail Prices API fallback for unresolved meters after MCP failure. +Price planned Azure resources with the Azure MCP `pricing` tool (`pricing_get`) only; +use only the documented public Retail Prices API fallback for unresolved meters after MCP failure. Parent agents provide paths and receive only a compact summary; write the full result to `output_path`. @@ -31,7 +31,9 @@ Callers: Architect (planned estimates) | As-Built (deployed estimates). Preserve all SKU choices, pins, revisions and unrelated user edits. No other artifact, recall or Azure writes. - Use editing tools for JSON and #tool:execute only for arithmetic, validation, atomic rename, - and the documented `fetch-retail-price-evidence.mjs` helper. The helper alone may write its validated + and the documented `cost-estimate.mjs` and `fetch-retail-price-evidence.mjs` helpers. Never run inline + double-quoted `node -e "... !..."` commands. Never call `mcp_azure_mcp_ser_pricing` with `learn: true`; + invoke `command: "pricing_get"` (`learn: false`) directly. The evidence helper alone may write its validated direct-API evidence output; it never writes the estimate. Terminal access is not inherently read-only. - No questions, todos, delegation or automatic model fallback. Missing essential tools/model/inputs return FAILED with `unresolved_items[]`; if persistence is @@ -49,14 +51,17 @@ Exactly one input mode must be supplied: - `resource_list`: `[{ name?, service_name, sku, region, quantity, usage?, meter_name? }]` - `manifest_path`: path to `sku-manifest.json`; expand effective services across environments, regions, and stamps using the canonical pricing guidance. - Optional `manifest_writeback` defaults to `true`. + Optional `manifest_writeback` defaults to `true`. Optional `manifest_usage` + supplies per-service variable usage keyed by service `id` (or `id:env:region`). + Optional `supplemental_resources` (`resource_list`-shaped entries for non-manifest + architecture resources) are priced in the same run and included in `resources[]`, + `monthly_total`, and `yearly_total`, but excluded from `manifest_writeback[]`. - `candidate_sets`: `[{ decision_id, candidates: [{ label, service_name, sku, region, quantity, usage?, meter_name?, notes? }] }]`. Common inputs: `project_name`, `region`, `output_path`, and `overwrite` (default -`false`). Optional: `compare_regions`, `include_ri_savings`, `scope`, and -`deployed` (default `false`). Multiple input modes or a missing required field -must produce `FAILED` with a specific `unresolved_items[]` entry. +`false`). Optional: `compare_regions` and `include_ri_savings`. Multiple input modes +or a missing required field must produce `FAILED` with a specific `unresolved_items[]` entry. Optional `recovery` supplies `draft_path`, `evidence_paths`, `remaining_requests` and `prior_attempt` context for a parent-authorized interrupted attempt, while retaining exactly one original pricing input mode. @@ -66,29 +71,30 @@ After validation, publish without new queries when evidence is sufficient; cance ## Required references -After input validation, read these before pricing using available tools. Reuse unchanged +After input validation, read this reference before pricing using available tools. Reuse unchanged content and recover missing/changed evidence after compaction; no skill digest tier: - `../../skills/apex-azure-defaults/references/pricing-guidance.md` -- `../../skills/apex-azure-artifacts/templates/03-des-cost-estimate.template.md` -The pricing guidance is the canonical source for ARM MCP parameter names, -service names, region handling, meter selection, usage units, and calculations. +The pricing guidance is the canonical source for `pricing_get` parameters and `filter` syntax, +service names, region handling, meter selection, usage units, and calculations. Do not read +Markdown report templates (`03-des-cost-estimate.template.md`) — this worker emits JSON only. ## Workflow -1. Validate the input mode and refuse an existing `output_path` unless - `overwrite: true`. Reuse an existing COMPLETE result without writing only - when the guidance's freshness and equivalence checks pass; otherwise report - that a refresh requires overwrite authorization. +1. Validate the input mode (using `node tools/scripts/cost-estimate.mjs preflight` when a request JSON is present) + and refuse an existing `output_path` unless `overwrite: true`. Reuse an existing COMPLETE result without writing only + when the guidance's freshness and equivalence checks pass; otherwise report that a refresh requires overwrite authorization. 2. Normalize each line using the canonical guidance. Do not guess aliases that are not documented there. Deployment tiers are not automatically catalog `armSkuName` values; use service/region discovery for tier/operation meters and share results across candidate tiers. 3. Group identical `(serviceName, armSkuName, armRegionName, meterName, - priceType, currencyCode)` queries. Call `get_retail_prices` once per distinct + priceType, currencyCode)` queries. Call `pricing_get` (`command: "pricing_get"`, `learn: false`) once per distinct group and reuse the returned rows across quantities and candidates. + Express meter filters through its OData `filter`; it has no `meterName` input and a bare `service` can be rejected. Keep a query ledger including empty/error results; do not repeat successful or empty identical requests. - After documented empty-result discovery or an exhausted transient retry, use the + After documented empty-result discovery, an exhausted transient retry, or a status-200 multi-band response that + omits `tierMinimumUnits` (`missing_tier_metadata`), use the [constrained fallback](../../skills/apex-azure-defaults/references/pricing-guidance.md#constrained-direct-api-fallback) only for unresolved meters and within the remaining shared request budget. 4. Select only rows matching the requested product, meter, unit, OS, and price @@ -97,20 +103,19 @@ service names, region handling, meter selection, usage units, and calculations. `unitOfMeasure`, quantity, and explicit usage. Use 730 hours per month only for hourly meters. Sum separate meters when a service bills across compute, storage, requests, or transfer. -6. When `deployed: true` and `scope` is supplied, `query_costs` may provide - actual cost context. Use `query_aks_costs` only for deployed AKS breakdowns. - These tools never replace `get_retail_prices` for hypothetical resources. -7. If requested, use `forecast_costs` or benefit tools only for authenticated, - deployed scopes. Region comparison means repeating the same retail query for - the requested regions; do not recommend a region that violates requirements. -8. Write the JSON atomically through `{output_path}.tmp`, validate totals and - status, then rename it to `output_path`. - Validate JSON syntax before rename; if an existing temporary sibling is not owned - by this invocation, return FAILED rather than overwrite it or unrelated user work. -9. In manifest mode with `manifest_writeback: true`, atomically update only - `cost_estimate_monthly_usd` and `cost_estimated_at` when status is COMPLETE. Sum each service's deployment - lines across environments, regions, and stamps; exclude comparison-only - candidates. Preserve the original query timestamp when reusing evidence. +6. Actual-cost, forecast and benefit analysis are out of scope; this worker prices from the retail catalog only. +7. If `include_ri_savings` is requested, use `include-savings-plan` or the `Reservation` price type. + Region comparison means repeating the same retail query for the requested regions; + do not recommend a region that violates requirements. +8. Write the JSON draft to `{output_path}.tmp` using editing tools, then run + `node tools/scripts/cost-estimate.mjs publish --draft .tmp --output [--manifest ]` + to normalize totals, validate JSON syntax and schema/evidence rules, atomically rename to `output_path`, + and print the compact summary. If an existing temporary sibling is not owned + by this invocation, return FAILED rather than overwrite it or unrelated user work. +9. In manifest mode with `manifest_writeback: true`, `cost-estimate.mjs publish --manifest ` + atomically updates only `cost_estimate_monthly_usd` and `cost_estimated_at` for manifest-owned `services[]` + when status is COMPLETE (excluding `supplemental_resources[]` and comparison-only candidates). + Preserve the original query timestamp when reusing evidence. 10. Return the compact parent summary. Never paste the full JSON into chat. ## Query budget @@ -134,7 +139,7 @@ Finishing all required evidence on request 20 is within budget; exhaustion block ## Meter rules -- Treat `get_retail_prices` as raw catalog data, not a computed estimate. +- Treat `pricing_get` as raw catalog data, not a computed estimate. - Require explicit usage for non-hourly meters. Do not turn missing usage into zero cost. - Free resources may be recorded as zero only when a matching returned meter has @@ -193,7 +198,7 @@ Write this shape to `output_path`: "savings_status": "QUANTIFIED | NOT_QUANTIFIED | NOT_APPLICABLE", "savings_reason": "reason", "eligible_strategies": [], - "data_source": "Azure Resource Manager MCP get_retail_prices", + "data_source": "Azure MCP pricing_get", "queried_at": "ISO 8601 timestamp", "confidence": "High | Medium | Low", "unresolved_items": [], @@ -213,7 +218,7 @@ comparison advice only; it is not SKU approval and never changes the manifest. When fallback is used, also emit `direct_api_calls_used` and `retail_api_evidence[]` entries with `evidence_path`, `resource_names`, `selected_meters` (meter ID, SKU, billing region and tier), and `calculation`. Reference the helper's raw responses, original MCP failure and timestamps; do not duplicate raw catalogs in the estimate. -Set `data_source` to `Azure Resource Manager MCP + Azure Retail Prices API (direct fallback)` for mixed results, +Set `data_source` to `Azure MCP pricing_get + Azure Retail Prices API (direct fallback)` for mixed results, or the direct source label if every priced meter used fallback. Identify the source in each affected line's notes. Do not label direct API data as MCP-verified. Evidence paths must remain available alongside the estimate. @@ -246,7 +251,7 @@ budget_exceeded: {true | false} - Authentication or authorization failure: return FAILED with the Azure scope and missing access described, without exposing tokens or tenant details. - API timeout: retry once, then fail the affected lines. -- Unsupported custom capability: state that ARM MCP does not provide it. Do not +- Unsupported custom capability: state that Azure MCP `pricing_get` does not provide it. Do not recreate Databricks, GitHub, PTU sizing, Spot history, orphan detection, customer discounts, fuzzy SKU discovery, or custom bulk-estimate behavior. diff --git a/.github/data/avm-bicep-modules.csv b/.github/data/avm-bicep-modules.csv index cd0e4a01b..4fedcde8f 100644 --- a/.github/data/avm-bicep-modules.csv +++ b/.github/data/avm-bicep-modules.csv @@ -24,7 +24,7 @@ Microsoft.ApiManagement,service/caches,API Management Service - Cache,,avm/res/a Microsoft.ApiManagement,service/diagnostics,API Management Service - Diagnostics,,avm/res/api-management/service/diagnostics,avm/res/api-management/service,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/api-management/service/diagnostics,br/public:avm/res/api-management/service/diagnostics:X.Y.Z,46d3xbcp.res.e1fc71d,abhishekaryams,Abhishek Arya,tony-box,Tony Box,,AVM Child Module for API Management Service - Diagnostics,,2026-05 Microsoft.ApiManagement,service/identityProviders,API Management Service - Identity Provider,,avm/res/api-management/service/identity-provider,avm/res/api-management/service,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/api-management/service/identity-provider,br/public:avm/res/api-management/service/identity-provider:X.Y.Z,46d3xbcp.res.deef1dc,abhishekaryams,Abhishek Arya,tony-box,Tony Box,,AVM Child Module for API Management Service - Identity Provider,,2025-09 Microsoft.ApiManagement,service/loggers,API Management Service - Logger,,avm/res/api-management/service/logger,avm/res/api-management/service,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/api-management/service/logger,br/public:avm/res/api-management/service/logger:X.Y.Z,46d3xbcp.res.9585b26,abhishekaryams,Abhishek Arya,tony-box,Tony Box,,AVM Child Module for API Management Service - Logger,,2025-09 -Microsoft.ApiManagement,service/namesValues,API Management Service - Named Value,,avm/res/api-management/service/named-value,avm/res/api-management/service,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/api-management/service/named-value,br/public:avm/res/api-management/service/named-value:X.Y.Z,46d3xbcp.res.5af41aa,abhishekaryams,Abhishek Arya,tony-box,Tony Box,,AVM Child Module for API Management Service - Named Value,,2025-09 +Microsoft.ApiManagement,service/namedValues,API Management Service - Named Value,,avm/res/api-management/service/named-value,avm/res/api-management/service,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/api-management/service/named-value,br/public:avm/res/api-management/service/named-value:X.Y.Z,46d3xbcp.res.5af41aa,abhishekaryams,Abhishek Arya,tony-box,Tony Box,,AVM Child Module for API Management Service - Named Value,,2025-09 Microsoft.ApiManagement,service/policies,API Management Service - Policy,,avm/res/api-management/service/policy,avm/res/api-management/service,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/api-management/service/policy,br/public:avm/res/api-management/service/policy:X.Y.Z,46d3xbcp.res.6a49d00,abhishekaryams,Abhishek Arya,tony-box,Tony Box,,AVM Child Module for API Management Service - Policy,,2025-09 Microsoft.ApiManagement,service/portalsettings,API Management Service - Portal Setting,,avm/res/api-management/service/portalsetting,avm/res/api-management/service,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/api-management/service/portalsetting,br/public:avm/res/api-management/service/portalsetting:X.Y.Z,46d3xbcp.res.ab98503,abhishekaryams,Abhishek Arya,tony-box,Tony Box,,AVM Child Module for API Management Service - Portal Setting,,2025-09 Microsoft.ApiManagement,service/privateEndpointConnections,API Management Service - Private Endpoint Connections,,avm/res/api-management/service/private-endpoint-connection,avm/res/api-management/service,Proposed,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/api-management/service/private-endpoint-connection,br/public:avm/res/api-management/service/private-endpoint-connection:X.Y.Z,46d3xbcp.res.786b254,abhishekaryams,Abhishek Arya,tony-box,Tony Box,,AVM Child Module for API Management Service - Private Endpoint Connections,, @@ -62,11 +62,11 @@ Microsoft.App,managedEnvironments/certificates,App Managed Environment - Certifi Microsoft.App,managedEnvironments/dotNetComponents,App Managed Environment - .NET Component,,avm/res/app/managed-environment/dot-net-component,avm/res/app/managed-environment,Proposed,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/app/managed-environment/dot-net-component,br/public:avm/res/app/managed-environment/dot-net-component:X.Y.Z,46d3xbcp.res.d11fde2,abhishekaryams,Abhishek Arya,,,,AVM Child Module for App Managed Environment - .NET Component,, Microsoft.App,managedEnvironments/storages,App Managed Environment - Storage,,avm/res/app/managed-environment/storage,avm/res/app/managed-environment,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/app/managed-environment/storage,br/public:avm/res/app/managed-environment/storage:X.Y.Z,46d3xbcp.res.fd1e2c8,abhishekaryams,Abhishek Arya,,,,AVM Child Module for App Managed Environment - Storage,Bulk-added child module,2026-05 Microsoft.App,sessionPools,App Session Pool,,avm/res/app/session-pool,n/a,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/app/session-pool,br/public:avm/res/app/session-pool:X.Y.Z,46d3xbcp.res.31b032b,abhishekaryams,Abhishek Arya,,,,AVM Resource Module for App Session Pool,,2025-02 -Microsoft.Authorization,policyAssignments,Authorization - Policy Assignment,,avm/res/authorization/policy-assignment,n/a,Proposed,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/authorization/policy-assignment,br/public:avm/res/authorization/policy-assignment:X.Y.Z,46d3xbcp.res.278b004,AlexanderSehr,Alexander Sehr,arnoldna,Nate Arnold,,AVM Resource Module for Authorization - Policy Assignment,, +Microsoft.Authorization,policyAssignments,Authorization - Policy Assignment,,avm/res/authorization/policy-assignment,n/a,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/authorization/policy-assignment,br/public:avm/res/authorization/policy-assignment:X.Y.Z,46d3xbcp.res.278b004,AlexanderSehr,Alexander Sehr,arnoldna,Nate Arnold,,AVM Resource Module for Authorization - Policy Assignment,, Microsoft.Authorization,policyAssignments,Authorization - Policy Assignment - Management Group Scope,,avm/res/authorization/policy-assignment/mg-scope,avm/res/authorization/policy-assignment,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/authorization/policy-assignment/mg-scope,br/public:avm/res/authorization/policy-assignment/mg-scope:X.Y.Z,46d3xbcp.res.5a0bcb4,AlexanderSehr,Alexander Sehr,arnoldna,Nate Arnold,,AVM Child Module for Authorization - Policy Assignment - Management Group Scope,,2025-09 Microsoft.Authorization,policyAssignments,Authorization - Policy Assignment - Resource Group Scope,,avm/res/authorization/policy-assignment/rg-scope,avm/res/authorization/policy-assignment,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/authorization/policy-assignment/rg-scope,br/public:avm/res/authorization/policy-assignment/rg-scope:X.Y.Z,46d3xbcp.res.309acb4,AlexanderSehr,Alexander Sehr,arnoldna,Nate Arnold,,AVM Child Module for Authorization - Policy Assignment - Resource Group Scope,,2025-09 Microsoft.Authorization,policyAssignments,Authorization - Policy Assignment - Subscription Scope,,avm/res/authorization/policy-assignment/sub-scope,avm/res/authorization/policy-assignment,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/authorization/policy-assignment/sub-scope,br/public:avm/res/authorization/policy-assignment/sub-scope:X.Y.Z,46d3xbcp.res.b242ce4,AlexanderSehr,Alexander Sehr,arnoldna,Nate Arnold,,AVM Child Module for Authorization - Policy Assignment - Subscription Scope,,2025-09 -Microsoft.Authorization,roleAssignments,Authorization - Role Assignment,,avm/res/authorization/role-assignment,n/a,Proposed,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/authorization/role-assignment,br/public:avm/res/authorization/role-assignment:X.Y.Z,46d3xbcp.res.cae0fee,arnoldna,Nate Arnold,,,,AVM Resource Module for Authorization - Role Assignment,, +Microsoft.Authorization,roleAssignments,Authorization - Role Assignment,,avm/res/authorization/role-assignment,n/a,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/authorization/role-assignment,br/public:avm/res/authorization/role-assignment:X.Y.Z,46d3xbcp.res.cae0fee,arnoldna,Nate Arnold,,,,AVM Resource Module for Authorization - Role Assignment,, Microsoft.Authorization,roleAssignments,Authorization - Role Assignment - Management Group Scope,,avm/res/authorization/role-assignment/mg-scope,avm/res/authorization/role-assignment,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/authorization/role-assignment/mg-scope,br/public:avm/res/authorization/role-assignment/mg-scope:X.Y.Z,46d3xbcp.res.83062ce,arnoldna,Nate Arnold,,,,AVM Child Module for Authorization - Role Assignment - Management Group Scope,,2025-06 Microsoft.Authorization,roleAssignments,Authorization - Role Assignment - Resource Group Scope,,avm/res/authorization/role-assignment/rg-scope,avm/res/authorization/role-assignment,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/authorization/role-assignment/rg-scope,br/public:avm/res/authorization/role-assignment/rg-scope:X.Y.Z,46d3xbcp.res.4838972,arnoldna,Nate Arnold,,,,AVM Child Module for Authorization - Role Assignment - Resource Group Scope,,2025-06 Microsoft.Authorization,roleAssignments,Authorization - Role Assignment - Subscription Scope,,avm/res/authorization/role-assignment/sub-scope,avm/res/authorization/role-assignment,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/authorization/role-assignment/sub-scope,br/public:avm/res/authorization/role-assignment/sub-scope:X.Y.Z,46d3xbcp.res.f306b2b,arnoldna,Nate Arnold,,,,AVM Child Module for Authorization - Role Assignment - Subscription Scope,,2025-06 @@ -167,7 +167,7 @@ Microsoft.Databricks,accessConnectors,Azure Databricks Access Connector,,avm/res Microsoft.Databricks,workspaces,Azure Databricks Workspace,,avm/res/databricks/workspace,n/a,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/databricks/workspace,br/public:avm/res/databricks/workspace:X.Y.Z,46d3xbcp.res.778c711,abhishekaryams,Abhishek Arya,,,,AVM Resource Module for Azure Databricks Workspace,,2024-01 Microsoft.DBforMySQL,flexibleServers,DB for MySQL Flexible Server,,avm/res/db-for-my-sql/flexible-server,n/a,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/db-for-my-sql/flexible-server,br/public:avm/res/db-for-my-sql/flexible-server:X.Y.Z,46d3xbcp.res.67b5df5,abhishekaryams,Abhishek Arya,,,,AVM Resource Module for DB for MySQL Flexible Server,,2024-01 Microsoft.DBforMySQL,flexibleServers/administrators,DB for MySQL Flexible Server - Administrator,,avm/res/db-for-my-sql/flexible-server/administrator,avm/res/db-for-my-sql/flexible-server,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/db-for-my-sql/flexible-server/administrator,br/public:avm/res/db-for-my-sql/flexible-server/administrator:X.Y.Z,46d3xbcp.res.c6d0414,abhishekaryams,Abhishek Arya,,,,AVM Child Module for DB for MySQL Flexible Server - Administrator,Bulk-added child module,2026-05 -Microsoft.DBforMySQL,flexibleServers/advancedThreatProtectionSettings,DB for MySQL Flexible Server - Advanced Threat Protection,,avm/res/db-for-my-sql/flexible-server/advanced-threat-protection,avm/res/db-for-my-sql/flexible-server,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/db-for-my-sql/flexible-server/advanced-threat-protection,br/public:avm/res/db-for-my-sql/flexible-server/advanced-threat-protection:X.Y.Z,46d3xbcp.res.5a9e8b0,abhishekaryams,Abhishek Arya,,,,AVM Child Module for DB for MySQL Flexible Server - Advanced Threat Protection,Bulk-added child module,2026-05 +Microsoft.DBforMySQL,flexibleServers/advancedThreatProtectionSettings,DB for MySQL Flexible Server - Advanced Threat Protection,,avm/res/db-for-my-sql/flexible-server/advanced-threat-protection-setting,avm/res/db-for-my-sql/flexible-server,Proposed,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/db-for-my-sql/flexible-server/advanced-threat-protection-setting,br/public:avm/res/db-for-my-sql/flexible-server/advanced-threat-protection-setting:X.Y.Z,46d3xbcp.res.5a9e8b0,abhishekaryams,Abhishek Arya,,,,AVM Child Module for DB for MySQL Flexible Server - Advanced Threat Protection,, Microsoft.DBforMySQL,flexibleServers/configurations,DB for MySQL Flexible Server - Configuration,,avm/res/db-for-my-sql/flexible-server/configuration,avm/res/db-for-my-sql/flexible-server,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/db-for-my-sql/flexible-server/configuration,br/public:avm/res/db-for-my-sql/flexible-server/configuration:X.Y.Z,46d3xbcp.res.0ef9df0,abhishekaryams,Abhishek Arya,,,,AVM Child Module for DB for MySQL Flexible Server - Configuration,Bulk-added child module,2026-05 Microsoft.DBforMySQL,flexibleServers/databases,DB for MySQL Flexible Server - Database,,avm/res/db-for-my-sql/flexible-server/database,avm/res/db-for-my-sql/flexible-server,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/db-for-my-sql/flexible-server/database,br/public:avm/res/db-for-my-sql/flexible-server/database:X.Y.Z,46d3xbcp.res.4264cd6,abhishekaryams,Abhishek Arya,,,,AVM Child Module for DB for MySQL Flexible Server - Database,Bulk-added child module,2026-05 Microsoft.DBforMySQL,flexibleServers/firewallRules,DB for MySQL Flexible Server - Firewall Rule,,avm/res/db-for-my-sql/flexible-server/firewall-rule,avm/res/db-for-my-sql/flexible-server,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/db-for-my-sql/flexible-server/firewall-rule,br/public:avm/res/db-for-my-sql/flexible-server/firewall-rule:X.Y.Z,46d3xbcp.res.55b5c3f,abhishekaryams,Abhishek Arya,,,,AVM Child Module for DB for MySQL Flexible Server - Firewall Rule,Bulk-added child module,2026-05 @@ -202,10 +202,10 @@ Microsoft.DevTestLab,labs/costs,DevTest Lab - Cost,,avm/res/dev-test-lab/lab/cos Microsoft.DevTestLab,labs/notificationchannels,DevTest Lab - Notificationchannel,,avm/res/dev-test-lab/lab/notificationchannel,avm/res/dev-test-lab/lab,Orphaned,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/dev-test-lab/lab/notificationchannel,br/public:avm/res/dev-test-lab/lab/notificationchannel:X.Y.Z,46d3xbcp.res.1c1f0d6,,,,,,AVM Child Module for DevTest Lab - Notificationchannel,Bulk-added child module,2026-05 Microsoft.DevTestLab,labs/policysets/policies,DevTest Lab - Policyset - Policy,,avm/res/dev-test-lab/lab/policyset/policy,avm/res/dev-test-lab/lab,Orphaned,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/dev-test-lab/lab/policyset/policy,br/public:avm/res/dev-test-lab/lab/policyset/policy:X.Y.Z,46d3xbcp.res.5476bf0,,,,,,AVM Child Module for DevTest Lab - Policyset - Policy,Bulk-added child module,2026-05 Microsoft.DevTestLab,labs/schedules,DevTest Lab - Schedule,,avm/res/dev-test-lab/lab/schedule,avm/res/dev-test-lab/lab,Orphaned,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/dev-test-lab/lab/schedule,br/public:avm/res/dev-test-lab/lab/schedule:X.Y.Z,46d3xbcp.res.2e79b13,,,,,,AVM Child Module for DevTest Lab - Schedule,Bulk-added child module,2026-05 -Microsoft.DevTestLab,labs/secrets,DevTest Lab - Secret,,avm/res/dev-test-lab/lab/secret,avm/res/dev-test-lab/lab,Orphaned,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/dev-test-lab/lab/secret,br/public:avm/res/dev-test-lab/lab/secret:X.Y.Z,46d3xbcp.res.cb331da,,,,,,AVM Child Module for DevTest Lab - Secret,Bulk-added child module,2026-05 +Microsoft.DevTestLab,labs/secrets,DevTest Lab - Secret,,avm/res/dev-test-lab/lab/secret,avm/res/dev-test-lab/lab,Deprecated,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/dev-test-lab/lab/secret,br/public:avm/res/dev-test-lab/lab/secret:X.Y.Z,46d3xbcp.res.cb331da,,,,,,AVM Child Module for DevTest Lab - Secret,Bulk-added child module,2026-05 Microsoft.DevTestLab,labs/virtualnetworks,DevTest Lab - Virtualnetwork,,avm/res/dev-test-lab/lab/virtualnetwork,avm/res/dev-test-lab/lab,Orphaned,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/dev-test-lab/lab/virtualnetwork,br/public:avm/res/dev-test-lab/lab/virtualnetwork:X.Y.Z,46d3xbcp.res.44b6d99,,,,,,AVM Child Module for DevTest Lab - Virtualnetwork,Bulk-added child module,2026-05 Microsoft.Devices,IotHubs,IoT Hub,,avm/res/devices/iot-hub,n/a,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/devices/iot-hub,br/public:avm/res/devices/iot-hub:X.Y.Z,46d3xbcp.res.59302a9,TomazMlakar,Tomaz Mlakar,,,,AVM Resource Module for IoT Hub,,2026-03 -Microsoft.Devices,IotHubs/eventHubEndpoints/ConsumerGroups,IoT Hub Consumer Groups,,avm/res/devices/iot-hub/consumergroup,avm/res/devices/iot-hub,Proposed,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/devices/iot-hub/consumergroup,br/public:avm/res/devices/iot-hub/consumergroup:X.Y.Z,46d3xbcp.res.1d02abb,TomazMlakar,Tomaz Mlakar,,,,This module deploys an IoT Hub Consumer Group.,, +Microsoft.Devices,IotHubs/eventHubEndpoints/ConsumerGroups,IoT Hub Consumer Groups,,avm/res/devices/iot-hub/event-hub-endpoint/consumergroup,avm/res/devices/iot-hub,Proposed,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/devices/iot-hub/event-hub-endpoint/consumergroup,br/public:avm/res/devices/iot-hub/event-hub-endpoint/consumergroup:X.Y.Z,46d3xbcp.res.1d02abb,TomazMlakar,Tomaz Mlakar,,,,This module deploys an IoT Hub Consumer Group.,, Microsoft.DigitalTwins,digitalTwinsInstances,Digital Twins Instance,,avm/res/digital-twins/digital-twins-instance,n/a,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/digital-twins/digital-twins-instance,br/public:avm/res/digital-twins/digital-twins-instance:X.Y.Z,46d3xbcp.res.b619933,ryanmstephens,Ryan Stephens,,,,AVM Resource Module for Digital Twins Instance,,2024-03 Microsoft.DigitalTwins,digitalTwinsInstances/endpoints,Digital Twins Instance - Endpoint,,avm/res/digital-twins/digital-twins-instance/endpoint,avm/res/digital-twins/digital-twins-instance,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/digital-twins/digital-twins-instance/endpoint,br/public:avm/res/digital-twins/digital-twins-instance/endpoint:X.Y.Z,46d3xbcp.res.d9f341d,ryanmstephens,Ryan Stephens,,,,AVM Child Module for Digital Twins Instance - Endpoint,Bulk-added child module,2026-05 Microsoft.DocumentDB,databaseAccounts,Cosmos DB Database Account,,avm/res/document-db/database-account,n/a,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/document-db/database-account,br/public:avm/res/document-db/database-account:X.Y.Z,46d3xbcp.res.2507273,cmaneu,Christopher MANEU,,,,AVM Resource Module for Cosmos DB Database Account,,2023-12 @@ -229,7 +229,7 @@ Microsoft.DocumentDB,mongoClusters/users,Cosmos DB for MongoDB (vCore) - User,,a Microsoft.DurableTask,schedulers,Durable Task Scheduler,,avm/res/durable-task/scheduler,n/a,Proposed,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/durable-task/scheduler,br/public:avm/res/durable-task/scheduler:X.Y.Z,46d3xbcp.res.214b660,greenie-msft,,,,,AVM Resource Module for Durable Task Scheduler,, Microsoft.EdgeOrder,orderItems,Edge Order Item,,avm/res/edge-order/order-item,n/a,Proposed,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/edge-order/order-item,br/public:avm/res/edge-order/order-item:X.Y.Z,46d3xbcp.res.a8dd86f,chirag1603,,,,,AVM Resource Module for Edge Order Item,, Microsoft.Edge,configurations,Edge Configuration,,avm/res/edge/configuration,n/a,Proposed,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/edge/configuration,br/public:avm/res/edge/configuration:X.Y.Z,46d3xbcp.res.62b2509,chirag1603,,,,,AVM Resource Module for Edge Configuration,, -Microsoft.Edge,sites,Edge Site,,avm/res/edge/site,n/a,Proposed,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/edge/site,br/public:avm/res/edge/site:X.Y.Z,46d3xbcp.res.685b936,chirag1603,,bniranjanbhat,,,AVM Resource Module for Edge Site,, +Microsoft.Edge,sites,Edge Site,,avm/res/edge/site,n/a,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/edge/site,br/public:avm/res/edge/site:X.Y.Z,46d3xbcp.res.685b936,chirag1603,,bniranjanbhat,,,AVM Resource Module for Edge Site,, Microsoft.Edge,sites,Edge Site - Resource Group Scope,,avm/res/edge/site/rg-scope,avm/res/edge/site,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/edge/site/rg-scope,br/public:avm/res/edge/site/rg-scope:X.Y.Z,46d3xbcp.res.6a45d55,chirag1603,,bniranjanbhat,,,AVM Resource Module for Edge Site - Resource Group Scope,,2026-06 Microsoft.Edge,sites,Edge Site - Subscription Scope,,avm/res/edge/site/sub-scope,avm/res/edge/site,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/edge/site/sub-scope,br/public:avm/res/edge/site/sub-scope:X.Y.Z,46d3xbcp.res.b45c538,chirag1603,,bniranjanbhat,,,AVM Resource Module for Edge Site - Subscription Scope,,2026-06 Microsoft.ElasticSan,elasticSans,Elastic SAN,"SAN, ESAN, Elastic SAN, Azure Elastic Storage Area Network, iSCSI, internet Small Computer Systems Interface",avm/res/elastic-san/elastic-san,n/a,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/elastic-san/elastic-san,br/public:avm/res/elastic-san/elastic-san:X.Y.Z,46d3xbcp.res.3cd952e,jbinko,jbinko,,,,AVM Resource Module for Elastic SAN,,2024-12 @@ -385,7 +385,7 @@ Microsoft.Network,networkManagers/scopeConnections,Network Manager - Scope Conne Microsoft.Network,networkManagers/securityAdminConfigurations,Network Manager - Security Admin Configuration,,avm/res/network/network-manager/security-admin-configuration,avm/res/network/network-manager,Orphaned,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/network-manager/security-admin-configuration,br/public:avm/res/network/network-manager/security-admin-configuration:X.Y.Z,46d3xbcp.res.f4e9b71,,,,,,AVM Child Module for Network Manager - Security Admin Configuration,Bulk-added child module,2026-05 Microsoft.Network,networkManagers/securityAdminConfigurations/ruleCollections,Network Manager - Security Admin Configuration - Rule Collection,,avm/res/network/network-manager/security-admin-configuration/rule-collection,avm/res/network/network-manager,Orphaned,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/network-manager/security-admin-configuration/rule-collection,br/public:avm/res/network/network-manager/security-admin-configuration/rule-collection:X.Y.Z,46d3xbcp.res.88637c4,,,,,,AVM Child Module for Network Manager - Security Admin Configuration - Rule Collection,Bulk-added child module,2026-05 Microsoft.Network,networkManagers/securityAdminConfigurations/ruleCollections/rules,Network Manager - Security Admin Configuration - Rule Coll - Rule,,avm/res/network/network-manager/security-admin-configuration/rule-collection/rule,avm/res/network/network-manager,Orphaned,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/network-manager/security-admin-configuration/rule-collection/rule,br/public:avm/res/network/network-manager/security-admin-configuration/rule-collection/rule:X.Y.Z,46d3xbcp.res.b51899a,,,,,,AVM Child Module for Network Manager - Security Admin Configuration - Rule Coll - Rule,Bulk-added child module,2026-05 -Microsoft.Network,networkSecurityGroups,Network Security Group,NSG,avm/res/network/network-security-group,n/a,Orphaned,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/network-security-group,br/public:avm/res/network/network-security-group:X.Y.Z,46d3xbcp.res.dd6a433,,,,,,AVM Resource Module for Network Security Group,,2023-12 +Microsoft.Network,networkSecurityGroups,Network Security Group,NSG,avm/res/network/network-security-group,n/a,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/network-security-group,br/public:avm/res/network/network-security-group:X.Y.Z,46d3xbcp.res.dd6a433,arjenhuitema,Arjen Huitema,,,,AVM Resource Module for Network Security Group,,2023-12 Microsoft.Network,networkSecurityPerimeters,Network Security Perimeter,,avm/res/network/network-security-perimeter,n/a,Orphaned,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/network-security-perimeter,br/public:avm/res/network/network-security-perimeter:X.Y.Z,46d3xbcp.res.dc662c3,,,,,,AVM Resource Module for Network Security Perimeter,,2025-03 Microsoft.Network,networkSecurityPerimeters/profiles,Network Security Perimeter - Profile,,avm/res/network/network-security-perimeter/profile,avm/res/network/network-security-perimeter,Orphaned,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/network-security-perimeter/profile,br/public:avm/res/network/network-security-perimeter/profile:X.Y.Z,46d3xbcp.res.671d98f,,,,,,AVM Child Module for Network Security Perimeter - Profile,Bulk-added child module,2026-05 Microsoft.Network,networkSecurityPerimeters/profiles/accessRules,Network Security Perimeter - Profile - Access Rule,,avm/res/network/network-security-perimeter/profile/access-rule,avm/res/network/network-security-perimeter,Orphaned,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/network-security-perimeter/profile/access-rule,br/public:avm/res/network/network-security-perimeter/profile/access-rule:X.Y.Z,46d3xbcp.res.70ab7cf,,,,,,AVM Child Module for Network Security Perimeter - Profile - Access Rule,Bulk-added child module,2026-05 @@ -494,19 +494,19 @@ Microsoft.Sql,managedInstances/encryptionProtector,SQL Managed Instance - Encryp Microsoft.Sql,managedInstances/keys,SQL Managed Instance - Key,,avm/res/sql/managed-instance/key,avm/res/sql/managed-instance,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/sql/managed-instance/key,br/public:avm/res/sql/managed-instance/key:X.Y.Z,46d3xbcp.res.3867c32,chanakanissanka,Chanaka Nissanka,,,,AVM Child Module for SQL Managed Instance - Key,Bulk-added child module,2026-05 Microsoft.Sql,managedInstances/securityAlertPolicies,SQL Managed Instance - Security Alert Policy,,avm/res/sql/managed-instance/security-alert-policy,avm/res/sql/managed-instance,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/sql/managed-instance/security-alert-policy,br/public:avm/res/sql/managed-instance/security-alert-policy:X.Y.Z,46d3xbcp.res.003a699,chanakanissanka,Chanaka Nissanka,,,,AVM Child Module for SQL Managed Instance - Security Alert Policy,Bulk-added child module,2026-05 Microsoft.Sql,managedInstances/vulnerabilityAssessments,SQL Managed Instance - Vulnerability Assessment,,avm/res/sql/managed-instance/vulnerability-assessment,avm/res/sql/managed-instance,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/sql/managed-instance/vulnerability-assessment,br/public:avm/res/sql/managed-instance/vulnerability-assessment:X.Y.Z,46d3xbcp.res.329624e,chanakanissanka,Chanaka Nissanka,,,,AVM Child Module for SQL Managed Instance - Vulnerability Assessment,Bulk-added child module,2026-05 -Microsoft.Sql,servers,Azure SQL Server,,avm/res/sql/server,n/a,Orphaned,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/sql/server,br/public:avm/res/sql/server:X.Y.Z,46d3xbcp.res.31e070f,,,,,,AVM Resource Module for Azure SQL Server,,2023-11 -Microsoft.Sql,servers/auditingSettings,Azure SQL Server - Auditing Setting,,avm/res/sql/server/auditing-setting,avm/res/sql/server,Orphaned,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/sql/server/auditing-setting,br/public:avm/res/sql/server/auditing-setting:X.Y.Z,46d3xbcp.res.21c74d4,,,,,,AVM Child Module for Azure SQL Server - Auditing Setting,Bulk-added child module,2026-06 -Microsoft.Sql,servers/databases,Azure SQL Database,,avm/res/sql/server/database,avm/res/sql/server,Orphaned,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/sql/server/database,br/public:avm/res/sql/server/database:X.Y.Z,46d3xbcp.res.3254c50,,,,,,AVM Resource Module for Azure SQL Database,,2025-09 -Microsoft.Sql,servers/databases/backupLongTermRetentionPolicies,Azure SQL Server - Database - Backup Long Term Retention Policy,,avm/res/sql/server/database/backup-long-term-retention-policy,avm/res/sql/server,Orphaned,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/sql/server/database/backup-long-term-retention-policy,br/public:avm/res/sql/server/database/backup-long-term-retention-policy:X.Y.Z,46d3xbcp.res.55b299c,,,,,,AVM Child Module for Azure SQL Server - Database - Backup Long Term Retention Policy,Bulk-added child module,2026-06 -Microsoft.Sql,servers/databases/backupShortTermRetentionPolicies,Azure SQL Server - Database - Backup Short Term Retention Policy,,avm/res/sql/server/database/backup-short-term-retention-policy,avm/res/sql/server,Orphaned,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/sql/server/database/backup-short-term-retention-policy,br/public:avm/res/sql/server/database/backup-short-term-retention-policy:X.Y.Z,46d3xbcp.res.b99866e,,,,,,AVM Child Module for Azure SQL Server - Database - Backup Short Term Retention Policy,Bulk-added child module,2026-06 -Microsoft.Sql,servers/elasticPools,Azure SQL Server - Elastic Pool,,avm/res/sql/server/elastic-pool,avm/res/sql/server,Orphaned,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/sql/server/elastic-pool,br/public:avm/res/sql/server/elastic-pool:X.Y.Z,46d3xbcp.res.3f3ea8c,,,,,,AVM Child Module for Azure SQL Server - Elastic Pool,Bulk-added child module,2026-06 -Microsoft.Sql,servers/encryptionProtector,Azure SQL Server - Encryption Protector,,avm/res/sql/server/encryption-protector,avm/res/sql/server,Orphaned,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/sql/server/encryption-protector,br/public:avm/res/sql/server/encryption-protector:X.Y.Z,46d3xbcp.res.7fa5222,,,,,,AVM Child Module for Azure SQL Server - Encryption Protector,Bulk-added child module,2026-06 -Microsoft.Sql,servers/failoverGroups,Azure SQL Server - Failover Group,,avm/res/sql/server/failover-group,avm/res/sql/server,Orphaned,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/sql/server/failover-group,br/public:avm/res/sql/server/failover-group:X.Y.Z,46d3xbcp.res.87c6eca,,,,,,AVM Child Module for Azure SQL Server - Failover Group,Bulk-added child module,2026-06 -Microsoft.Sql,servers/firewallRules,Azure SQL Server - Firewall Rule,,avm/res/sql/server/firewall-rule,avm/res/sql/server,Orphaned,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/sql/server/firewall-rule,br/public:avm/res/sql/server/firewall-rule:X.Y.Z,46d3xbcp.res.f391d10,,,,,,AVM Child Module for Azure SQL Server - Firewall Rule,Bulk-added child module,2026-06 -Microsoft.Sql,servers/keys,Azure SQL Server - Key,,avm/res/sql/server/key,avm/res/sql/server,Orphaned,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/sql/server/key,br/public:avm/res/sql/server/key:X.Y.Z,46d3xbcp.res.a9991c5,,,,,,AVM Child Module for Azure SQL Server - Key,Bulk-added child module,2026-06 -Microsoft.Sql,servers/securityAlertPolicies,Azure SQL Server - Security Alert Policy,,avm/res/sql/server/security-alert-policy,avm/res/sql/server,Orphaned,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/sql/server/security-alert-policy,br/public:avm/res/sql/server/security-alert-policy:X.Y.Z,46d3xbcp.res.63d577a,,,,,,AVM Child Module for Azure SQL Server - Security Alert Policy,Bulk-added child module,2026-06 -Microsoft.Sql,servers/virtualNetworkRules,Azure SQL Server - Virtual Network Rule,,avm/res/sql/server/virtual-network-rule,avm/res/sql/server,Orphaned,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/sql/server/virtual-network-rule,br/public:avm/res/sql/server/virtual-network-rule:X.Y.Z,46d3xbcp.res.2ad5c9f,,,,,,AVM Child Module for Azure SQL Server - Virtual Network Rule,Bulk-added child module,2026-06 -Microsoft.Sql,servers/vulnerabilityAssessments,Azure SQL Server - Vulnerability Assessment,,avm/res/sql/server/vulnerability-assessment,avm/res/sql/server,Orphaned,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/sql/server/vulnerability-assessment,br/public:avm/res/sql/server/vulnerability-assessment:X.Y.Z,46d3xbcp.res.6d8c193,,,,,,AVM Child Module for Azure SQL Server - Vulnerability Assessment,Bulk-added child module,2026-06 +Microsoft.Sql,servers,Azure SQL Server,,avm/res/sql/server,n/a,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/sql/server,br/public:avm/res/sql/server:X.Y.Z,46d3xbcp.res.31e070f,arjenhuitema,Arjen Huitema,,,,AVM Resource Module for Azure SQL Server,,2023-11 +Microsoft.Sql,servers/auditingSettings,Azure SQL Server - Auditing Setting,,avm/res/sql/server/auditing-setting,avm/res/sql/server,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/sql/server/auditing-setting,br/public:avm/res/sql/server/auditing-setting:X.Y.Z,46d3xbcp.res.21c74d4,arjenhuitema,Arjen Huitema,,,,AVM Child Module for Azure SQL Server - Auditing Setting,Bulk-added child module,2026-06 +Microsoft.Sql,servers/databases,Azure SQL Database,,avm/res/sql/server/database,avm/res/sql/server,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/sql/server/database,br/public:avm/res/sql/server/database:X.Y.Z,46d3xbcp.res.3254c50,arjenhuitema,Arjen Huitema,,,,AVM Resource Module for Azure SQL Database,,2025-09 +Microsoft.Sql,servers/databases/backupLongTermRetentionPolicies,Azure SQL Server - Database - Backup Long Term Retention Policy,,avm/res/sql/server/database/backup-long-term-retention-policy,avm/res/sql/server,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/sql/server/database/backup-long-term-retention-policy,br/public:avm/res/sql/server/database/backup-long-term-retention-policy:X.Y.Z,46d3xbcp.res.55b299c,arjenhuitema,Arjen Huitema,,,,AVM Child Module for Azure SQL Server - Database - Backup Long Term Retention Policy,Bulk-added child module,2026-06 +Microsoft.Sql,servers/databases/backupShortTermRetentionPolicies,Azure SQL Server - Database - Backup Short Term Retention Policy,,avm/res/sql/server/database/backup-short-term-retention-policy,avm/res/sql/server,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/sql/server/database/backup-short-term-retention-policy,br/public:avm/res/sql/server/database/backup-short-term-retention-policy:X.Y.Z,46d3xbcp.res.b99866e,arjenhuitema,Arjen Huitema,,,,AVM Child Module for Azure SQL Server - Database - Backup Short Term Retention Policy,Bulk-added child module,2026-06 +Microsoft.Sql,servers/elasticPools,Azure SQL Server - Elastic Pool,,avm/res/sql/server/elastic-pool,avm/res/sql/server,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/sql/server/elastic-pool,br/public:avm/res/sql/server/elastic-pool:X.Y.Z,46d3xbcp.res.3f3ea8c,arjenhuitema,Arjen Huitema,,,,AVM Child Module for Azure SQL Server - Elastic Pool,Bulk-added child module,2026-06 +Microsoft.Sql,servers/encryptionProtector,Azure SQL Server - Encryption Protector,,avm/res/sql/server/encryption-protector,avm/res/sql/server,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/sql/server/encryption-protector,br/public:avm/res/sql/server/encryption-protector:X.Y.Z,46d3xbcp.res.7fa5222,arjenhuitema,Arjen Huitema,,,,AVM Child Module for Azure SQL Server - Encryption Protector,Bulk-added child module,2026-06 +Microsoft.Sql,servers/failoverGroups,Azure SQL Server - Failover Group,,avm/res/sql/server/failover-group,avm/res/sql/server,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/sql/server/failover-group,br/public:avm/res/sql/server/failover-group:X.Y.Z,46d3xbcp.res.87c6eca,arjenhuitema,Arjen Huitema,,,,AVM Child Module for Azure SQL Server - Failover Group,Bulk-added child module,2026-06 +Microsoft.Sql,servers/firewallRules,Azure SQL Server - Firewall Rule,,avm/res/sql/server/firewall-rule,avm/res/sql/server,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/sql/server/firewall-rule,br/public:avm/res/sql/server/firewall-rule:X.Y.Z,46d3xbcp.res.f391d10,arjenhuitema,Arjen Huitema,,,,AVM Child Module for Azure SQL Server - Firewall Rule,Bulk-added child module,2026-06 +Microsoft.Sql,servers/keys,Azure SQL Server - Key,,avm/res/sql/server/key,avm/res/sql/server,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/sql/server/key,br/public:avm/res/sql/server/key:X.Y.Z,46d3xbcp.res.a9991c5,arjenhuitema,Arjen Huitema,,,,AVM Child Module for Azure SQL Server - Key,Bulk-added child module,2026-06 +Microsoft.Sql,servers/securityAlertPolicies,Azure SQL Server - Security Alert Policy,,avm/res/sql/server/security-alert-policy,avm/res/sql/server,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/sql/server/security-alert-policy,br/public:avm/res/sql/server/security-alert-policy:X.Y.Z,46d3xbcp.res.63d577a,arjenhuitema,Arjen Huitema,,,,AVM Child Module for Azure SQL Server - Security Alert Policy,Bulk-added child module,2026-06 +Microsoft.Sql,servers/virtualNetworkRules,Azure SQL Server - Virtual Network Rule,,avm/res/sql/server/virtual-network-rule,avm/res/sql/server,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/sql/server/virtual-network-rule,br/public:avm/res/sql/server/virtual-network-rule:X.Y.Z,46d3xbcp.res.2ad5c9f,arjenhuitema,Arjen Huitema,,,,AVM Child Module for Azure SQL Server - Virtual Network Rule,Bulk-added child module,2026-06 +Microsoft.Sql,servers/vulnerabilityAssessments,Azure SQL Server - Vulnerability Assessment,,avm/res/sql/server/vulnerability-assessment,avm/res/sql/server,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/sql/server/vulnerability-assessment,br/public:avm/res/sql/server/vulnerability-assessment:X.Y.Z,46d3xbcp.res.6d8c193,arjenhuitema,Arjen Huitema,,,,AVM Child Module for Azure SQL Server - Vulnerability Assessment,Bulk-added child module,2026-06 Microsoft.Storage,storageAccounts,Storage Account,,avm/res/storage/storage-account,n/a,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/storage/storage-account,br/public:avm/res/storage/storage-account:X.Y.Z,46d3xbcp.res.c73d079,fblix,Felix Borst,,,,AVM Resource Module for Storage Account,,2024-01 Microsoft.Storage,storageAccounts/blobServices,Storage Account - Blob Service,,avm/res/storage/storage-account/blob-service,avm/res/storage/storage-account,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/storage/storage-account/blob-service,br/public:avm/res/storage/storage-account/blob-service:X.Y.Z,46d3xbcp.res.d96673b,fblix,Felix Borst,,,,AVM Child Module for Storage Account - Blob Service,Bulk-added child module,2026-05 Microsoft.Storage,storageAccounts/blobServices/containers,Storage Account - Blob Container,,avm/res/storage/storage-account/blob-service/container,avm/res/storage/storage-account,Available,https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/storage/storage-account/blob-service/container,br/public:avm/res/storage/storage-account/blob-service/container:X.Y.Z,46d3xbcp.res.8ee959d,fblix,Felix Borst,,,,AVM Child Module for Storage Account - Blob Container,,2025-06 diff --git a/.github/data/avm-module-index.json b/.github/data/avm-module-index.json index c16f220a6..a7dc48655 100644 --- a/.github/data/avm-module-index.json +++ b/.github/data/avm-module-index.json @@ -1,7 +1,7 @@ { "$schema": null, "schema_version": "avm-module-index-v1", - "generated_at": "2026-10-05T10:34:47.364Z", + "generated_at": "2026-10-13T10:25:36.344Z", "sources": { "bicep": "https://azure.github.io/Azure-Verified-Modules/module-indexes/BicepResourceModules.csv", "terraform": "https://azure.github.io/Azure-Verified-Modules/module-indexes/TerraformResourceModules.csv" @@ -292,7 +292,7 @@ "module_name": "avm/res/api-management/service/named-value", "source": "br/public:avm/res/api-management/service/named-value", "provider_namespace": "Microsoft.ApiManagement", - "resource_type": "service/namesValues", + "resource_type": "service/namedValues", "module_status": "Available", "display_name": "API Management Service - Named Value", "repo_url": "https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/api-management/service/named-value", @@ -711,7 +711,7 @@ "source": "br/public:avm/res/authorization/policy-assignment", "provider_namespace": "Microsoft.Authorization", "resource_type": "policyAssignments", - "module_status": "Proposed", + "module_status": "Available", "display_name": "Authorization - Policy Assignment", "repo_url": "https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/authorization/policy-assignment", "alternative_names": "" @@ -755,7 +755,7 @@ "source": "br/public:avm/res/authorization/role-assignment", "provider_namespace": "Microsoft.Authorization", "resource_type": "roleAssignments", - "module_status": "Proposed", + "module_status": "Available", "display_name": "Authorization - Role Assignment", "repo_url": "https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/authorization/role-assignment", "alternative_names": "" @@ -1862,13 +1862,13 @@ }, { "tool": "bicep", - "module_name": "avm/res/db-for-my-sql/flexible-server/advanced-threat-protection", - "source": "br/public:avm/res/db-for-my-sql/flexible-server/advanced-threat-protection", + "module_name": "avm/res/db-for-my-sql/flexible-server/advanced-threat-protection-setting", + "source": "br/public:avm/res/db-for-my-sql/flexible-server/advanced-threat-protection-setting", "provider_namespace": "Microsoft.DBforMySQL", "resource_type": "flexibleServers/advancedThreatProtectionSettings", - "module_status": "Available", + "module_status": "Proposed", "display_name": "DB for MySQL Flexible Server - Advanced Threat Protection", - "repo_url": "https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/db-for-my-sql/flexible-server/advanced-threat-protection", + "repo_url": "https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/db-for-my-sql/flexible-server/advanced-threat-protection-setting", "alternative_names": "" }, { @@ -2251,7 +2251,7 @@ "source": "br/public:avm/res/dev-test-lab/lab/secret", "provider_namespace": "Microsoft.DevTestLab", "resource_type": "labs/secrets", - "module_status": "Orphaned", + "module_status": "Deprecated", "display_name": "DevTest Lab - Secret", "repo_url": "https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/dev-test-lab/lab/secret", "alternative_names": "" @@ -2280,13 +2280,13 @@ }, { "tool": "bicep", - "module_name": "avm/res/devices/iot-hub/consumergroup", - "source": "br/public:avm/res/devices/iot-hub/consumergroup", + "module_name": "avm/res/devices/iot-hub/event-hub-endpoint/consumergroup", + "source": "br/public:avm/res/devices/iot-hub/event-hub-endpoint/consumergroup", "provider_namespace": "Microsoft.Devices", "resource_type": "IotHubs/eventHubEndpoints/ConsumerGroups", "module_status": "Proposed", "display_name": "IoT Hub Consumer Groups", - "repo_url": "https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/devices/iot-hub/consumergroup", + "repo_url": "https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/devices/iot-hub/event-hub-endpoint/consumergroup", "alternative_names": "" }, { @@ -2548,7 +2548,7 @@ "source": "br/public:avm/res/edge/site", "provider_namespace": "Microsoft.Edge", "resource_type": "sites", - "module_status": "Proposed", + "module_status": "Available", "display_name": "Edge Site", "repo_url": "https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/edge/site", "alternative_names": "" @@ -4264,7 +4264,7 @@ "source": "br/public:avm/res/network/network-security-group", "provider_namespace": "Microsoft.Network", "resource_type": "networkSecurityGroups", - "module_status": "Orphaned", + "module_status": "Available", "display_name": "Network Security Group", "repo_url": "https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/network-security-group", "alternative_names": "NSG" @@ -5463,7 +5463,7 @@ "source": "br/public:avm/res/sql/server", "provider_namespace": "Microsoft.Sql", "resource_type": "servers", - "module_status": "Orphaned", + "module_status": "Available", "display_name": "Azure SQL Server", "repo_url": "https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/sql/server", "alternative_names": "" @@ -5474,7 +5474,7 @@ "source": "br/public:avm/res/sql/server/auditing-setting", "provider_namespace": "Microsoft.Sql", "resource_type": "servers/auditingSettings", - "module_status": "Orphaned", + "module_status": "Available", "display_name": "Azure SQL Server - Auditing Setting", "repo_url": "https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/sql/server/auditing-setting", "alternative_names": "" @@ -5485,7 +5485,7 @@ "source": "br/public:avm/res/sql/server/database", "provider_namespace": "Microsoft.Sql", "resource_type": "servers/databases", - "module_status": "Orphaned", + "module_status": "Available", "display_name": "Azure SQL Database", "repo_url": "https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/sql/server/database", "alternative_names": "" @@ -5496,7 +5496,7 @@ "source": "br/public:avm/res/sql/server/database/backup-long-term-retention-policy", "provider_namespace": "Microsoft.Sql", "resource_type": "servers/databases/backupLongTermRetentionPolicies", - "module_status": "Orphaned", + "module_status": "Available", "display_name": "Azure SQL Server - Database - Backup Long Term Retention Policy", "repo_url": "https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/sql/server/database/backup-long-term-retention-policy", "alternative_names": "" @@ -5507,7 +5507,7 @@ "source": "br/public:avm/res/sql/server/database/backup-short-term-retention-policy", "provider_namespace": "Microsoft.Sql", "resource_type": "servers/databases/backupShortTermRetentionPolicies", - "module_status": "Orphaned", + "module_status": "Available", "display_name": "Azure SQL Server - Database - Backup Short Term Retention Policy", "repo_url": "https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/sql/server/database/backup-short-term-retention-policy", "alternative_names": "" @@ -5518,7 +5518,7 @@ "source": "br/public:avm/res/sql/server/elastic-pool", "provider_namespace": "Microsoft.Sql", "resource_type": "servers/elasticPools", - "module_status": "Orphaned", + "module_status": "Available", "display_name": "Azure SQL Server - Elastic Pool", "repo_url": "https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/sql/server/elastic-pool", "alternative_names": "" @@ -5529,7 +5529,7 @@ "source": "br/public:avm/res/sql/server/encryption-protector", "provider_namespace": "Microsoft.Sql", "resource_type": "servers/encryptionProtector", - "module_status": "Orphaned", + "module_status": "Available", "display_name": "Azure SQL Server - Encryption Protector", "repo_url": "https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/sql/server/encryption-protector", "alternative_names": "" @@ -5540,7 +5540,7 @@ "source": "br/public:avm/res/sql/server/failover-group", "provider_namespace": "Microsoft.Sql", "resource_type": "servers/failoverGroups", - "module_status": "Orphaned", + "module_status": "Available", "display_name": "Azure SQL Server - Failover Group", "repo_url": "https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/sql/server/failover-group", "alternative_names": "" @@ -5551,7 +5551,7 @@ "source": "br/public:avm/res/sql/server/firewall-rule", "provider_namespace": "Microsoft.Sql", "resource_type": "servers/firewallRules", - "module_status": "Orphaned", + "module_status": "Available", "display_name": "Azure SQL Server - Firewall Rule", "repo_url": "https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/sql/server/firewall-rule", "alternative_names": "" @@ -5562,7 +5562,7 @@ "source": "br/public:avm/res/sql/server/key", "provider_namespace": "Microsoft.Sql", "resource_type": "servers/keys", - "module_status": "Orphaned", + "module_status": "Available", "display_name": "Azure SQL Server - Key", "repo_url": "https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/sql/server/key", "alternative_names": "" @@ -5573,7 +5573,7 @@ "source": "br/public:avm/res/sql/server/security-alert-policy", "provider_namespace": "Microsoft.Sql", "resource_type": "servers/securityAlertPolicies", - "module_status": "Orphaned", + "module_status": "Available", "display_name": "Azure SQL Server - Security Alert Policy", "repo_url": "https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/sql/server/security-alert-policy", "alternative_names": "" @@ -5584,7 +5584,7 @@ "source": "br/public:avm/res/sql/server/virtual-network-rule", "provider_namespace": "Microsoft.Sql", "resource_type": "servers/virtualNetworkRules", - "module_status": "Orphaned", + "module_status": "Available", "display_name": "Azure SQL Server - Virtual Network Rule", "repo_url": "https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/sql/server/virtual-network-rule", "alternative_names": "" @@ -5595,7 +5595,7 @@ "source": "br/public:avm/res/sql/server/vulnerability-assessment", "provider_namespace": "Microsoft.Sql", "resource_type": "servers/vulnerabilityAssessments", - "module_status": "Orphaned", + "module_status": "Available", "display_name": "Azure SQL Server - Vulnerability Assessment", "repo_url": "https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/sql/server/vulnerability-assessment", "alternative_names": "" @@ -6849,7 +6849,7 @@ "source": "Azure/avm-res-insights-actiongroup/azurerm", "provider_namespace": "Microsoft.Insights", "resource_type": "actionGroups", - "module_status": "Proposed", + "module_status": "Available", "display_name": "Action Group", "repo_url": "https://github.com/Azure/terraform-azurerm-avm-res-insights-actiongroup", "alternative_names": "" @@ -6860,7 +6860,7 @@ "source": "Azure/avm-res-insights-activitylogalert/azurerm", "provider_namespace": "Microsoft.Insights", "resource_type": "activityLogAlerts", - "module_status": "Proposed", + "module_status": "Available", "display_name": "Activity log alerts", "repo_url": "https://github.com/Azure/terraform-azurerm-avm-res-insights-activitylogalert", "alternative_names": "" @@ -6981,7 +6981,7 @@ "source": "Azure/avm-res-keyvault-vault/azurerm", "provider_namespace": "Microsoft.KeyVault", "resource_type": "vaults", - "module_status": "Available", + "module_status": "Orphaned", "display_name": "Key Vault", "repo_url": "https://github.com/Azure/terraform-azurerm-avm-res-keyvault-vault", "alternative_names": "KV" @@ -7740,7 +7740,7 @@ "source": "Azure/avm-res-signalrservice-webpubsub/azurerm", "provider_namespace": "Microsoft.SignalRService", "resource_type": "webPubSub", - "module_status": "Proposed", + "module_status": "Available", "display_name": "SignalR Service Web PubSub", "repo_url": "https://github.com/Azure/terraform-azure-avm-res-signalrservice-webpubsub", "alternative_names": "" diff --git a/.github/data/avm-terraform-modules.csv b/.github/data/avm-terraform-modules.csv index fec83962d..79e7f60fa 100644 --- a/.github/data/avm-terraform-modules.csv +++ b/.github/data/avm-terraform-modules.csv @@ -67,8 +67,8 @@ Microsoft.Features,providers/features,Azure Feature Exposure Control (AFEC),,avm Microsoft.HealthBot,healthBots,Azure Health Bot,,avm-res-healthbot-healthbot,n/a,Proposed,https://github.com/Azure/terraform-azurerm-avm-res-healthbot-healthbot,https://registry.terraform.io/modules/Azure/avm-res-healthbot-healthbot,,,,,AVM Resource Module for Azure Health Bot,, Microsoft.HybridCompute,machines,Hybrid Compute Machine,,avm-res-hybridcompute-machine,n/a,Proposed,https://github.com/Azure/terraform-azurerm-avm-res-hybridcompute-machine,https://registry.terraform.io/modules/Azure/avm-res-hybridcompute-machine,,,,,AVM Resource Module for Hybrid Compute Machine,, Microsoft.HybridContainerService,provisionedClusterInstances,AKS Arc,,avm-res-hybridcontainerservice-provisionedclusterinstance,n/a,Available,https://github.com/Azure/terraform-azurerm-avm-res-hybridcontainerservice-provisionedclusterinstance,https://registry.terraform.io/modules/Azure/avm-res-hybridcontainerservice-provisionedclusterinstance,chirag1603,,,,AVM Resource Module for AKS Arc,,2024-08 -Microsoft.Insights,actionGroups,Action Group,,avm-res-insights-actiongroup,n/a,Proposed,https://github.com/Azure/terraform-azurerm-avm-res-insights-actiongroup,https://registry.terraform.io/modules/Azure/avm-res-insights-actiongroup,arjenhuitema,Arjen Huitema,Brunoga-MS,Bruno Gabrielli,AVM Resource Module for Action Group,, -Microsoft.Insights,activityLogAlerts,Activity log alerts,,avm-res-insights-activitylogalert,n/a,Proposed,https://github.com/Azure/terraform-azurerm-avm-res-insights-activitylogalert,https://registry.terraform.io/modules/Azure/avm-res-insights-activitylogalert,tagolovina,Tanya Golovina,,,AVM Resource Module for Activity log alerts,, +Microsoft.Insights,actionGroups,Action Group,,avm-res-insights-actiongroup,n/a,Available,https://github.com/Azure/terraform-azurerm-avm-res-insights-actiongroup,https://registry.terraform.io/modules/Azure/avm-res-insights-actiongroup,arjenhuitema,Arjen Huitema,Brunoga-MS,Bruno Gabrielli,AVM Resource Module for Action Group,,2026-10 +Microsoft.Insights,activityLogAlerts,Activity log alerts,,avm-res-insights-activitylogalert,n/a,Available,https://github.com/Azure/terraform-azurerm-avm-res-insights-activitylogalert,https://registry.terraform.io/modules/Azure/avm-res-insights-activitylogalert,tagolovina,Tanya Golovina,,,AVM Resource Module for Activity log alerts,,2026-10 Microsoft.Insights,autoscalesettings,Auto scale settings,,avm-res-insights-autoscalesetting,n/a,Available,https://github.com/Azure/terraform-azurerm-avm-res-insights-autoscalesetting,https://registry.terraform.io/modules/Azure/avm-res-insights-autoscalesetting,MinHeinA,Min Hein Aung,,,AVM Resource Module for Auto scale settings,,2024-11 Microsoft.Insights,components,Application Insight,,avm-res-insights-component,n/a,Available,https://github.com/Azure/terraform-azurerm-avm-res-insights-component,https://registry.terraform.io/modules/Azure/avm-res-insights-component,Jfolberth,,,,AVM Resource Module for Application Insight,,2024-05 Microsoft.Insights,dataCollectionEndpoints,Data Collection Endpoint,,avm-res-insights-datacollectionendpoint,n/a,Available,https://github.com/Azure/terraform-azurerm-avm-res-insights-datacollectionendpoint,https://registry.terraform.io/modules/Azure/avm-res-insights-datacollectionendpoint,sharmilamusunuru,,,,AVM Resource Module for Data Collection Endpoint,,2024-07 @@ -79,7 +79,7 @@ Microsoft.Insights,privateLinkScopes,Insights Private Link Scope,,avm-res-insigh Microsoft.Insights,scheduledQueryRules,Scheduled Query Rule,,avm-res-insights-scheduledqueryrule,n/a,Proposed,https://github.com/Azure/terraform-azurerm-avm-res-insights-scheduledqueryrule,https://registry.terraform.io/modules/Azure/avm-res-insights-scheduledqueryrule,xsatishx,Satish,,,AVM Resource Module for Scheduled Query Rule,, Microsoft.IoTOperations,instances,Azure IoT Operations,,avm-res-iotoperations-instance,n/a,Proposed,https://github.com/Azure/terraform-azurerm-avm-res-iotoperations-instance,https://registry.terraform.io/modules/Azure/avm-res-iotoperations-instance,agreaves-ms,Allen Greaves,WilliamBerryiii,Bill Berry,AVM Resource Module for Azure IoT Operations,, Microsoft.KeyVault,managedHSMs,Key Vault Managed HSM,mHSM,avm-res-keyvault-managedhsm,n/a,Available,https://github.com/Azure/terraform-azure-avm-res-keyvault-managedhsm,https://registry.terraform.io/modules/Azure/avm-res-keyvault-managedhsm,benvbr,Benjamin Van Brussel,,,AVM Resource Module for Key Vault Managed HSM,,2026-07 -Microsoft.KeyVault,vaults,Key Vault,KV,avm-res-keyvault-vault,n/a,Available,https://github.com/Azure/terraform-azurerm-avm-res-keyvault-vault,https://registry.terraform.io/modules/Azure/avm-res-keyvault-vault,matt-FFFFFF,Matt White,,,AVM Resource Module for Key Vault,,2023-09 +Microsoft.KeyVault,vaults,Key Vault,KV,avm-res-keyvault-vault,n/a,Orphaned,https://github.com/Azure/terraform-azurerm-avm-res-keyvault-vault,https://registry.terraform.io/modules/Azure/avm-res-keyvault-vault,,,,,AVM Resource Module for Key Vault,,2023-09 Microsoft.KubernetesConfiguration,extensions,Kubernetes Configuration Extension,,avm-res-kubernetesconfiguration-extension,n/a,Proposed,https://github.com/Azure/terraform-azure-avm-res-kubernetesconfiguration-extension,https://registry.terraform.io/modules/Azure/avm-res-kubernetesconfiguration-extension,lonegunmanb,,,,AVM Terraform resource module for Kubernetes Configuration Extension.,, Microsoft.Kusto,clusters,Kusto Clusters,,avm-res-kusto-cluster,n/a,Orphaned,https://github.com/Azure/terraform-azurerm-avm-res-kusto-cluster,https://registry.terraform.io/modules/Azure/avm-res-kusto-cluster,,,,,AVM Resource Module for Kusto Clusters,,2024-04 Microsoft.LoadTestService,loadTests,Load Testing Service,,avm-res-loadtestservice-loadtest,n/a,Proposed,https://github.com/Azure/terraform-azurerm-avm-res-loadtestservice-loadtest,https://registry.terraform.io/modules/Azure/avm-res-loadtestservice-loadtest,pfayika1,,,,AVM Resource Module for Load Testing Service,, @@ -148,7 +148,7 @@ Microsoft.ServiceBus,namespaces,Service Bus Namespace,,avm-res-servicebus-namesp Microsoft.ServiceFabric,clusters,Service Fabric Cluster,,avm-res-servicefabric-cluster,n/a,Proposed,https://github.com/Azure/terraform-azure-avm-res-servicefabric-cluster,https://registry.terraform.io/modules/Azure/avm-res-servicefabric-cluster,,,,,AVM Resource Module for Service Fabric Cluster,, Microsoft.ServiceNetworking,trafficControllers,Application Gateway for Containers (Traffic Controller),,avm-res-servicenetworking-trafficcontroller,n/a,Available,https://github.com/Azure/terraform-azurerm-avm-res-servicenetworking-trafficcontroller,https://registry.terraform.io/modules/Azure/avm-res-servicenetworking-trafficcontroller,ethanjenkins1,Ethan Jenkins MSFT,,,AVM Resource Module for Application Gateway for Containers (Traffic Controller),,2026-06 Microsoft.SignalRService,signalR,SignalR Service SignalR,,avm-res-signalrservice-signalr,n/a,Proposed,https://github.com/Azure/terraform-azure-avm-res-signalrservice-signalr,https://registry.terraform.io/modules/Azure/avm-res-signalrservice-signalr,,,,,AVM Resource Module for SignalR Service SignalR,, -Microsoft.SignalRService,webPubSub,SignalR Service Web PubSub,,avm-res-signalrservice-webpubsub,n/a,Proposed,https://github.com/Azure/terraform-azure-avm-res-signalrservice-webpubsub,https://registry.terraform.io/modules/Azure/avm-res-signalrservice-webpubsub,sujaypillai,Sujay Pillai,,,AVM Terraform resource module for SignalR Service Web PubSub.,, +Microsoft.SignalRService,webPubSub,SignalR Service Web PubSub,,avm-res-signalrservice-webpubsub,n/a,Available,https://github.com/Azure/terraform-azure-avm-res-signalrservice-webpubsub,https://registry.terraform.io/modules/Azure/avm-res-signalrservice-webpubsub,sujaypillai,Sujay Pillai,,,AVM Terraform resource module for SignalR Service Web PubSub.,,2026-10 Microsoft.Sql,InstancePools,Azure SQL Managed Instance Pools,,avm-res-sql-instancepool,n/a,Proposed,https://github.com/Azure/terraform-azapi-avm-res-sql-instancepool,https://registry.terraform.io/modules/Azure/avm-res-sql-instancepool,sujaypillai,Sujay Pillai,,,AVM Terraform resource module for Azure SQL Managed Instance Pools.,, Microsoft.Sql,managedInstances,SQL Managed Instance,SQL MI,avm-res-sql-managedinstance,n/a,Available,https://github.com/Azure/terraform-azurerm-avm-res-sql-managedinstance,https://registry.terraform.io/modules/Azure/avm-res-sql-managedinstance,chanakanissanka,Chanaka Nissanka,abhishekaryams,Abhishek Arya,AVM Resource Module for SQL Managed Instance,,2026-02 Microsoft.Sql,servers,Azure SQL Server,,avm-res-sql-server,n/a,Available,https://github.com/Azure/terraform-azurerm-avm-res-sql-server,https://registry.terraform.io/modules/Azure/avm-res-sql-server,abhishekaryams,Abhishek Arya,,,AVM Resource Module for Azure SQL Server,,2024-09 diff --git a/.github/data/azure-deprecations.json b/.github/data/azure-deprecations.json index 45ab523b4..f48f85024 100644 --- a/.github/data/azure-deprecations.json +++ b/.github/data/azure-deprecations.json @@ -1,6 +1,6 @@ { - "lastUpdated": "2026-06-22T12:09:22.357Z", - "totalCount": 6, + "lastUpdated": "2026-10-13T11:17:16.062Z", + "totalCount": 35, "sources": [ "manual-known", "azure-updates-rss" @@ -12,7 +12,8 @@ "sunsetDate": "2024-03-31", "replacement": "Basic/Standard/Premium", "source": "manual", - "addedDate": "2023-01-01" + "addedDate": "2023-01-01", + "status": "retired" }, { "service": "Azure CDN", @@ -20,7 +21,8 @@ "sunsetDate": "2025-09-30", "replacement": "Standard_AzureFrontDoor", "source": "manual", - "addedDate": "2024-01-15" + "addedDate": "2024-01-15", + "status": "retired" }, { "service": "Azure CDN", @@ -28,7 +30,8 @@ "sunsetDate": "2025-09-30", "replacement": "Premium_AzureFrontDoor", "source": "manual", - "addedDate": "2024-01-15" + "addedDate": "2024-01-15", + "status": "retired" }, { "service": "Application Gateway", @@ -36,7 +39,8 @@ "sunsetDate": "2026-04-28", "replacement": "Standard_v2", "source": "manual", - "addedDate": "2024-06-01" + "addedDate": "2024-06-01", + "status": "retired" }, { "service": "Application Gateway", @@ -44,7 +48,297 @@ "sunsetDate": "2026-04-28", "replacement": "WAF_v2", "source": "manual", - "addedDate": "2024-06-01" + "addedDate": "2024-06-01", + "status": "retired" + }, + { + "service": "Nested confidential (cc_v5)", + "title": "Retirement: Nested confidential (cc_v5) VMs will be retired on September 1, 2026", + "link": "https://azure.microsoft.com/updates?id=568661", + "pubDate": "Wed, 05 Aug 2026 00:22:49 Z", + "source": "azure-updates-rss", + "sunsetDate": "2026-09-01", + "replacement": null, + "status": "retired" + }, + { + "service": "Containerized data connector", + "title": "Retirement: Containerized data connector agent for the Microsoft Sentinel solution for SAP applications", + "link": "https://azure.microsoft.com/updates?id=568457", + "pubDate": "Wed, 12 Aug 2026 19:41:20 Z", + "source": "azure-updates-rss", + "sunsetDate": "2026-09-14", + "replacement": null, + "status": "retired" + }, + { + "service": "NVv3-series Azure Virtual", + "title": "Retirement: NVv3-series Azure Virtual Machines", + "link": "https://azure.microsoft.com/updates?id=573414", + "pubDate": "Wed, 30 Sep 2026 18:26:47 Z", + "source": "azure-updates-rss", + "sunsetDate": "2026-09-30", + "replacement": null, + "status": "retired" + }, + { + "service": "NVv4-series Azure Virtual", + "title": "Retirement: NVv4-series Azure Virtual Machines", + "link": "https://azure.microsoft.com/updates?id=573415", + "pubDate": "Wed, 30 Sep 2026 18:24:01 Z", + "source": "azure-updates-rss", + "sunsetDate": "2026-09-30", + "replacement": null, + "status": "retired" + }, + { + "service": "SAP container images", + "title": "Retirement Update: SAP container images removed October 14, 2026", + "link": "https://azure.microsoft.com/updates?id=571342", + "pubDate": "Wed, 16 Sep 2026 17:19:02 Z", + "source": "azure-updates-rss", + "sunsetDate": "2026-10-14", + "replacement": null, + "status": "upcoming" + }, + { + "service": "Support for PowerShell 7.4", + "title": "Retirement: Support for PowerShell 7.4 ends on November 10, 2026", + "link": "https://azure.microsoft.com/updates?id=572770", + "pubDate": "Wed, 23 Sep 2026 16:50:05 Z", + "source": "azure-updates-rss", + "sunsetDate": "2026-11-10", + "replacement": null, + "status": "upcoming" + }, + { + "service": "Support for .NET", + "title": "Retirement: Support for .NET 8 and .NET 9 ends on November 10, 2026—upgrade your apps to .NET 10", + "link": "https://azure.microsoft.com/updates?id=572838", + "pubDate": "Wed, 23 Sep 2026 16:46:49 Z", + "source": "azure-updates-rss", + "sunsetDate": "2026-11-10", + "replacement": null, + "status": "upcoming" + }, + { + "service": "Azure Linux with OS", + "title": "Retirement: Azure Linux with OS Guard in Azure Kubernetes Service", + "link": "https://azure.microsoft.com/updates?id=571257", + "pubDate": "Fri, 11 Sep 2026 18:08:28 Z", + "source": "azure-updates-rss", + "sunsetDate": "2026-12-10", + "replacement": null, + "status": "upcoming" + }, + { + "service": "Deployment Environments", + "title": "Retirement: Azure Deployment Environments will be retired on February 22, 2027", + "link": "https://azure.microsoft.com/updates?id=567934", + "pubDate": "Thu, 08 Oct 2026 18:20:34 Z", + "source": "azure-updates-rss", + "sunsetDate": "2027-02-22", + "replacement": null, + "status": "upcoming" + }, + { + "service": "Azure Cache for Redis", + "sku": "Enterprise, Enterprise Flash", + "sunsetDate": "2027-03-31", + "replacement": "Azure Managed Redis", + "source": "manual", + "notes": "https://learn.microsoft.com/azure/azure-cache-for-redis/retirement-faq", + "addedDate": "2026-10-13", + "status": "upcoming" + }, + { + "service": "Support for Node.js", + "title": "Retirement: Support for Node.js 22 ends on April 30, 2027", + "link": "https://azure.microsoft.com/updates?id=572771", + "pubDate": "Tue, 22 Sep 2026 19:22:34 Z", + "source": "azure-updates-rss", + "sunsetDate": "2027-04-30", + "replacement": null, + "status": "upcoming" + }, + { + "service": "Support for Node", + "title": "Retirement: Support for Node 22 LTS ends on April 30, 2027", + "link": "https://azure.microsoft.com/updates?id=567334", + "pubDate": "Mon, 24 Aug 2026 17:30:43 Z", + "source": "azure-updates-rss", + "sunsetDate": "2027-04-30", + "replacement": null, + "status": "upcoming" + }, + { + "service": "Microsoft HPC Pack", + "title": "Retirement: Microsoft HPC Pack", + "link": "https://azure.microsoft.com/updates?id=570046", + "pubDate": "Tue, 29 Sep 2026 17:26:50 Z", + "source": "azure-updates-rss", + "sunsetDate": "2027-08-27", + "replacement": null, + "status": "upcoming" + }, + { + "service": "Azure VMware Solution", + "title": "Retirement: Azure VMware Solution License-included service will be retired August 30, 2027", + "link": "https://azure.microsoft.com/updates?id=569535", + "pubDate": "Tue, 18 Aug 2026 19:52:06 Z", + "source": "azure-updates-rss", + "sunsetDate": "2027-08-30", + "replacement": null, + "status": "upcoming" + }, + { + "service": "Support for Java", + "title": "Retirement: Support for Java 8, 11 and 17 will end on September 1, 2027", + "link": "https://azure.microsoft.com/updates?id=568585", + "pubDate": "Tue, 06 Oct 2026 18:21:59 Z", + "source": "azure-updates-rss", + "sunsetDate": "2027-09-01", + "replacement": null, + "status": "upcoming" + }, + { + "service": "Functions on Azure", + "title": "Retirement: Functions on Azure Container Apps V1", + "link": "https://azure.microsoft.com/updates?id=570800", + "pubDate": "Tue, 29 Sep 2026 19:41:09 Z", + "source": "azure-updates-rss", + "sunsetDate": "2027-09-29", + "replacement": null, + "status": "upcoming" + }, + { + "service": "Pod name dimension", + "title": "Retirement: Pod name dimension in AKS pod platform metrics", + "link": "https://azure.microsoft.com/updates?id=570232", + "pubDate": "Tue, 06 Oct 2026 22:37:54 Z", + "source": "azure-updates-rss", + "sunsetDate": "2027-09-30", + "replacement": null, + "status": "upcoming" + }, + { + "service": "Azure Key Vault", + "title": "Retirement: Azure Key Vault Secrets Provider Extension for Azure Arc enabled Kubernetes clusters", + "link": "https://azure.microsoft.com/updates?id=570313", + "pubDate": "Fri, 09 Oct 2026 20:27:17 Z", + "source": "azure-updates-rss", + "sunsetDate": "2027-10-09", + "replacement": null, + "status": "upcoming" + }, + { + "service": "Always Encrypted with", + "title": "Retirement: Always Encrypted with Intel SGX Enclaves", + "link": "https://azure.microsoft.com/updates?id=569236", + "pubDate": "Tue, 06 Oct 2026 16:42:33 Z", + "source": "azure-updates-rss", + "sunsetDate": "2027-10-31", + "replacement": null, + "status": "upcoming" + }, + { + "service": "Microsoft Dev Box", + "title": "Retirement: Microsoft Dev Box will be retired on September 18, 2028", + "link": "https://azure.microsoft.com/updates?id=567933", + "pubDate": "Thu, 08 Oct 2026 18:23:25 Z", + "source": "azure-updates-rss", + "sunsetDate": "2028-09-18", + "replacement": null, + "status": "upcoming" + }, + { + "service": "Azure Cache for Redis", + "sku": "Basic, Standard, Premium", + "sunsetDate": "2028-09-30", + "replacement": "Azure Managed Redis", + "source": "manual", + "notes": "https://learn.microsoft.com/azure/azure-cache-for-redis/retirement-faq", + "addedDate": "2026-10-13", + "status": "upcoming" + }, + { + "service": "Azure Functions", + "sku": "Linux Consumption", + "sunsetDate": "2028-09-30", + "replacement": "Flex Consumption", + "source": "manual", + "addedDate": "2026-10-13", + "status": "upcoming" + }, + { + "service": "Transition to using", + "title": "Retirement: Transition to using standard tests for single-step availability testing in Azure Monitor application insights by September 30, 2028", + "link": "https://azure.microsoft.com/updates?id=transition-to-using-standard-tests-for-singlestep-availability-testing-in-azure-monitor-application-insights-by-30-september", + "pubDate": "Fri, 22 Sep 2023 23:00:56 Z", + "source": "azure-updates-rss", + "sunsetDate": "2028-09-30", + "replacement": null, + "status": "upcoming" + }, + { + "service": "Azure Communication Services", + "title": "Retirement: Azure Communication Services (ACS) standalone services will be retired on September 30, 2028", + "link": "https://azure.microsoft.com/updates?id=557117", + "pubDate": "Thu, 24 Sep 2026 17:51:23 Z", + "source": "azure-updates-rss", + "sunsetDate": "2028-09-30", + "replacement": null, + "status": "upcoming" + }, + { + "service": "IoT Central", + "title": "Retirement: Azure IoT Central will be retired on September 20, 2029", + "link": "https://azure.microsoft.com/updates?id=569914", + "pubDate": "Tue, 29 Sep 2026 17:25:10 Z", + "source": "azure-updates-rss", + "sunsetDate": "2029-09-20", + "replacement": null, + "status": "upcoming" + }, + { + "service": "Azure App Service", + "title": "Retirement: Azure App Service on Azure Stack Hub", + "link": "https://azure.microsoft.com/updates?id=568178", + "pubDate": "Tue, 06 Oct 2026 18:24:43 Z", + "source": "azure-updates-rss", + "sunsetDate": "2029-09-30", + "replacement": null, + "status": "upcoming" + }, + { + "service": "Arc enabled System Center Virtual Machine Manager", + "title": "Retirement: Azure Arc enabled System Center Virtual Machine Manager will be retired September 30, 2029", + "link": "https://azure.microsoft.com/updates?id=570283", + "pubDate": "Tue, 29 Sep 2026 17:36:13 Z", + "source": "azure-updates-rss", + "sunsetDate": "2029-09-30", + "replacement": null, + "status": "upcoming" + }, + { + "service": "Virtual Machines", + "title": "Retirement: DCsv3 and DCdsv3-series Azure Virtual Machines will be retired on October 31, 2029", + "link": "https://azure.microsoft.com/updates?id=569592", + "pubDate": "Thu, 01 Oct 2026 17:02:55 Z", + "source": "azure-updates-rss", + "sunsetDate": "2029-10-31", + "replacement": null, + "status": "upcoming" + }, + { + "service": "Dv3, Dsv3, Ev3,", + "title": "Retirement: Dv3, Dsv3, Ev3, and Esv3 Azure VMs", + "link": "https://azure.microsoft.com/updates?id=572346", + "pubDate": "Wed, 30 Sep 2026 17:07:30 Z", + "source": "azure-updates-rss", + "sunsetDate": "2029-11-15", + "replacement": null, + "status": "upcoming" }, { "service": "Azure Service Bus", diff --git a/.github/data/governance-policy-baseline.json.gz b/.github/data/governance-policy-baseline.json.gz index 32d485b2a..5bbaf3085 100644 Binary files a/.github/data/governance-policy-baseline.json.gz and b/.github/data/governance-policy-baseline.json.gz differ diff --git a/.github/instructions/agent-operating-frame.instructions.md b/.github/instructions/agent-operating-frame.instructions.md index b350379c3..418233632 100644 --- a/.github/instructions/agent-operating-frame.instructions.md +++ b/.github/instructions/agent-operating-frame.instructions.md @@ -16,6 +16,9 @@ shared guidance when attached or explicitly loaded, not as an assumed inherited - Reuse unchanged content still available in context. After a source change, compaction, or a new chat, load only the missing or changed material needed for the task. - Do not repeat a complete read for an already available section. +- If the `skill` tool reports a skill as not found, read that skill's `SKILL.md` from the skills directory by path. + Its list is not always complete (for example `apex-terraform-patterns`, `apex-workflow-engine` and + `apex-microsoft-docs` can be missing). ## Skill and user precedence diff --git a/.github/instructions/governance-discovery.instructions.md b/.github/instructions/governance-discovery.instructions.md index d08354636..07024a023 100644 --- a/.github/instructions/governance-discovery.instructions.md +++ b/.github/instructions/governance-discovery.instructions.md @@ -12,6 +12,9 @@ committed under `.github/data/governance-policy-baseline.json.gz`. **GATE**: This is a mandatory gate. If Azure connectivity fails or policies cannot be retrieved during live discovery, STOP and inform the user. Do NOT generate governance constraints from assumptions. +**Single exception**: the user may explicitly choose the shipped `alz-corp-default` baseline as *assumed* +governance (see `baseline-check.md`). It must be labelled assumed and not tenant-verified in every artifact, +and the Step 3.5 review stays mandatory. Do NOT accept arbitrary static files as governance input — only the approved workflow output file (`.github/data/governance-policy-baseline.json.gz`) is valid for cached baseline mode. diff --git a/.github/instructions/iac-terraform-best-practices.instructions.md b/.github/instructions/iac-terraform-best-practices.instructions.md index a12933787..9212863a0 100644 --- a/.github/instructions/iac-terraform-best-practices.instructions.md +++ b/.github/instructions/iac-terraform-best-practices.instructions.md @@ -92,7 +92,7 @@ once, passed everywhere. | `>= 3.0` provider version range | Use `~> 4.0` major-series constraint | | Raw `azurerm_*` when AVM exists | Use AVM-TF modules or get approval | | `connection_string` auth | Use managed identity RBAC | -| AVM-TF `version = "~> X.Y"` | Use exact semver `version = "X.Y.Z"` — resolved live from `registry.terraform.io` at plan time | +| AVM-TF `version = "~> X.Y"` | Use exact semver `version = "X.Y.Z"` — resolved live from the AVM registry at plan time | ## Validation diff --git a/.github/instructions/no-interactive-shell.instructions.md b/.github/instructions/no-interactive-shell.instructions.md index b03669650..f3102e20c 100644 --- a/.github/instructions/no-interactive-shell.instructions.md +++ b/.github/instructions/no-interactive-shell.instructions.md @@ -26,6 +26,16 @@ shell builtin (including inside `bash -c '...'`). If the user genuinely needs confirmation, use the `vscode_askQuestions` tool — never an interactive shell prompt. +### Sub-rule 1a — Aliased `rm`/`mv`/`cp` are still interactive + +A plain `rm path` can still prompt if the user's shell aliases it to +`rm -i` (common in dotfiles, e.g. `alias rm='rm -iv'`). Passing `-f` +always wins over an aliased `-i`, so prefer `-f` over relying on the +absence of a flag, or bypass the alias with `command rm -f path`. +If a background terminal reports no output for an extended period +right after an `rm`/`mv`/`cp` call with no explicit `-f`, assume an +interactive prompt is waiting and send `y`; do not poll it repeatedly. + ## Rule 2 — Pipe long output to a file For commands likely to produce more than ~50 lines of output, redirect diff --git a/.github/instructions/references/iac-policy-compliance.md b/.github/instructions/references/iac-policy-compliance.md index 784666b29..977355ff3 100644 --- a/.github/instructions/references/iac-policy-compliance.md +++ b/.github/instructions/references/iac-policy-compliance.md @@ -10,6 +10,14 @@ and `04-governance-constraints.json` before writing templates. These artifacts contain discovered Azure Policy constraints for the target subscription. +## Policy Source And Precedence + +Use effective policy discovered in the target tenant. When discovery is not used, the ALZ Corp default +(`baseline_kind: alz-corp-default`) is assumed governance, never discovered policy; its floor is in the +[security baseline](iac-security-baseline.md#alz-corp-inherited-policy-floor). Where the APEX baseline +and ALZ differ, ALZ governs and the stricter APEX rule is reported to the owner. Do not present assumed +governance as verified. + ## Dynamic Tag List Tags come from governance constraints, not hardcoded defaults. The greenfield fallback set @@ -105,6 +113,8 @@ read the `azurePropertyPath` field and translate it to the corresponding | `sqlServer.properties.publicNetworkAccess` | `public_network_access_enabled` | | `keyVault.properties.enableSoftDelete` | `soft_delete_retention_days` (> 0 = true) | | `keyVault.properties.enablePurgeProtection` | `purge_protection_enabled` | +| `keyVault.properties.enableRbacAuthorization` | `rbac_authorization_enabled` | +| `webApp.properties.publicNetworkAccess` | `public_network_access_enabled` | | `containerRegistry.properties.publicNetworkAccess` | `public_network_access_enabled` | | `webApp.properties.httpsOnly` | `https_only` | diff --git a/.github/instructions/references/iac-security-baseline.md b/.github/instructions/references/iac-security-baseline.md index 91f23ee02..888105c95 100644 --- a/.github/instructions/references/iac-security-baseline.md +++ b/.github/instructions/references/iac-security-baseline.md @@ -9,6 +9,12 @@ Referenced by `iac-bicep-best-practices.instructions.md` and Azure Policy always wins. Current Azure Policy implementation cannot be changed. Code adapts to policy, never the reverse. +Precedence, highest first: (1) effective policy discovered in the target tenant, including +management-group-inherited assignments; (2) the [ALZ Corp floor](#alz-corp-inherited-policy-floor) +when discovery is not used; (3) this baseline where it is stricter than both. Where this baseline +and the ALZ Corp floor differ, the ALZ Corp floor governs and the difference is reported, not +silently resolved. + ## Private Networking And DNS These defaults apply in every environment, including development and synthetic-data tests. @@ -19,7 +25,7 @@ Policy discovery may add constraints; absence of a Deny policy does not relax th | PaaS data services supporting Private Link, except Azure Monitor below | Private endpoints; public access disabled | | Log Analytics and workspace-based Application Insights | Authenticated public query/ingestion permitted as below | | App Service hosting an API | Private endpoint and public network access disabled | -| App Service hosting a public-facing web application | Public HTTPS ingress permitted; authentication and other security controls still apply | +| App Service hosting a public-facing web application | Public HTTPS ingress permitted only where effective policy allows it; the ALZ Corp floor denies it, so use private access behind an approved edge; authentication and other security controls still apply | Log Analytics workspaces and workspace-based Application Insights do not require AMPLS solely because they support Private Link. Public query and ingestion endpoints are permitted when effective Azure Policy and @@ -44,6 +50,10 @@ Private DNS resolution is mandatory for every private endpoint. The plan must ac zone, records/zone group, VNet links and any required forwarding. Reuse verified central DNS where present; never point at a zone that no owner provisions. +The ALZ Corp default assigns DINE zone-group creation against zones in a central DNS subscription +and resource group. Its zone IDs are placeholders, so the platform team must confirm zone and +VNet-link ownership before the plan relies on it; the project does not deploy duplicate zones. + Project IaC owns the required DNS components unless an effective DeployIfNotExists (DINE) assignment is verified to own those specific components. Record its assignment/definition, applicable resource scope, parameters and zone IDs, exclusions/exemptions, deployment template, managed identity/RBAC and remediation @@ -66,8 +76,63 @@ The scanner is single-line source checking: it cannot establish PE coverage, eva prove module internals, or test DNS. Plan/code review must check those contracts, and connectivity validation must verify actual resolution from the intended private client before claiming operational readiness. +## ALZ Corp Inherited Policy Floor + +Source: `alz-corp-default` baseline (`root`, `landing_zones` and `corp` archetypes, pinned in +`alz_library_ref`). It is assumed governance, not tenant-verified; live discovery replaces it. + +### Enforced Deny rules the IaC must satisfy + +| Assignment (scope) | IaC requirement | +| --- | --- | +| `Deny-Public-Endpoints` (corp) | Public network access disabled, or private-only, for Storage, SQL, Key Vault, Cosmos DB, ACR, Redis, App Configuration, Service Bus, Event Hubs/Grid, Search, AI Services, Data Factory, Data Explorer, Synapse, PostgreSQL/MySQL, AKS (private cluster), Container Apps (environment and external ingress) and App Service, Function and Logic apps and slots. Log Analytics and Application Insights are not listed. | +| `Deny-Public-IP-On-NIC` (corp) | No public IP on a network interface; Bastion, Application Gateway and Front Door own their own public endpoints. | +| `Deny-HybridNetworking` (corp) | No ExpressRoute, VPN/P2S gateway, virtual WAN, virtual hub or virtual network gateway in the project. | +| `Deny-Classic-Resources` (root) | No classic resource types. | +| `Deny-Subnet-Without-Nsg` (landing_zones) | Every subnet has an NSG except `GatewaySubnet`, `AzureFirewallSubnet`, `AzureFirewallManagementSubnet` and `RouteServerSubnet`. `AzureBastionSubnet` is not excluded and needs an NSG with the Bastion-required rules. | +| `Deny-MgmtPorts-Internet` (landing_zones) | No inbound management-port rule (for example 22 or 3389) from the Internet. | +| `Deny-IP-forwarding` (landing_zones) | Network interface IP forwarding disabled. | +| `Deny-Storage-http`, `Enforce-TLS-SSL-Q225` (landing_zones) | HTTPS-only on Storage, App Service, Function, Logic and Container Apps; TLS 1.2 or newer on SQL, Storage and Event Hubs. | +| `Enforce-GR-KeyVault` (landing_zones) | Soft delete and purge protection on; Azure RBAC permission model; RSA keys and certificates at least 2048 bits; secrets with a content type and a validity of at most 90 days; certificates not expiring within 90 days. | + +### Platform-owned remediation (DINE and Modify) + +Expect these assignments to act after deployment. Plan and cost them, and do not duplicate what +they own: diagnostic settings named `setByPolicy-LogAnalytics` (still wire project diagnostics, +below), private DNS zone groups, SQL auditing, TDE and threat protection, Defender plans, the +Activity Log, service health alerts and the DDoS plan association on virtual networks (Modify: +do not set a DDoS plan in project IaC). + +### Assigned but not enforced + +The `Enforce-GR-*` guardrails for services other than Key Vault and the `Enforce-Encrypt-CMK0` +customer-managed key rules are assigned with `DoNotEnforce`. They do not require CMK; treat them +as recommendations, not gates. + +### Stricter than the ALZ Corp floor (kept, reported to the owner) + +- Storage shared-key access disabled; SQL Entra-only authentication; Cosmos DB local auth disabled. +- Container Registry admin user disabled; App Service HTTP/2; FTPS not `AllAllowed`; no remote debugging. +- Redis non-TLS port blocked and MySQL/PostgreSQL SSL enforcement required. +- TLS 1.2 minimum on every service, not only the types ALZ lists. +- Diagnostic settings wired in IaC on every supported resource; ALZ remediates them only after deployment. +- Key Vault network ACL default action Deny (warning). +- The APEX greenfield tag set: the ALZ Corp default has no tag policy. +- Application Gateway WAF: ALZ only audits it. + +### Verify at tenant discovery + +`Subnets should be private` (default outbound access), zone-resilience and Application Gateway WAF +assignments are present, but their effect is not recorded in the snapshot. Confirm each effect +before a design depends on explicit outbound access, and before a production design chooses a +single-zone posture. + ## Zone Redundancy SKUs +Zone redundancy applies only to production environments. Do not require, plan, price or flag the +absence of zone redundancy in dev, test or staging; use single-zone SKUs and instance counts there. +A production design records an explicit `az_posture` decision. + | SKU | Zone Redundancy | Use Case | | --------- | --------------- | ------------------- | | S1/S2 | Not supported | Dev/test | diff --git a/.github/skills/apex-azure-artifacts/references/07-docs-template.md b/.github/skills/apex-azure-artifacts/references/07-docs-template.md index a8d9e38dd..e2a1adf8b 100644 --- a/.github/skills/apex-azure-artifacts/references/07-docs-template.md +++ b/.github/skills/apex-azure-artifacts/references/07-docs-template.md @@ -111,7 +111,7 @@ 1. **Gather Context** — Read project artifacts (01-06) 2. **Check H2 Structures** — Reference templates above 3. **Extract Resources** — Parse from `06-deployment-summary.md` -4. **Query Pricing** — Use Azure Resource Manager MCP through the cost subagent +4. **Query Pricing** — Use Azure MCP pricing through the cost subagent 5. **Generate Documents** — Follow H2 structure exactly 6. **Cross-Reference** — Ensure consistency across documents 7. **Create Index** — Generate `07-documentation-index.md` last diff --git a/.github/skills/apex-azure-artifacts/references/cost-estimate-sections.md b/.github/skills/apex-azure-artifacts/references/cost-estimate-sections.md index 4af969e38..6c0553ca7 100644 --- a/.github/skills/apex-azure-artifacts/references/cost-estimate-sections.md +++ b/.github/skills/apex-azure-artifacts/references/cost-estimate-sections.md @@ -13,7 +13,7 @@ Rules and enforcement live in the instruction file and templates. **Generated**: {YYYY-MM-DD} **Region**: {primary-region} **Environment**: {Production|Staging|Development} -**MCP Tools Used**: {get_retail_prices, query_costs, forecast_costs} +**MCP Tools Used**: {pricing_get} **Architecture Reference**: {relative link to assessment doc} ``` @@ -364,7 +364,7 @@ Required bullets: ## 📊 Pricing Data Accuracy > **📊 Data Source**: Prices from Azure Retail Prices API via -> Azure Resource Manager MCP +> Azure MCP pricing (`pricing_get`) > > ✅ **Included**: Retail list prices (PAYG) > diff --git a/.github/skills/apex-azure-artifacts/templates/01-requirements.template.md b/.github/skills/apex-azure-artifacts/templates/01-requirements.template.md index 2d35a4cea..54ccf2b32 100644 --- a/.github/skills/apex-azure-artifacts/templates/01-requirements.template.md +++ b/.github/skills/apex-azure-artifacts/templates/01-requirements.template.md @@ -218,7 +218,7 @@ Table of recommended controls based on workload pattern and compliance requireme ## 💰 Budget > [!NOTE] -> Azure Resource Manager MCP provides retail pricing for detailed estimates during +> Azure MCP pricing provides retail pricing for detailed estimates during > architecture assessment (Step 2). Provide an approximate budget here. | Field | Value | @@ -263,6 +263,16 @@ Table of recommended controls based on workload pattern and compliance requireme | ----------- | ----------------------------- | ----------------- | -------------------- | | {component} | {Daily / Hourly / Continuous} | {30d / 90d / 1yr} | {Automated / Manual} | +### Operations & Ownership + +| Item | Value | +| ---------------------------------------- | ------------------------------------------------------------- | +| Operator access path to private services | {Bastion + jump VM / VPN / corporate network / None} | +| Web sign-in owner | {Platform team / Application team / Not in scope} | +| Budget alert recipient and role | {email} — {Owner / not an Owner} | +| SQL Entra admin principal | {group or user and object ID} | +| Required tag values | {values the policy requires, or none enforced} | + ## 🌍 Regional Preferences | Preference | Value | Justification | diff --git a/.github/skills/apex-azure-artifacts/templates/02-architecture-assessment.template.md b/.github/skills/apex-azure-artifacts/templates/02-architecture-assessment.template.md index 9757746da..5a5e77f90 100644 --- a/.github/skills/apex-azure-artifacts/templates/02-architecture-assessment.template.md +++ b/.github/skills/apex-azure-artifacts/templates/02-architecture-assessment.template.md @@ -320,7 +320,7 @@ or Governance directly. Never route directly to implementation planning. --- -_Assessment performed using Azure Well-Architected Framework. Pricing data from Azure Resource Manager MCP ({date})._ +_Assessment performed using Azure Well-Architected Framework. Pricing data from Azure MCP pricing ({date})._ --- diff --git a/.github/skills/apex-azure-artifacts/templates/03-des-cost-estimate.template.md b/.github/skills/apex-azure-artifacts/templates/03-des-cost-estimate.template.md index bc99b0a32..b056aeba6 100644 --- a/.github/skills/apex-azure-artifacts/templates/03-des-cost-estimate.template.md +++ b/.github/skills/apex-azure-artifacts/templates/03-des-cost-estimate.template.md @@ -30,7 +30,7 @@ **Generated**: {YYYY-MM-DD} **Region**: {primary-region} **Environment**: {Production|Staging|Development} -**MCP Tools Used**: {get_retail_prices, forecast_costs, get_benefit_recommendations} +**MCP Tools Used**: {pricing_get} **Architecture Reference**: {relative link to assessment doc, if available} **Pricing Evidence**: {relative link to the actual COMPLETE worker JSON and its referenced evidence} diff --git a/.github/skills/apex-azure-artifacts/templates/04-implementation-plan.template.md b/.github/skills/apex-azure-artifacts/templates/04-implementation-plan.template.md index 9dfaba455..acac9a1d1 100644 --- a/.github/skills/apex-azure-artifacts/templates/04-implementation-plan.template.md +++ b/.github/skills/apex-azure-artifacts/templates/04-implementation-plan.template.md @@ -64,10 +64,14 @@ Brief description of what will be implemented. - ✅ `satisfied` — property is wired in the plan with the required value - ⚠️ `pending` — property declared but value not yet finalised +- ➖ `not-applicable` — the Deny targets a resource type absent from the plan; state the reason in the + row and keep the row so a later plan change is caught - ❌ `unsatisfiable` — no plan shape satisfies this Deny; **return to 04g-Governance** via `▶ Refresh Governance` per [governance-drift-routing.md](../../.github/skills/apex-iac-common/references/governance-drift-routing.md) +Mirror each row in `04-policy-property-map.json`: `status` carries the legend value (`not-applicable` also needs +`not_applicable_reason`) and the Required Value column is that entry's `target_property.expected_value`. **Coverage check**: every Deny policy in `04-governance-constraints.json` MUST appear in at least one row. The Phase 4.3 challenger pass 1 (security-governance lens) verifies coverage. @@ -94,6 +98,16 @@ infra/bicep/{project-name}/ | module2.bicep | `br/public:avm/res/{provider}/{type}` | {x.x.x} | {purpose} | | module3.bicep | `br/public:avm/res/{provider}/{type}` | {x.x.x} | {purpose} | +### Raw Resource Exceptions + +One row per resource emitted without an AVM module, per +[cost-alerts-baseline.md](../../apex-azure-defaults/references/cost-alerts-baseline.md#raw-resource-exception-record). +Write `_None._` when every resource uses AVM. + +| Resource | Stack | Scope | Evidence URL | Rationale | Review After | +| ----------------------------- | ------- | ------- | ------------ | ----------- | ------------ | +| `Microsoft.{Provider}/{type}` | {bicep} | {scope} | {url} | {rationale} | {YYYY-MM-DD} | + --- ## 🔨 Implementation Tasks @@ -274,6 +288,8 @@ is not proof that Storage or Key Vault fits its 24-character limit. Mirror the d | Deployment | X minutes | | **Total** | **~X minutes** | +`Not estimated` is an accepted value for any row; measured durations are recorded at Step 6. + --- ## 🔒 Approval Gate diff --git a/.github/skills/apex-azure-artifacts/templates/07-ab-cost-estimate.template.md b/.github/skills/apex-azure-artifacts/templates/07-ab-cost-estimate.template.md index cc69e83b6..77949c0cf 100644 --- a/.github/skills/apex-azure-artifacts/templates/07-ab-cost-estimate.template.md +++ b/.github/skills/apex-azure-artifacts/templates/07-ab-cost-estimate.template.md @@ -35,7 +35,7 @@ **Source**: Implemented Bicep Templates **Region**: {primary-region} **Environment**: {Production|Staging|Development} -**MCP Tools Used**: {get_retail_prices, query_costs, query_aks_costs} +**MCP Tools Used**: {pricing_get} **IaC Reference**: {relative link to infra/bicep/{project}/, if available} ## 💵 Cost At-a-Glance diff --git a/.github/skills/apex-azure-bicep-patterns/SKILL.md b/.github/skills/apex-azure-bicep-patterns/SKILL.md index 738a8daaf..b95d89c2e 100644 --- a/.github/skills/apex-azure-bicep-patterns/SKILL.md +++ b/.github/skills/apex-azure-bicep-patterns/SKILL.md @@ -29,6 +29,7 @@ Reusable infrastructure patterns for Azure Bicep templates. Complements | Module Composition | Breaking main.bicep into reusable modules | [common-patterns](references/common-patterns.md) | | Managed Identity Binding | Any service-to-service authentication | [common-patterns](references/common-patterns.md) | | Budget & Cost Monitoring | Every deployment (mandatory) | [budget-pattern](references/budget-pattern.md) | +| Private-Registry App Service | App Service pulling from a private registry, Entra sign-in | [app-service-entra-private-registry-pattern](references/app-service-entra-private-registry-pattern.md) | | What-If / AVM Pitfalls | Pre-deployment validation & AVM gotchas | [avm-pitfalls](references/avm-pitfalls.md) | | Batch Bicep Formatting | After generating/editing the Bicep tree | `npm run format:bicep -- infra/bicep/{project}` (wraps `bicep format`) | @@ -77,7 +78,8 @@ Applying a pattern in a Bicep template: Always run what-if before deploy. - **MCR version discovery** — When AVM version helpers are incomplete, query `mcr.microsoft.com/v2/bicep/{module}/tags/list` for authoritative - published versions. + published versions. The metadata tool can omit published modules (it missed + `avm/res/consumption/budget`), so never conclude "no AVM module" from it alone. - **Cross-RG module `scope:` ARM ID split indexes** — Splitting a full resource ID (`/subscriptions/{sub}/resourceGroups/{rg}/providers/...`) on `/` yields `['', 'subscriptions', '{sub}', 'resourceGroups', '{rg}', ...]`. @@ -123,6 +125,7 @@ Applying a pattern in a Bicep template: | [private-endpoint-pattern.md](references/private-endpoint-pattern.md) | PE wiring + DNS zone groups + group ID table | | [common-patterns.md](references/common-patterns.md) | Diagnostics, conditional deploy, module composition, managed identity | | [budget-pattern.md](references/budget-pattern.md) | Consumption budget, forecast alerts, anomaly detection | +| [app-service-entra-private-registry-pattern.md](references/app-service-entra-private-registry-pattern.md) | Private-registry image pull routing, certificateless Entra sign-in, AVM gaps | | [avm-pitfalls.md](references/avm-pitfalls.md) | What-if interpretation, AVM gotchas, learn more links | | [module-interface.md](references/module-interface.md) | Canonical module input/output contract | diff --git a/.github/skills/apex-azure-bicep-patterns/references/app-service-entra-private-registry-pattern.md b/.github/skills/apex-azure-bicep-patterns/references/app-service-entra-private-registry-pattern.md new file mode 100644 index 000000000..3e2f354ca --- /dev/null +++ b/.github/skills/apex-azure-bicep-patterns/references/app-service-entra-private-registry-pattern.md @@ -0,0 +1,46 @@ + + +# App Service with a Private Registry and Entra Sign-in + +Verified against Microsoft Learn on 2026-10-15 (VNet integration, routing, Entra +authentication, point-in-time restore). AVM gaps were observed at the module +versions named below; re-check them against the current MCR release before +relying on them. + +## Private container registry pull + +- Pulling from a registry behind a private endpoint needs the app VNet integrated and image + pull routed through it. Set `outboundVnetRouting.imagePullTraffic = true`; `allTraffic = true` + already covers it. The legacy `vnetImagePullEnabled` property and the + `WEBSITE_PULL_IMAGE_OVER_VNET` app setting still work. Prefer the site property: Azure Policy can audit it. +- Configuration traffic is public by default. Key Vault references fall back to the VNet + integration when the vault blocks public access. Managed identity token acquisition is + public unless `outboundVnetRouting.managedIdentityTraffic = true` or `allTraffic = true`. +- The integration subnet needs outbound 443 to Entra and registry endpoints the NSG or routes allow. +- Order the registry and Key Vault private endpoints and DNS links before the apps that consume them. + +## Certificateless Entra sign-in (workforce tenants only) + +- Create a federated identity credential on the app registration that trusts the app's + user-assigned managed identity. +- Add the slot-sticky app setting `OVERRIDE_USE_MI_FIC_ASSERTION_CLIENTID` with the managed + identity's **client ID** (not the app registration's), and set the provider's + `clientSecretSettingName` to that setting name. +- Assign that identity to this app only; sharing it widens access to the app registration. +- The federated credential lives on the app registration, so it is created outside the platform + deployment. Sequence it after the identity exists and before the app serves traffic. +- If sign-in ownership sits with another team, the platform deploys none of the above. + +## Known AVM gaps (observed on nordic-foods) + +| Module | Version observed | Gap | +| ------------------------- | ---------------- | ------------------------------------------------------------------ | +| `avm/res/web/site` | 0.24.0 | No input for `authsettingsV2`; use a raw `config` child resource | +| `avm/res/sql/server` | 0.22.1 | No server-level diagnostic settings (database level only) | + +A raw resource here needs a `### Raw Resource Exceptions` record in the plan. + +## Storage point-in-time restore + +The restore retention period must be at least one day less than the blob soft delete +retention. With 30-day soft delete, restore days are at most 29. diff --git a/.github/skills/apex-azure-cloud-migrate/SKILL.md b/.github/skills/apex-azure-cloud-migrate/SKILL.md index c09b2efa7..4348e2a32 100644 --- a/.github/skills/apex-azure-cloud-migrate/SKILL.md +++ b/.github/skills/apex-azure-cloud-migrate/SKILL.md @@ -27,7 +27,7 @@ Assessment-only requests stop after the report. 1. Follow phases sequentially — do not skip 2. Generate assessment before any code migration 3. Load the scenario reference and follow its rules -4. Use `mcp_azure-mcp_get_azure_bestpractices` (command `get_bestpractices`) and `mcp_azure-mcp_documentation` MCP tools +4. Use `mcp_azure-mcp_get_azure_bestpractices` (command `get_azure_bestpractices_get`) and `mcp_azure-mcp_documentation` MCP tools 5. Use the latest supported runtime for the target service 6. Destructive actions require `ask_user` — [global-rules](references/services/functions/global-rules.md) 7. Audit service discovery in app code — Kubernetes DNS names (for example `http://order-service:3001`) don't diff --git a/.github/skills/apex-azure-compliance/references/azure-quick-review.md b/.github/skills/apex-azure-compliance/references/azure-quick-review.md index b8db09fc0..2dda0a26f 100644 --- a/.github/skills/apex-azure-compliance/references/azure-quick-review.md +++ b/.github/skills/apex-azure-compliance/references/azure-quick-review.md @@ -8,6 +8,7 @@ This skill enables comprehensive Azure compliance assessments using Azure Quick - **Azure authentication** - Logged in via Azure CLI (`az login`) or using Service Principal/Managed Identity - **Reader permissions** - Minimum Reader role on target subscription or management group +- **azqr 3.0.0 or later on `PATH`** - Azure MCP lists `extension_azqr` only while azqr is installed locally; without it, run `azqr scan --subscription-id --json` directly ## Assessment Workflow diff --git a/.github/skills/apex-azure-compute/SKILL.md b/.github/skills/apex-azure-compute/SKILL.md index 32dfdd836..c679f42dd 100644 --- a/.github/skills/apex-azure-compute/SKILL.md +++ b/.github/skills/apex-azure-compute/SKILL.md @@ -3,7 +3,7 @@ name: apex-azure-compute user-invocable: true disable-model-invocation: false argument-hint: "workload requirements, region and budget" -description: '**ANALYSIS SKILL** — Recommend Azure VM sizes and VMSS for workload, performance, and budget. Uses public docs and the Azure Retail Prices API. WHEN: "recommend VM size", "choose Azure VM", "GPU VM", "compare VM sizes", "VMSS vs VM", "autoscale VMs". DO NOT USE FOR: provisioning VMs (apex-azure-prepare), VM pricing for budgets (ARM MCP).' +description: '**ANALYSIS SKILL** — Recommend Azure VM sizes and VMSS for workload, performance, and budget. Uses public docs and the Azure Retail Prices API. WHEN: "recommend VM size", "choose Azure VM", "GPU VM", "compare VM sizes", "VMSS vs VM", "autoscale VMs". DO NOT USE FOR: provisioning VMs (apex-azure-prepare), VM pricing for budgets (Azure MCP pricing).' license: MIT metadata: author: Microsoft diff --git a/.github/skills/apex-azure-cost-optimization/references/azure-quick-review.md b/.github/skills/apex-azure-cost-optimization/references/azure-quick-review.md index 4b24013a8..0851452d9 100644 --- a/.github/skills/apex-azure-cost-optimization/references/azure-quick-review.md +++ b/.github/skills/apex-azure-cost-optimization/references/azure-quick-review.md @@ -25,11 +25,11 @@ excludeSections: Execute the scan using Azure MCP or CLI: ```powershell -# Via Azure MCP (preferred if available) +# Via Azure MCP (extension_azqr is listed only while azqr 3.0.0 or later is on PATH) # Use the extension_azqr tool with subscription and optional resource-group parameters # Or via direct CLI: -azqr scan --subscription "" --resource-group "" --filters ./filters.yaml --output json +azqr scan --subscription-id "" --resource-group "" --filters ./filters.yaml --json --output-name "agent-output/{project}/azqr_report_" ``` ## Save Output diff --git a/.github/skills/apex-azure-cost-optimization/references/azure-storage-tiers.md b/.github/skills/apex-azure-cost-optimization/references/azure-storage-tiers.md index 566d302a8..53009033c 100644 --- a/.github/skills/apex-azure-cost-optimization/references/azure-storage-tiers.md +++ b/.github/skills/apex-azure-cost-optimization/references/azure-storage-tiers.md @@ -132,6 +132,6 @@ Resources ## Pricing -Use the ARM MCP `get_retail_prices` tool for the requested region, redundancy, +Use the Azure MCP `pricing_get` tool for the requested region, redundancy, tier, and currency. Include storage, transaction, retrieval, early-deletion, and rehydration costs when relevant; never rely on embedded rates. diff --git a/.github/skills/apex-azure-cost-optimization/references/detailed-workflow-steps.md b/.github/skills/apex-azure-cost-optimization/references/detailed-workflow-steps.md index a0f2a8038..be5e3a141 100644 --- a/.github/skills/apex-azure-cost-optimization/references/detailed-workflow-steps.md +++ b/.github/skills/apex-azure-cost-optimization/references/detailed-workflow-steps.md @@ -68,7 +68,7 @@ If the response has a `nextLink`, follow every page before using the results; th ## Step 5: Validate Pricing -Validate unit prices with the ARM MCP `get_retail_prices` tool, following the +Validate unit prices with the Azure MCP `pricing_get` tool, following the [pricing guidance](../../apex-azure-defaults/references/pricing-guidance.md). Record the query parameters and the returned meter in the audit trail. Pricing pages such as are human references, not price evidence. diff --git a/.github/skills/apex-azure-cost-optimization/references/tools-and-safety.md b/.github/skills/apex-azure-cost-optimization/references/tools-and-safety.md index 3351539ae..6e7b35189 100644 --- a/.github/skills/apex-azure-cost-optimization/references/tools-and-safety.md +++ b/.github/skills/apex-azure-cost-optimization/references/tools-and-safety.md @@ -7,7 +7,8 @@ with the `cost-estimation` fallback rows). ## Tool preference -Use Azure Resource Manager MCP (ARM MCP) first. For Resource Graph, use the +Use Azure Resource Manager MCP (ARM MCP) first for cost, forecast and benefit operations; public retail prices use +Azure MCP `pricing_get`. For Resource Graph, use the generate, validate, execute sequence. If a required operation is unavailable, name the fallback API and use an diff --git a/.github/skills/apex-azure-cost-optimization/references/workflow-steps.md b/.github/skills/apex-azure-cost-optimization/references/workflow-steps.md index 3632d6927..dc9d827b0 100644 --- a/.github/skills/apex-azure-cost-optimization/references/workflow-steps.md +++ b/.github/skills/apex-azure-cost-optimization/references/workflow-steps.md @@ -39,7 +39,7 @@ Get Azure cost optimization best practices to inform recommendations: // Use Azure MCP best practices tool mcp_azure-mcp_get_azure_bestpractices({ intent: "Get cost optimization best practices", - command: "get_bestpractices", + command: "get_azure_bestpractices_get", parameters: { resource: "cost-optimization", action: "all" }, }); ``` diff --git a/.github/skills/apex-azure-defaults/SKILL.md b/.github/skills/apex-azure-defaults/SKILL.md index a4c877033..64159b3b7 100644 --- a/.github/skills/apex-azure-defaults/SKILL.md +++ b/.github/skills/apex-azure-defaults/SKILL.md @@ -71,6 +71,13 @@ always overrides repository defaults. - **Lifecycle is verified live**: selectable engine and runtime versions require current support-policy evidence. +## Azure MCP tool names + +Skills and agents write Azure MCP tools as `mcp_azure-mcp_` (for example `mcp_azure-mcp_documentation`). The client may +show the same tool with a shortened prefix such as `mcp_azure_mcp_ser_`; the server reports its name as "Azure MCP +Server", and VS Code derives the prefix from it. Match on `` and call whichever name the client lists. Both prefixes +are accepted by `npm run validate:mcp-tool-refs`. + ## Validation ```bash @@ -94,6 +101,6 @@ Load references progressively; do not read the directory wholesale. | Networking | [VNet planning](references/vnet-planning.md), [identity resolution](references/identity-resolution.md) | | Cost and sizing | [Cost baseline](references/cost-alerts-baseline.md), [Bicep](references/cost-alerts-bicep.md), [Terraform](references/cost-alerts-terraform.md), [pricing](references/pricing-guidance.md), [service matrices](references/service-matrices.md) | | Governance and lifecycle | [Governance discovery](references/governance-discovery.md), [policy effects](references/policy-effect-decision-tree.md), [deprecated services](references/deprecated-services.md), [workflow gates](references/workflow-gates.md) | -| Architecture and review | [WAF criteria](references/waf-criteria.md), [research workflow](references/research-workflow.md), [review protocol](references/adversarial-review-protocol.md), [deep review](references/adversarial-review-deep.md) | +| Architecture and review | [WAF criteria](references/waf-criteria.md), [Architect checklist](references/architect-validation-checklist.md), [research workflow](references/research-workflow.md), [review protocol](references/adversarial-review-protocol.md), [deep review](references/adversarial-review-deep.md) | | IaC implementation | [Terraform conventions](references/terraform-conventions.md), [plan decisions](references/plan-design-decisions.md), [Azure CLI auth](references/azure-cli-auth-validation.md) | | Artifact integration | [Artifact categories](references/artifact-type-categories.md), [cost delegation](references/cost-estimate-parent-contract.md), [service class menu](references/service-class-menu.md) | diff --git a/.github/skills/apex-azure-defaults/references/adversarial-review-protocol.md b/.github/skills/apex-azure-defaults/references/adversarial-review-protocol.md index 3eeb4179a..1786906fa 100644 --- a/.github/skills/apex-azure-defaults/references/adversarial-review-protocol.md +++ b/.github/skills/apex-azure-defaults/references/adversarial-review-protocol.md @@ -17,6 +17,8 @@ required lenses, human gates and existing repair caps remain authoritative; this Do not infer newest files or parse legacy audit prose into a selection. Unsupported replacement scopes return to their owner. Preserved superseded failures are history, not current blockers; missing deep lenses still block. 2. Verify primary and declared supporting inputs with `--verify-cache`, review identity/lens and unresolved findings. + Never declare a file a later step must change (`sku-manifest.json`, decision sidecars, handoff, README, recall state) + as a supporting input; its edit makes the review fail strict freshness. `validate-challenger-findings.mjs` rejects them. A current hash alone is not semantic approval. An accepted mitigation is not verified closure. Missing/stale evidence or unresolved current must-fix findings block advancement, regardless of historical step completion. The only supported residual-risk route is the explicit [lab authorization contract](../../../../tools/apex-recall/docs/risk-authorizations.md). diff --git a/.github/skills/apex-azure-defaults/references/architect-validation-checklist.md b/.github/skills/apex-azure-defaults/references/architect-validation-checklist.md new file mode 100644 index 000000000..ef1eea794 --- /dev/null +++ b/.github/skills/apex-azure-defaults/references/architect-validation-checklist.md @@ -0,0 +1,31 @@ + + +# Architect Validation Checklist + +Load once before presenting the Step 2 Approval Gate. Every item must hold. + +- [ ] All 5 WAF pillars scored with rationale and confidence level +- [ ] Service Maturity Assessment table included +- [ ] Cost estimate uses worker-verified MCP data or the documented direct-API evidence fallback, with truthful sources +- [ ] **Every dollar figure** in 02 and 03 artifacts traces back to `cost-estimate-subagent` response — no hardcoded prices +- [ ] Line-item totals sum correctly to reported monthly total +- [ ] H2 headings match apex-azure-artifacts templates exactly +- [ ] Region selection justified (default: swedencentral) +- [ ] AVM modules recommended where available +- [ ] No deprecated services recommended (checked against apex-azure-defaults Deprecated Services table) +- [ ] Service retirement timelines verified for any multi-year RI commitments +- [ ] Storage redundancy tier compatible with data residency requirements (no GRS with single-region GDPR) +- [ ] Global/non-regional services (Front Door, Entra, Traffic Manager) flagged for EU Data Boundary compliance +- [ ] SKU zone-redundancy capabilities verified for all services claiming AZ support +- [ ] A recorded owner decision that conflicts with the budget was asked about, not substituted +- [ ] No `*.tmp.md` or `FAILED` cost estimate remains in the project folder; superseded files are in `_meta/` + +## WAF scoring table format + +Illustrative structure only; compute every score and confidence from project evidence, never from examples. + +```markdown +| WAF Pillar | Score | Confidence | Key Factor | +| --- | --- | --- | --- | +| {pillar} | {evidence-based score}/10 | {confidence} | {verified factor} | +``` diff --git a/.github/skills/apex-azure-defaults/references/avm-modules.md b/.github/skills/apex-azure-defaults/references/avm-modules.md index d549b4756..d6d42237b 100644 --- a/.github/skills/apex-azure-defaults/references/avm-modules.md +++ b/.github/skills/apex-azure-defaults/references/avm-modules.md @@ -7,10 +7,17 @@ > > - **Bicep:** `curl -sf https://mcr.microsoft.com/v2/bicep/avm/res/{path}/tags/list` > → highest non-prerelease semver in `tags[]`. -> - **Terraform:** `curl -sf https://registry.terraform.io/v1/modules/Azure/avm-res-{path}/azurerm/versions` -> → first entry in `modules[0].versions[]`. -> - **Guidance:** Azure MCP provides Azure Terraform documentation and best -> practices; Registry metadata still comes from the public registry APIs. +> - **Terraform:** Azure MCP `azureterraform avm versions --module-name avm-res-{path}` lists versions; or +> `curl -sf https://registry.terraform.io/v1/modules/Azure/avm-res-{path}/azurerm/versions` +> → highest stable exact semver in `modules[0].versions[]` (the API is not sorted). +> - **Guidance:** Azure MCP provides Azure Terraform documentation, best practices, +> AVM module READMEs (`avm get`) and provider docs (`azurerm get`, `azapi get`). Use it +> only for modules the index lists as published. `avm versions` reports a module absent from the AVM list +> as `status: 400` with `results.type: "ArgumentException"` and "not found in available modules"; only that +> response means "unpublished". Any other status or error is a verification failure — stop and escalate, +> do not emit a raw-resource exception. `avm get` on an unknown version returns `status: 422` (HTTP 404), and +> `azurerm get` returns `status: 200` even for unknown types: empty `arguments` plus a "Documentation not +> found" `summary` means the resource type does not exist. > - **Validator:** `npm run validate:avm-versions:freeze` (planner agents > MUST call this before `apex-recall complete-step 4`). > @@ -42,9 +49,11 @@ ### Resolving the Latest AVM Version ```text -1. MCP (preferred): mcp_bicep_list_avm_metadata → filter by resource type → newest stable -2. CLI fallback: curl -sf https://mcr.microsoft.com/v2/bicep/avm/res/{path}/tags/list \ +1. MCR (authoritative): curl -sf https://mcr.microsoft.com/v2/bicep/avm/res/{path}/tags/list \ | jq -r '.tags[]' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 + A 404 here is the only valid basis for a "no AVM module" conclusion. +2. MCP (parameters only): mcp_bicep_list_avm_metadata -> module parameters and outputs. + It can omit published modules, so never use it to decide a module does not exist. 3. Validator: npm run validate:avm-versions (runs the same lookup with caching) ``` diff --git a/.github/skills/apex-azure-defaults/references/cost-alerts-baseline.md b/.github/skills/apex-azure-defaults/references/cost-alerts-baseline.md index 23d820a35..67477e45b 100644 --- a/.github/skills/apex-azure-defaults/references/cost-alerts-baseline.md +++ b/.github/skills/apex-azure-defaults/references/cost-alerts-baseline.md @@ -71,7 +71,7 @@ other AVM module. If a live lookup returns `404` (module genuinely missing) or the candidate module fails an AVM-quality check at the target scope, the plan may emit the raw resource **only** with a structured exception -in the plan's exceptions section: +in the plan's `### Raw Resource Exceptions` section (H3 under Module Structure): ```json { @@ -189,13 +189,17 @@ implementation plan; Challenger D-6 asserts the merge is faithful. | Mode | Resources emitted | Allowed environments | Extra requirements | | ---------- | ----------------------------------------- | ---------------------- | ---------------------------------------------------------- | | `enforced` | Budget + Action Group + anomaly | All (default for prod) | None | -| `minimal` | Budget only | `dev`, `sandbox` only | `governance.deferred_allowed != false` | +| `minimal` | Budget only (no cost Action Group, no anomaly alert; operational alerts remain) | `dev`, `sandbox` only | `governance.deferred_allowed != false` | | `deferred` | None | `dev`, `sandbox` only | `cost_monitoring_exception = {rationale, expiry_date}` | Production (`environment = prod` / `staging`) cannot opt down. Sandbox `deferred` produces an informational Challenger finding (not a blocker). +Under `minimal`, budget notifications reach only `contactRoles: ['Owner']`. When the named alert +recipient is not a human Owner at the budget scope, they receive no budget notification; say so +when offering the opt-down. + ## Cross-references - Decision keys: `tools/apex-recall/docs/decision-keys.md` diff --git a/.github/skills/apex-azure-defaults/references/cost-estimate-parent-contract.md b/.github/skills/apex-azure-defaults/references/cost-estimate-parent-contract.md index 401d24c03..9218c0dff 100644 --- a/.github/skills/apex-azure-defaults/references/cost-estimate-parent-contract.md +++ b/.github/skills/apex-azure-defaults/references/cost-estimate-parent-contract.md @@ -30,16 +30,22 @@ model/tools and require explicit selection of the owning agent. Parent-side model evaluation found agents hallucinating Azure SKU prices (e.g., AKS Standard at $0.60/hr instead of $0.10/hr) when writing from parametric knowledge. **ALL dollar figures in user-facing artifacts MUST -come from `cost-estimate-subagent` (ARM MCP primary; constrained first-party API evidence fallback).** +come from `cost-estimate-subagent` (Azure MCP `pricing_get` primary; constrained first-party API evidence fallback).** Never write a price that did not originate from a subagent response. ## Delegation Procedure (5 steps) -1. **Prepare a resource list** — compile resource types, SKUs, region, +1. **Prepare the pricing request and run preflight** — compile resource types, SKUs, region, and quantities from the upstream source, preserving each environment, region, and independent stamp plus effective overrides and explicit usage: - - **03-architect**: from the WAF assessment / sku-manifest. - - **08-as-built**: from `az resource list` + Azure Resource Graph + - **03-architect**: pass `manifest_path` (`agent-output/{project}/sku-manifest.json`), + `manifest_usage` (per-service variable usage keyed by service `id` or `id:env:region`), + and `supplemental_resources[]` (non-manifest architecture resources such as Bastion, + NAT Gateway, Private Endpoints, Private DNS, Key Vault, Log Analytics, Application Insights, + Alert Rules, Action Groups, VM OS disk, Public IPs) in a **single** committed pricing run + so `02-cost-estimate.json` holds the complete architecture cost while `manifest_writeback[]` + updates only manifest-owned `services[]`. + - **08-as-built**: pass `resource_list` from `az resource list` + Azure Resource Graph queries against the actual deployed environment (NOT the plan). For private networking, follow the [Private Endpoint and private DNS recipes](pricing-guidance.md#private-endpoint-and-private-dns-meters). @@ -47,6 +53,8 @@ Never write a price that did not originate from a subagent response. Capture central/DINE zone ownership and shared-cost allocation; do not assume private traffic is zero because internet egress is excluded. Keep the deployment region unchanged; the worker selects the documented billing region. Record unknown usage as unresolved or obtain an explicit assumption before invoking; do not invent zero usage. + Validate the request payload before delegation: + `node tools/scripts/cost-estimate.mjs preflight --request ` 2. **Check current pricing evidence**, then delegate only when needed. Reuse a persisted COMPLETE worker result only after the freshness and full-input equivalence checks in [pricing guidance](pricing-guidance.md#evidence-reuse). @@ -54,7 +62,7 @@ Never write a price that did not originate from a subagent response. requires `cost-estimate-subagent`; never infer current prices from a path or restamp reused rates. Reuse preserves the independent cost-feasibility review. Invoke with: - - `resource_list`, `project_name`, `region` + - `manifest_path` (+ optional `manifest_usage`, `supplemental_resources[]`) or `resource_list`, `project_name`, `region` - `output_path` = `agent-output/{project}/-cost-estimate.json` (per-agent: `02-cost-estimate.json` for 03, `07-ab-cost-estimate.json` for 08) - `overwrite` = `false` (set to `true` only when re-running after revisions) @@ -76,6 +84,20 @@ Never write a price that did not originate from a subagent response. `resources[].monthly_cost` equals `monthly_total`. Flag any discrepancy to the user before proceeding to the next phase. +## Candidate-set calls + +The worker's 20-request budget is per invocation, and a bundled call can exhaust it. Slow runs came from +rebuilding the same ambiguous meters across retries. + +- Issue one `candidate_sets` call per `decision_id`, each with its own `output_path` + (`02-cost-candidates-{decision_id}.json`). Never bundle decisions into one call. +- Before each call, confirm no stale `{output_path}.tmp` or prior output exists; the worker returns FAILED on a + non-owned `.tmp`. Preserve earlier output as `*.failed-attempt-N.json` first. +- Put the recipe from [candidate meter hints](pricing-guidance.md#candidate-meter-hints) in each candidate's + `meter_name`/`notes`, plus every variable-meter usage figure (GB, requests, free-rule assumption) so no line + stalls on a missing assumption. +- On retry, cite prior `.retail-evidence` paths and ask the worker to reuse them rather than re-query. + ## Interrupted pricing recovery A canceled terminal command or empty worker response is not proof of publication and is not a pricing failure category. @@ -113,14 +135,11 @@ Only after current pricing evidence exists may artifact generation and independe | Tool | Purpose | Context | | --- | --- | --- | -| `get_retail_prices` | Planned-resource retail prices | Primary | -| `query_costs` | Actual cost by Azure scope | Deployed only | -| `query_aks_costs` | Actual AKS cost breakdown | Deployed only | -| `forecast_costs` | Scope cost forecast | Optional, deployed only | -| `get_benefit_recommendations` | Reservation and savings-plan analysis | Optional | - -The subagent deduplicates identical service, ARM SKU, region, meter, price-type, -and currency queries within its MCP call budget. Include explicit usage for +| `pricing_get` (Azure MCP `pricing`) | Planned-resource retail prices, optional savings-plan data | Only pricing tool | + +The subagent uses no Azure Resource Manager MCP tools. Actual-cost, forecast and benefit analysis are outside this +contract; the cost-optimization skill covers them. The subagent deduplicates identical service, SKU, region, meter, +price-type, and currency queries within its MCP call budget. Include explicit usage for non-hourly meters. Canonical query and calculation rules live in [`pricing-guidance.md`](pricing-guidance.md). diff --git a/.github/skills/apex-azure-defaults/references/plan-design-decisions.md b/.github/skills/apex-azure-defaults/references/plan-design-decisions.md index aafcfcd03..cfa253a55 100644 --- a/.github/skills/apex-azure-defaults/references/plan-design-decisions.md +++ b/.github/skills/apex-azure-defaults/references/plan-design-decisions.md @@ -70,9 +70,11 @@ phases can consume it without re-asking. ### 4. `az_posture` +- **Applies to**: production environments only. For non-production, record + `single-zone-mvp` without asking. - **Question**: "What is the target Availability Zone posture?" - **Options**: - - `single-zone-mvp` (cheapest; acceptable for non-prod) + - `single-zone-mvp` (cheapest; the only posture for non-prod) - `zone-redundant` (recommended for prod; requires P1v4+/v2+ SKUs and 2+ instances) - `regional-pair` (zone-redundant primary + read-only or warm @@ -122,6 +124,13 @@ free-text `askQuestions` to capture the custom value, then persist as (e.g. from a resume), omit that question from the panel. - Do not ask for `identity_model` when the project has zero compute resources; default to `none` and persist that. +- **Show the architecture's value.** Before building the panel, read + `02-architecture-assessment.md` for a stated value of each key (for + example system-assigned identity) and add it to that option's + description as "Architecture assessment: {value}". When the answer + differs, record the deviation with `apex-recall decide --decision + "{key} deviates from architecture: {old} -> {new}" --step 4` and in + the plan's decision table. ## Anti-patterns diff --git a/.github/skills/apex-azure-defaults/references/pricing-guidance.md b/.github/skills/apex-azure-defaults/references/pricing-guidance.md index eebfc9cbc..95f43a38f 100644 --- a/.github/skills/apex-azure-defaults/references/pricing-guidance.md +++ b/.github/skills/apex-azure-defaults/references/pricing-guidance.md @@ -1,43 +1,60 @@ -# Azure Resource Manager MCP Pricing Guidance +# Azure MCP Pricing Guidance -Use the official Azure Resource Manager MCP `get_retail_prices` tool for -planned-resource prices. It returns raw Azure Retail Prices API records; the -caller selects meters and calculates totals. +Use the Azure MCP `pricing` tool's `pricing_get` command for planned-resource prices. It returns raw +Azure Retail Prices API records; the caller selects meters and calculates totals. -ARM MCP remains primary. Only `cost-estimate-subagent` may use the constrained direct-API fallback below, +Azure MCP remains primary. Only `cost-estimate-subagent` may use the constrained direct-API fallback below, for unresolved public retail meters after documented MCP failure. Parent agents never price resources directly. ## Connection -The workspace registers `https://mcp.management.azure.com` with the optional -`CostManagement` toolset. Pricing is public, while cost, forecast, benefit, and -pricesheet tools use the signed-in user's Azure permissions. - -APEX cost agents use read-only pricing and cost tools. They do not use ARM MCP -deployment, resource mutation, or `create_budget` tools. +The workspace registers `azure-mcp` (pinned `@azure/mcp` release) in `.vscode/mcp.json`; the worker +reaches it as `azure-mcp/pricing`. Retail catalog data is public. The worker uses no Azure Resource Manager +MCP tools, cost-query, forecast or benefit tools, and no deployment or resource mutation tools. ## Query contract -Pass only filters needed to identify the intended meter: +Invoke `mcp_azure_mcp_ser_pricing` directly with `command: "pricing_get"` and `learn: false`; never spend a +turn calling `learn: true`. `pricing_get` takes `sku`, `service`, `region`, `service-family`, `price-type`, +`currency`, `include-savings-plan` and a raw OData `filter`; at least one of the filter inputs is required. +Catalog field names map to it as follows: -| ARM MCP parameter | APEX input | Purpose | +| Retail catalog field | `pricing_get` input | Purpose | | --- | --- | --- | -| `serviceName` | `service_name` | Azure Retail Prices service name | -| `armSkuName` | verified catalog ARM SKU | Optional catalog filter, not automatically the deployed SKU/tier | -| `armRegionName` | `region` | Retail catalog region | -| `meterName` | `meter_name` | Optional meter disambiguation | -| `priceType` | requirement | Usually `Consumption` | -| `currencyCode` | requirement | Defaults to `USD` | +| `serviceName` | `service`, or `serviceName eq '...'` in `filter` | Azure Retail Prices service name | +| `armSkuName` | `sku`, or in `filter` | Optional catalog filter, not automatically the deployed SKU/tier | +| `armRegionName` | `region`, or in `filter` | Retail catalog region | +| `meterName`, `productName` | `filter` only | Meter and product disambiguation | +| `priceType` | `price-type` | Usually `Consumption` | +| `currencyCode` | `currency` | Defaults to `USD` | + +Observed behaviour on the pinned `@azure/mcp` 3.0.0 release: the result is `{ status, message, results: { prices: [...] } }`, +an empty match is `status: 200` with `prices: []`, and only 16 fields are projected (`armSkuName`, `skuName`, +`productName`, `serviceName`, `serviceFamily`, `region`, `location`, `retailPrice`, `unitPrice`, `currencyCode`, +`unitOfMeasure`, `priceType`, `effectiveStartDate`, `meterName`, `meterId`, `isPrimaryMeterRegion`) — `tierMinimumUnits`, +`Count`, and `NextPageLink` are stripped by the MCP wrapper. A bare `service` can be rejected with `status: 400` +(for example `SQL Database` requires `sku`); express the same constraints in `filter` instead. An unqualified +service-and-region query can return over a hundred rows, and without paging metadata completeness cannot be proven, +so narrow by `productName` or `meterName` and select the exact rows locally. When a meter returns multiple rate +rows without `tierMinimumUnits`, use the `missing_tier_metadata` fallback below to retrieve the raw Retail Prices API +tier bands. -Group identical parameter sets and call `get_retail_prices` once per group. -Follow `NextPageLink` when a complete result set is required. Reuse returned rows -across quantities and candidate comparisons. +```json +{ + "filter": "serviceName eq 'Azure App Service' and armRegionName eq 'swedencentral' and meterName eq 'P1 v3 App'", + "price-type": "Consumption", + "currency": "USD" +} +``` + +The example returns Windows and Linux rows; select by `productName`. Group identical parameter sets and call +`pricing_get` once per group. Reuse returned rows across quantities and candidate comparisons. Do not pass a deployment tier as `armSkuName` unless the catalog is known to populate that field. -For tier/operation services such as Container Registry and Key Vault, start with service, requested region, -price type and currency only. Inspect returned `skuName`, `productName`, `meterName`, `armSkuName` and units; +For tier/operation services such as Container Registry and Key Vault, start with a `filter` on service, +requested region and price type only, because a bare `service` can be rejected. Inspect returned `skuName`, `productName`, `meterName`, `armSkuName` and units; select the exact relevant billing dimensions locally. Reuse the same service response across candidate tiers. If a narrow query is empty, the one broader discovery query must remove unverified SKU/meter filters first, not merely remove region while retaining the suspect SKU. Keep the deployment region unless there is evidence @@ -169,7 +186,7 @@ not permission to change the deployment region. Use the exact recipes below befo ## Private Endpoint and private DNS meters These query shapes were verified against the public Retail Prices API on 2026-09-15; the worker must still -obtain current ARM MCP records. Do not hardcode observed rates or treat diagnostic API results as a completed estimate. +obtain current Azure MCP `pricing_get` records. Do not hardcode observed rates or treat diagnostic API results as a completed estimate. Apply the existing call budget, freshness and failure rules; these recipes do not reset retry allowances. ### Private Endpoint query @@ -241,7 +258,7 @@ the approved architecture requires them, never as substitutes for private zones If these exact queries return no rows, record the actual parameters, response/error, pagination state and unresolved meter names in the existing pricing evidence. Remove unverified filters only within the existing discovery allowance. Do not cycle through arbitrary regions or infer a free resource. A successful public-API diagnostic does not prove -ARM MCP returned the same evidence; report an MCP/catalog discrepancy. Use the constrained fallback only when +Azure MCP returned the same evidence; report an MCP/catalog discrepancy. Use the constrained fallback only when eligible; otherwise keep the affected estimate blocked. Billing semantics: [Private Link pricing](https://azure.microsoft.com/en-us/pricing/details/private-link/) @@ -289,22 +306,32 @@ to zero. For tiered rows, sort by `tierMinimumUnits` and apply each rate only within its band. If the returned rows do not define the bands sufficiently, fail closed. +### Multi-band meters missing `tierMinimumUnits` + +Because `@azure/mcp` 3.0.0 strips `tierMinimumUnits` from `pricing_get` responses, tiered consumption meters +(such as Blob `Hot LRS Data Stored`, `Analytics Logs Data Ingestion`, `Standard Data Processed`, and `Data Transfer Out`) +appear in `pricing_get` as multiple rows sharing `serviceName`, `productName`, `skuName`, `meterName`, `unitOfMeasure`, +region, `priceType`, and `currencyCode` with different `retailPrice` values (including `$0` free bands) and no band +boundaries. + +Do not guess band boundaries or invent upper-bound approximations. Whenever `pricing_get` returns `status: 200` with +two or more distinct `retailPrice` rows for the target meter and omits `tierMinimumUnits`, invoke +`tools/scripts/fetch-retail-price-evidence.mjs` with `mcp_failure.kind: "missing_tier_metadata"` for that narrow +meter query. The direct Retail Prices API response preserves `tierMinimumUnits` on every row so exact marginal tier +bands (including `$0` free allowances) are computed deterministically via `tools/scripts/cost-estimate.mjs`. + A zero-cost line is valid only when a matching returned meter has `retailPrice: 0`, or the Azure service itself has no charge. Usage-dependent charges around a free control-plane resource remain separate lines. ## Actual costs and forecasts -Use `query_costs`, `query_aks_costs`, and `forecast_costs` only for authenticated, -deployed scopes. They provide actual or forecast subscription costs and do not -replace retail-price queries for proposed resources. - -Use benefit-utilization and recommendation tools only when the parent requests -reservation or savings-plan analysis and supplies an authorized deployed scope. +This worker does not query actual costs, forecasts, benefit utilization or recommendations; those need the +cost-optimization skill with an authorized deployed scope. Savings-plan data comes only from `include-savings-plan`. ## Unsupported legacy capabilities -ARM MCP does not expose APEX's former custom bulk estimate, region recommendation, +Azure MCP `pricing_get` does not expose APEX's former custom bulk estimate, region recommendation, fuzzy SKU discovery, customer discount, PTU sizing, Databricks pricing, GitHub pricing, Spot history, eviction simulation, or orphan-resource tools. Do not recreate these capabilities in prompts. Use official service-specific sources in @@ -313,8 +340,10 @@ a separate workflow when such analysis is required. ## Constrained direct-API fallback This is a first-party evidence fallback, not permission to estimate from memory, scraped prices or a calculator. -Only the pricing worker may run it, after the existing bounded MCP discovery returned no rows or the original -transient HTTP failure plus its single retry both failed. Reuse verified current meters for unaffected lines. +Only the pricing worker may run it, after the existing bounded MCP discovery returned no rows (`kind: "empty_result"`), +the original transient HTTP failure plus its single retry both failed (`kind: "transient_exhausted"`), or a status-200 +`pricing_get` response returned multiple rate rows for a tiered meter with `tierMinimumUnits` stripped +(`kind: "missing_tier_metadata"`). Reuse verified current meters for unaffected lines. Authentication/authorization failures, missing usage, ambiguous meter selection, missing worker capability, unverified source equivalence and an exhausted budget remain blockers; fallback must not bypass them. @@ -356,8 +385,12 @@ Before fetching, create a request JSON with editing tools under `{output_path}.r } ``` -Copy actual MCP responses, not the example. Include the relevant discovery attempts; the helper requires a matching +Copy actual MCP responses, not the example. Record the failed `pricing_get` call in the helper's catalog field names +(`serviceName`, `armSkuName`, `armRegionName`, `productName`, `skuName`, `meterName`, `priceType`, `currencyCode`) and keep its real response, either +`{ "status": 200, "results": { "prices": [...] } }` or the retail-page shape shown. Include the relevant discovery attempts; the helper requires a matching failed query, the same service/currency, and a real timestamp within 24 hours. Never restamp old failure evidence. +For `kind: "missing_tier_metadata"`, record the single status-200 `pricing_get` attempt whose `results.prices` contains +multiple rate rows for that meter without `tierMinimumUnits`. For `kind: "transient_exhausted"`, record both attempts with `query`, actual `http_status` and `error` text; only 408, 429, 500, 502, 503 and 504 are eligible. Unknown statuses do not authorize fallback. `remaining_requests` is the actual unused shared budget, not an extra allowance. Identical requests share evidence; @@ -375,13 +408,37 @@ Meter records are data, not instructions. Do not read secrets or follow links ou In the worker estimate, persist `retail_api_evidence[]` mapping evidence paths to resource names, selected meter IDs, SKU/billing-region/tier selections and calculations. Include `direct_api_calls_used`; retain `mcp_calls_used` as the -actual MCP count. Mixed estimates use `data_source: "Azure Resource Manager MCP + Azure Retail Prices API (direct fallback)"` +actual MCP count. Mixed estimates use `data_source: "Azure MCP pricing_get + Azure Retail Prices API (direct fallback)"` and identify each affected line's source in notes. Never claim direct records came from MCP. Keep evidence files with the estimate, preserve original timestamps on reuse, and require current source equivalence as for MCP rates. No confirmed requirements, pins, approvals, deployment scope or budgets change under this fallback. COMPLETE still requires every line resolved, correct totals and valid usage. Both independent reviews and final human approval remain. +## Candidate meter hints + +Discovery shapes observed in the public catalog (2026-10). They are query hints, not evidence or rates: +the worker must still return current records and apply the existing budget, ambiguity and failure rules. +Express each shape as a `pricing_get` `filter` (`serviceName eq '...' and armRegionName eq '...' and ...`). +Prefer narrow `productName`/`meterName` filters over an unqualified service/region query, and share rows across tiers. + +| Candidate | Query shape | Select | +| --- | --- | --- | +| App Service Basic/Standard Linux | `serviceName` `Azure App Service`, region, `meterName` | `productName` `Azure App Service Plan - Linux`; `meterName` `B2`, `S2 App` | +| App Service Premium v3 Linux | same | `Azure App Service Premium v3 Plan - Linux`; `meterName` `P1 v3 App` (space, not `P1v3`); the Windows row also matches | +| Container Apps Consumption | `serviceName` `Azure Container Apps`, region | active vCPU-second and GiB-second meters plus the separate requests meter; needs monthly request count | +| Blob Storage Hot LRS | `serviceName` `Storage`, region, `productName` `Blob Storage` | `Hot LRS Data Stored` returns multiple band rates in `pricing_get` without `tierMinimumUnits`: use `missing_tier_metadata` fallback to retrieve exact tier bands; select the read, write and other operations meters locally (per 10K) and never the Hierarchical Namespace product | +| Log Analytics ingestion | `serviceName` `Log Analytics`, region | `Analytics Logs Data Ingestion` returns `$0` and paid rows in `pricing_get` without `tierMinimumUnits`: use `missing_tier_metadata` fallback to retrieve the 5 GB/month free band and paid band boundaries; retention beyond the included period is the separate `Analytics Logs Data Retention` meter | +| Azure Monitor alert rules | `serviceName` `Azure Monitor`, region, `skuName` `Alerts` | Resource-scoped metric alert rules bill on `Alerts Resource Monitored` (`unitOfMeasure: "1/Month"`) matched to the caller's `evaluation_frequency` (`1m` → `1 Minute Frequency`, `5m` → `5 Minute Frequency`, `10m` → `10 Minute Frequency`, `15m` → `15 Minute Frequency`) times monitored-resource count; log or custom time-series alert rules require their own explicit meters | +| SQL DTU tiers | `serviceName` `SQL Database`, region, `productName` | `SQL Database Single Standard`; `meterName` `S2 DTUs`; per-day unit; add `SQL Database Standard - Storage` `Data Stored` | +| SQL vCore GP | same | `Single/Elastic Pool General Purpose - Compute Gen5`; `armSkuName` `SQLDB_GP_Compute_Gen5_`, `meterName` `vCore` | +| NAT Gateway | `serviceName` `NAT Gateway`, billing region `Global` | `Standard Gateway` (hourly) and `Standard Data Processed` (per GB) | +| Standard Load Balancer | `serviceName` `Load Balancer`, billing region `Global` | rule hourly meter and `Standard Data Processed`; a `$0` Free meter does not establish an allowance, so the caller states the rule-count assumption | +| Standard static public IPv4 | `serviceName` `Virtual Network`, deployment region | Standard IPv4 Static Public IP, 1 Hour | + +Deployment tier names such as `B2`, `P1v3` or `50 DTUs` are not catalog `meterName` values; do not retry an +unchanged exact-name query that returned no rows. + ## Failure rule Retry one transient timeout once. After the permitted evidence fallback, a missing row, ambiguous meter, missing usage, diff --git a/.github/skills/apex-azure-defaults/references/research-workflow.md b/.github/skills/apex-azure-defaults/references/research-workflow.md index 220c65cd6..3a2a55d80 100644 --- a/.github/skills/apex-azure-defaults/references/research-workflow.md +++ b/.github/skills/apex-azure-defaults/references/research-workflow.md @@ -46,6 +46,21 @@ a fresh chat or `/clear` at the current agent before the next expensive phase; resume only from verified state. - Tool invocation or package-policy failure is not evidence of an Azure service capability limitation. Do not repeatedly attempt unavailable installers or infer availability from unrelated provider-wide region counts. +- Never pass more than one URL to a single page-fetch call in a research step. Fetch one page, check the size of the + result, then decide on the next. + +## SLA Figures + +Azure SLA percentages come from the Microsoft Online Services SLA document, not from service pages. + +- Never fetch `azure.microsoft.com/support/legal/sla/*` pages. They redirect to a licensing index and return a very + large, repeated page that stays in the chat. +- Run `npm run check:azure-sla -- ""` to print one bounded section of the current SLA document (downloaded + once and cached). Use `npm run check:azure-sla -- --list ` to find the exact service heading name. +- The first service-credit threshold in a tier's table is its SLA commitment. Record the figure, the document + month and the source URL in the research checkpoint. +- If the script cannot download the document, report the gap and lower Reliability confidence; do not substitute + remembered percentages. ## WAF Service Guides diff --git a/.github/skills/apex-azure-defaults/references/terraform-conventions.md b/.github/skills/apex-azure-defaults/references/terraform-conventions.md index 6b2a0504a..174dc1638 100644 --- a/.github/skills/apex-azure-defaults/references/terraform-conventions.md +++ b/.github/skills/apex-azure-defaults/references/terraform-conventions.md @@ -9,11 +9,14 @@ Use one source for each concern: | Need | Source | | --- | --- | | Azure Terraform guidance | Azure MCP documentation and best-practice tools | -| Provider/module search and versions | Public Terraform Registry API | +| Provider docs and AVM module READMEs | Azure MCP `azureterraform` (`azurerm get`, `azapi get`, `avm get`) | +| Module versions | Azure MCP `azureterraform avm versions`, or the public Terraform Registry API | +| Provider versions and search | Public Terraform Registry API | | Initialized provider resource schemas | `terraform providers schema -json` | | Existing Azure resource IDs | Azure MCP resource tools or Azure CLI | -Resolve an AVM-TF module version directly from the Registry API: +Resolve an AVM-TF module version with the Azure MCP `azureterraform avm versions` command, or directly from +the Registry API: ```bash curl -sf https://registry.terraform.io/v1/modules/Azure/avm-res-{path}/azurerm/versions \ @@ -21,13 +24,11 @@ curl -sf https://registry.terraform.io/v1/modules/Azure/avm-res-{path}/azurerm/v | sort_by(split(".") | map(tonumber)) | last' ``` -The API does not return versions newest-first; always sort as above. - -Use `https://registry.terraform.io/v1/providers/{namespace}/{provider}/versions` +The API does not return versions newest-first; always sort as above (apply the same stable-semver +filter and sort to `avm versions` output). Use `https://registry.terraform.io/v1/providers/{namespace}/{provider}/versions` for provider versions with the same sort (`hashicorp/azurerm`, `hashicorp/random`, `Azure/azapi` — azapi is not under `hashicorp`). Run `terraform init` before -`terraform providers schema -json`. Do not claim Azure MCP exposes Terraform -Registry metadata. +`terraform providers schema -json`. Azure MCP does not list provider versions; use the Registry API for those. ## Tag Syntax (HCL) diff --git a/.github/skills/apex-azure-defaults/references/workflow-gates.md b/.github/skills/apex-azure-defaults/references/workflow-gates.md index 157f14198..ea63534ec 100644 --- a/.github/skills/apex-azure-defaults/references/workflow-gates.md +++ b/.github/skills/apex-azure-defaults/references/workflow-gates.md @@ -71,6 +71,16 @@ Record via `apex-recall decide --key budget_decision --value " - command: "diagnose" + command: "applens_resource_diagnose" parameters: - resourceId: "" + resource: "" + question: "" + resource-group: "" Provides: - Automated issue detection @@ -134,9 +136,11 @@ For querying logs and metrics: ``` mcp_azure-mcp_monitor intent: "query logs for " - command: "logs_query" + command: "monitor_workspace_log_query" parameters: - workspaceId: "" + subscription: "" + workspace: "" + table: "" query: "" ``` @@ -151,7 +155,7 @@ See [kql-queries.md](references/kql-queries.md) for common diagnostic queries. ``` mcp_azure-mcp_resourcehealth intent: "check health status of " - command: "get" + command: "resourcehealth_availability-status_get" parameters: resourceId: "" ``` diff --git a/.github/skills/apex-azure-diagnostics/references/aks/general-diagnostics.md b/.github/skills/apex-azure-diagnostics/references/aks/general-diagnostics.md index 563814ab5..8f0d310f9 100644 --- a/.github/skills/apex-azure-diagnostics/references/aks/general-diagnostics.md +++ b/.github/skills/apex-azure-diagnostics/references/aks/general-diagnostics.md @@ -45,12 +45,15 @@ For AI-powered diagnostics: ```text mcp_azure-mcp_applens intent: "diagnose AKS cluster issues" - command: "diagnose" + command: "applens_resource_diagnose" parameters: - resourceId: "/subscriptions//resourceGroups//providers/Microsoft.ContainerService/managedClusters/" + resource: "" + question: "diagnose AKS cluster issues" + resource-group: "" + resource-type: "Microsoft.ContainerService/managedClusters" ``` -> 💡 **Tip:** AppLens automatically detects common issues and provides remediation recommendations using the cluster resource ID. +> 💡 **Tip:** AppLens automatically detects common issues and provides remediation recommendations for the named cluster. --- diff --git a/.github/skills/apex-azure-diagnostics/references/app-service/README.md b/.github/skills/apex-azure-diagnostics/references/app-service/README.md index 05a9349a4..b0f91f6cc 100644 --- a/.github/skills/apex-azure-diagnostics/references/app-service/README.md +++ b/.github/skills/apex-azure-diagnostics/references/app-service/README.md @@ -166,9 +166,9 @@ az webapp config ssl show --certificate-name CERT -g RG | `Azure CLI` | `az webapp deployment slot list -n APP -g RG` | Compare slot configurations | | `mcp_azure-mcp_appservice` | `appservice_webapp_diagnostic_diagnose` | AI-powered root cause analysis | | `mcp_azure-mcp_monitor` | `monitor_resource_log_query` | Run KQL against Log Analytics | -| `mcp_azure-mcp_resourcehealth` | `get` | Check platform-level health status | +| `mcp_azure-mcp_resourcehealth` | `resourcehealth_availability-status_get` | Check platform-level health status | -> 💡 **Tip:** Start with `mcp_azure-mcp_appservice` (`diagnose`) — it automatically runs relevant detectors and surfaces the most likely root cause before you dig into logs manually. +> 💡 **Tip:** Start with `mcp_azure-mcp_appservice` (`appservice_webapp_diagnostic_diagnose`; requires `app`, `detector-id` and `resource-group`) — it runs a chosen detector and surfaces the most likely root cause before you dig into logs manually. --- diff --git a/.github/skills/apex-azure-governance-discovery/SKILL.md b/.github/skills/apex-azure-governance-discovery/SKILL.md index e685500d7..eea62f5a0 100644 --- a/.github/skills/apex-azure-governance-discovery/SKILL.md +++ b/.github/skills/apex-azure-governance-discovery/SKILL.md @@ -38,6 +38,7 @@ pulling raw Azure REST responses into LLM context. - **Defender filtering is narrow** — the collector retains enforcement-bearing assignments; `--include-defender-auto` retains all - **Exit codes are contract** — `0` = COMPLETE, `1` = PARTIAL, `2` = FAILED; argparse errors also return nonzero. COMPLETE is collection status, not planning approval - **No artifact writing** — the script emits JSON + a `.preview.md`; the agent owns the final `04-governance-constraints.md` content and traffic-light rendering +- **Never hand-edit the envelope** — a re-run overwrites additions and any edit breaks the recorded signature; put additions in owner-controlled artifacts - **Reuse only current scoped evidence** — project, subscription, options, schema, COMPLETE status, valid TTL and exemptions must match - **Resolve confirmations before review** — all topics require current evidence-bound answers; unknowns block. See [inline resolution](references/inline-resolution-gate.md) diff --git a/.github/skills/apex-azure-governance-discovery/references/baseline-check.md b/.github/skills/apex-azure-governance-discovery/references/baseline-check.md index 17d769d12..7ed7966a2 100644 --- a/.github/skills/apex-azure-governance-discovery/references/baseline-check.md +++ b/.github/skills/apex-azure-governance-discovery/references/baseline-check.md @@ -20,6 +20,8 @@ same subscription and for offline / sandboxed environments. ## Procedure 1. Check whether `.github/data/governance-policy-baseline.json.gz` exists. + If `--get baseline_kind` prints `alz-corp-default`, follow + [ALZ Corp default baseline](#alz-corp-default-baseline-assumed-governance) instead of steps 2-5. 2. If it exists, read the target subscription ID from the project's `02-architecture-assessment.md` or session state. 3. **All** eligibility conditions must be true: @@ -61,3 +63,37 @@ same subscription and for offline / sandboxed environments. 6. If the baseline file is missing, eligibility fails, or the user chooses live discovery, proceed to Phase 0.5 (Cache-First Check). +## ALZ Corp default baseline (assumed governance) + +The repository ships an offline-generated baseline (`baseline_kind: alz-corp-default`): the default policy a +subscription inherits under the Azure landing zone Corp management group (`root`, `landing_zones` and `corp` +archetypes, pinned in `alz_library_ref`). It holds one synthetic subscription entry, so no target subscription +ID ever matches. It is **assumed governance**, never discovered policy, and adopters replace it by running the +tenant baseline workflow. + +1. Read the file's `baseline_kind` and `alz_library_ref`: + + ```bash + python3 .github/skills/apex-azure-governance-discovery/scripts/governance_baseline.py \ + .github/data/governance-policy-baseline.json.gz --get baseline_kind + ``` + +2. When the target subscription is not a key in `subscriptions`, use `askQuestions` with the options + **Run live discovery** (recommended when Azure access exists) and **Use ALZ Corp default as assumed + governance**. Never choose the default on the user's behalf. +3. If the user picks live discovery, proceed to Phase 0.5. +4. If the user picks the default, extract and render it: + + ```bash + python3 .github/skills/apex-azure-governance-discovery/scripts/governance_baseline.py \ + .github/data/governance-policy-baseline.json.gz --alz-default > /tmp/{project}-baseline-sub.json + ``` + + Then run `render_cached_governance.py` as in step 5 above, adding `--subscription `. + The renderer refuses the default without it and writes the target ID, not the synthetic baseline ID, into the + artifact (the synthetic ID stays in `provenance.baseline_subscription_id`). The preview carries an **Assumed + governance** banner; keep it in `04-governance-constraints.md`. +5. Record the choice with `apex-recall decide --decision "Governance assumed from the ALZ Corp + default baseline, not tenant-verified" --rationale "" --json`. +6. The Step 3.5 governance-reconciliation review stays mandatory; do not bypass it for assumed governance. + Treat any tag contract as the greenfield fallback, because the ALZ Corp set has no tag policy. diff --git a/.github/skills/apex-azure-governance-discovery/scripts/discover.py b/.github/skills/apex-azure-governance-discovery/scripts/discover.py index c69fd2fc9..bbe760a33 100644 --- a/.github/skills/apex-azure-governance-discovery/scripts/discover.py +++ b/.github/skills/apex-azure-governance-discovery/scripts/discover.py @@ -234,12 +234,26 @@ def _fetch(url: str, expected_id: str) -> dict[str, Any] | None: return list(pool.map(_fetch, urls, expected_ids)) +# Built-in diagnostic-settings initiatives gate each member's effect on a resource-type list parameter. +_CONDITIONAL_EFFECT = re.compile( + r"\[if\(contains\(parameters\('([^']+)'\),\s*'([^']+)'\),\s*parameters\('([^']+)'\),\s*'([^']+)'\)\]", + re.IGNORECASE, +) + + def _resolve_parameters(value: Any, parameters: dict[str, Any]) -> Any: if isinstance(value, str): + normalized = {key.lower(): item for key, item in parameters.items()} match = re.fullmatch(r"\[parameters\('([^']+)'\)\]", value.strip(), re.IGNORECASE) if match: - normalized = {key.lower(): item for key, item in parameters.items()} return normalized.get(match.group(1).lower(), value) + conditional = _CONDITIONAL_EFFECT.fullmatch(value.strip()) + if conditional: + container = normalized.get(conditional.group(1).lower()) + chosen = normalized.get(conditional.group(3).lower()) + if isinstance(container, list) and chosen is not None: + listed = {str(item).lower() for item in container} + return chosen if conditional.group(2).lower() in listed else conditional.group(4) if isinstance(value, dict): return {key: _resolve_parameters(item, parameters) for key, item in value.items()} if isinstance(value, list): @@ -878,7 +892,7 @@ def discover( audit_count += 1 continue if eff not in RELEVANT_EFFECTS: - if eff != "Append": + if eff not in {"Append", "enforceSetting"}: unresolved.append(original_defn.get("id") or pid) continue diff --git a/.github/skills/apex-azure-governance-discovery/scripts/generate_alz_baseline.py b/.github/skills/apex-azure-governance-discovery/scripts/generate_alz_baseline.py new file mode 100644 index 000000000..f9ff60bee --- /dev/null +++ b/.github/skills/apex-azure-governance-discovery/scripts/generate_alz_baseline.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +"""Generate the ALZ Corp default governance baseline without calling Azure. + +Resolves the `root`, `landing_zones` and `corp` archetypes of the pinned Azure Landing Zones +library to policy assignments, resolves their definitions (custom from the library, built-in from +the public azure-policy repo) and runs them through ``discover.discover`` with an offline REST +adapter, so classification matches a live run exactly. +""" + +from __future__ import annotations + +import argparse +import gzip +import io +import json +import re +import sys +import tarfile +import tempfile +import urllib.request +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +import discover as discover_mod + +LIBRARY_REF = "platform/alz/2026.10.0" +AZURE_POLICY_REF = "184a951ee59050e19d6e9b5db894383528cb86f3" +LIBRARY_URL = f"https://codeload.github.com/Azure/Azure-Landing-Zones-Library/tar.gz/refs/tags/{LIBRARY_REF}" +AZURE_POLICY_URL = f"https://codeload.github.com/Azure/azure-policy/tar.gz/{AZURE_POLICY_REF}" + +BASELINE_KIND = "alz-corp-default" +# Synthetic id: the baseline describes a policy set, not a real subscription. +SYNTHETIC_SUBSCRIPTION_ID = "00000000-0000-0000-0000-00000000a1c0" +DEFAULT_LOCATION = "swedencentral" +DEFAULT_OUT = Path(".github/data/governance-policy-baseline.json.gz") + +# Archetypes a subscription under the Corp management group inherits, outermost first. +ARCHETYPES: tuple[tuple[str, str], ...] = ( + ("root", "alz"), + ("landing_zones", "alz-landingzones"), + ("corp", "alz-corp"), +) +ROOT_MG = "alz" +MG_PREFIX = "/providers/Microsoft.Management/managementGroups" +PLACEHOLDER_MG = re.compile(r"/providers/Microsoft\.Management/managementGroups/[^/\"]+") + + +def _fetch(url: str) -> bytes: + if not url.startswith("https://"): + raise ValueError(f"refusing non-https source: {url}") + with urllib.request.urlopen(url, timeout=120) as response: # noqa: S310 — https constant above + return response.read() + + +def _extract(url: str, dest: Path, wanted: tuple[str, ...]) -> Path: + """Extract only members whose path contains one of ``wanted`` and return the archive root.""" + with tarfile.open(fileobj=io.BytesIO(_fetch(url)), mode="r:gz") as tar: + members = [m for m in tar.getmembers() if any(w in m.name for w in wanted)] + tar.extractall(dest, members=members, filter="data") # noqa: S202 — filter="data" blocks traversal + roots = [p for p in dest.iterdir() if p.is_dir()] + if len(roots) != 1: + raise RuntimeError(f"unexpected archive layout under {dest}") + return roots[0] + + +def _read_json(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def _localize(value: Any) -> Any: + """Point every library management group (placeholder, contoso, ...) at the synthetic root.""" + text = json.dumps(value) + text = PLACEHOLDER_MG.sub(f"{MG_PREFIX}/{ROOT_MG}", text) + text = text.replace("${current_scope_resource_id}", f"{MG_PREFIX}/{ROOT_MG}") + text = text.replace("${default_location}", DEFAULT_LOCATION) + return json.loads(text) + + +def _name_index(directory: Path) -> dict[str, dict[str, Any]]: + """Index library definition files by their `name`, keeping the highest file name on duplicates.""" + index: dict[str, tuple[str, dict[str, Any]]] = {} + for path in sorted(directory.glob("*.json")): + doc = _read_json(path) + name = str(doc.get("name", "")).lower() + if name: + index[name] = (path.name, doc) + return {name: doc for name, (_, doc) in index.items()} + + +def _builtin_index(builtins_dir: Path, kind: str) -> dict[str, dict[str, Any]]: + index: dict[str, dict[str, Any]] = {} + for path in (builtins_dir / kind).rglob("*.json"): + try: + doc = _read_json(path) + except (json.JSONDecodeError, UnicodeDecodeError): + continue + name = doc.get("name") if isinstance(doc, dict) else None + if isinstance(name, str) and isinstance(doc.get("properties"), dict): + index[name.lower()] = doc + return index + + +class OfflinePolicyApi: + """Serves the ARM reads that ``discover.discover`` issues from local library and built-in JSON.""" + + def __init__(self, assignments: list[dict[str, Any]], documents: dict[str, dict[str, Any]]) -> None: + self.assignments = assignments + self.documents = documents + self.missing: set[str] = set() + + def __call__(self, url: str) -> dict[str, Any]: + path = url.removeprefix(discover_mod.ARM).split("?", 1)[0] + lowered = path.lower() + if lowered.endswith("/policyassignments"): + return {"value": self.assignments} + if lowered.endswith(("/policydefinitions", "/policysetdefinitions", "/policyexemptions")): + return {"value": []} + document = self.documents.get(lowered) + if document is None: + self.missing.add(path) + return {} + return {**document, "id": path} + + +def build_policy_api(library_root: Path, builtins_dir: Path) -> tuple[OfflinePolicyApi, dict[str, list[str]]]: + alz = library_root / "platform" / "alz" + custom_defs = _name_index(alz / "policy_definitions") + custom_sets = _name_index(alz / "policy_set_definitions") + builtin_defs = _builtin_index(builtins_dir, "policyDefinitions") + builtin_sets = _builtin_index(builtins_dir, "policySetDefinitions") + assignment_files = { + str(_read_json(p)["name"]): _read_json(p) for p in sorted((alz / "policy_assignments").glob("*.json")) + } + + documents: dict[str, dict[str, Any]] = {} + custom_base = f"{MG_PREFIX}/{ROOT_MG}/providers/Microsoft.Authorization".lower() + for name, doc in custom_defs.items(): + documents[f"{custom_base}/policydefinitions/{name}"] = _localize(doc) + for name, doc in custom_sets.items(): + documents[f"{custom_base}/policysetdefinitions/{name}"] = _localize(doc) + for name, doc in builtin_defs.items(): + documents.setdefault(f"/providers/microsoft.authorization/policydefinitions/{name}", doc) + for name, doc in builtin_sets.items(): + documents.setdefault(f"/providers/microsoft.authorization/policysetdefinitions/{name}", doc) + + assignments: list[dict[str, Any]] = [] + included: dict[str, list[str]] = {} + for archetype, mg_name in ARCHETYPES: + archetype_doc = _read_json(alz / "archetype_definitions" / f"{archetype}.alz_archetype_definition.json") + scope = f"{MG_PREFIX}/{mg_name}" + included[archetype] = list(archetype_doc["policy_assignments"]) + for name in archetype_doc["policy_assignments"]: + source = _localize(assignment_files[name]) + props = {**source["properties"], "scope": scope} + assignments.append( + { + "id": f"{scope}/providers/Microsoft.Authorization/policyAssignments/{name}", + "name": name, + "properties": props, + } + ) + return OfflinePolicyApi(assignments, documents), included + + +def build_baseline( + library_root: Path, + builtins_dir: Path, + generated_at: str, + library_ref: str = LIBRARY_REF, + azure_policy_ref: str = AZURE_POLICY_REF, +) -> dict[str, Any]: + api, included = build_policy_api(library_root, builtins_dir) + envelope = discover_mod.discover( + SYNTHETIC_SUBSCRIPTION_ID, project=BASELINE_KIND, az_rest=api + ) + if api.missing: + raise RuntimeError("unresolved policy documents: " + ", ".join(sorted(api.missing))) + if envelope.get("discovery_status") != "COMPLETE": + raise RuntimeError(f"discovery incomplete: {envelope.get('discovery_status')}") + + envelope.pop("project", None) + envelope["source"] = BASELINE_KIND + envelope["subscription_id"] = SYNTHETIC_SUBSCRIPTION_ID + envelope["discovered_at"] = generated_at + metadata = envelope.get("discovery_metadata") + if isinstance(metadata, dict): + metadata["discovered_at"] = generated_at + scope = metadata.get("scope") + if isinstance(scope, dict): + scope["subscription_id"] = SYNTHETIC_SUBSCRIPTION_ID + envelope["provenance"] = { + "kind": BASELINE_KIND, + "alz_library_ref": library_ref, + "azure_policy_ref": azure_policy_ref, + "archetypes": included, + "verified_against_tenant": False, + } + + summary = envelope.get("discovery_summary", {}) + return { + "schema_version": "governance-baseline-v1", + "baseline_kind": BASELINE_KIND, + "alz_library_ref": library_ref, + "azure_policy_ref": azure_policy_ref, + "generated_at": generated_at, + "management_group_id": ARCHETYPES[-1][1], + "coverage_status": "COMPLETE", + "subscriptions_discovered": 1, + "subscriptions_processed": 1, + "subscriptions_skipped": [], + "subscriptions_excluded": [], + "summary": { + "total_findings": len(envelope.get("findings", [])), + "total_blockers": summary.get("blocker_count", 0), + "total_auto_remediate": summary.get("auto_remediate_count", 0), + "subscriptions_complete": 1, + }, + "subscriptions": {SYNTHETIC_SUBSCRIPTION_ID: envelope}, + } + + +def write_gzip_json(path: Path, document: dict[str, Any]) -> None: + """Write deterministic gzip (fixed mtime, sorted keys) so regeneration only diffs on content.""" + payload = json.dumps(document, indent=2, sort_keys=True, ensure_ascii=False).encode("utf-8") + b"\n" + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("wb") as raw, gzip.GzipFile(fileobj=raw, mode="wb", mtime=0, filename="") as gz: + gz.write(payload) + + +def _preserve_timestamp(out: Path, document: dict[str, Any]) -> None: + """Keep the prior generated_at when content is unchanged, to avoid no-op churn.""" + if not out.exists(): + return + try: + prior = json.loads(gzip.decompress(out.read_bytes())) + except (OSError, ValueError): + return + prior_time = prior.get("generated_at") + if not isinstance(prior_time, str): + return + entry = document["subscriptions"][SYNTHETIC_SUBSCRIPTION_ID] + current_time = document["generated_at"] + + def normalise(doc: dict[str, Any], stamp: str) -> str: + return json.dumps(doc, sort_keys=True).replace(stamp, "") + + if normalise(prior, prior_time) == normalise(document, current_time): + document["generated_at"] = prior_time + entry["discovered_at"] = prior_time + if isinstance(entry.get("discovery_metadata"), dict): + entry["discovery_metadata"]["discovered_at"] = prior_time + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Generate the ALZ Corp default governance baseline.") + parser.add_argument("--out", type=Path, default=DEFAULT_OUT) + parser.add_argument("--library-dir", type=Path, help="Pre-extracted Azure-Landing-Zones-Library archive root.") + parser.add_argument("--builtins-dir", type=Path, help="Pre-extracted azure-policy built-in-policies directory.") + parser.add_argument("--library-ref", help="ALZ library tag the --library-dir tree was taken from (required with it).") + parser.add_argument( + "--azure-policy-ref", help="azure-policy commit the --builtins-dir tree was taken from (required with it)." + ) + args = parser.parse_args(argv) + # A local tree is not the pinned release, so its provenance must be stated rather than assumed. + for tree, ref, tree_flag, ref_flag in ( + (args.library_dir, args.library_ref, "--library-dir", "--library-ref"), + (args.builtins_dir, args.azure_policy_ref, "--builtins-dir", "--azure-policy-ref"), + ): + if tree and not ref: + parser.error(f"{tree_flag} requires {ref_flag}") + if ref and not tree: + parser.error(f"{ref_flag} is only valid with {tree_flag}") + library_ref = args.library_ref or LIBRARY_REF + azure_policy_ref = args.azure_policy_ref or AZURE_POLICY_REF + + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + if args.library_dir: + library_root = args.library_dir + else: + library_root = _extract(LIBRARY_URL, tmp_path / "lib", ("/platform/alz/",)) + if args.builtins_dir: + builtins_dir = args.builtins_dir + else: + builtins_dir = _extract( + AZURE_POLICY_URL, tmp_path / "builtin", ("/built-in-policies/policyDefinitions/", "/built-in-policies/policySetDefinitions/") + ) / "built-in-policies" + generated_at = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + try: + document = build_baseline(library_root, builtins_dir, generated_at, library_ref, azure_policy_ref) + except (RuntimeError, KeyError, OSError, ValueError) as error: + print(f"generate_alz_baseline: {error}", file=sys.stderr) + return 1 + _preserve_timestamp(args.out, document) + write_gzip_json(args.out, document) + summary = document["summary"] + print( + f"wrote {args.out}: {summary['total_findings']} findings " + f"({summary['total_blockers']} blockers, {summary['total_auto_remediate']} auto-remediate)" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/skills/apex-azure-governance-discovery/scripts/governance_baseline.py b/.github/skills/apex-azure-governance-discovery/scripts/governance_baseline.py index d39725c3f..a611d79a9 100644 --- a/.github/skills/apex-azure-governance-discovery/scripts/governance_baseline.py +++ b/.github/skills/apex-azure-governance-discovery/scripts/governance_baseline.py @@ -33,12 +33,23 @@ def resolve_path(value: Any, dotted_path: str) -> Any: return current +def default_subscription(baseline: dict[str, Any]) -> tuple[str, dict[str, Any]]: + """Return the single synthetic envelope of an alz-corp-default baseline.""" + if baseline.get("baseline_kind") != "alz-corp-default": + raise KeyError("baseline is not an alz-corp-default baseline") + subscriptions = baseline.get("subscriptions") + if not isinstance(subscriptions, dict) or len(subscriptions) != 1: + raise ValueError("alz-corp-default baseline must hold exactly one subscription entry") + return next(iter(subscriptions.items())) + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="Read a JSON or JSON.gz governance baseline") parser.add_argument("baseline", help="Path to governance baseline") group = parser.add_mutually_exclusive_group() group.add_argument("--get", dest="field", help="Print one dotted field") group.add_argument("--subscription", help="Print one subscriptions. envelope") + group.add_argument("--alz-default", action="store_true", help="Print the alz-corp-default envelope") parser.add_argument("--compact", action="store_true", help="Emit compact JSON") args = parser.parse_args(argv) @@ -48,6 +59,8 @@ def main(argv: list[str] | None = None) -> int: value = resolve_path(baseline, args.field) elif args.subscription: value = baseline["subscriptions"][args.subscription] + elif args.alz_default: + value = default_subscription(baseline)[1] else: value = baseline except (OSError, gzip.BadGzipFile, UnicodeDecodeError, json.JSONDecodeError, KeyError, ValueError) as exc: diff --git a/.github/skills/apex-azure-governance-discovery/scripts/render_cached_governance.py b/.github/skills/apex-azure-governance-discovery/scripts/render_cached_governance.py index e0fa9b0fc..017daf4bf 100644 --- a/.github/skills/apex-azure-governance-discovery/scripts/render_cached_governance.py +++ b/.github/skills/apex-azure-governance-discovery/scripts/render_cached_governance.py @@ -11,6 +11,7 @@ import argparse import json +import re import sys from datetime import UTC, datetime from pathlib import Path @@ -82,6 +83,11 @@ def main(argv: list[str] | None = None) -> int: ) parser.add_argument("--in", dest="input", required=True, help="Path to governance-constraints-v1 JSON envelope.") parser.add_argument("--out", required=True, help="Path to project-local 04-governance-constraints.json.") + parser.add_argument( + "--subscription", + default=None, + help="Target subscription ID; required for the alz-corp-default baseline, ignored otherwise.", + ) parser.add_argument( "--arch", default=None, help="Path to 02-architecture-assessment.md for policy→resource mapping." ) @@ -107,6 +113,29 @@ def main(argv: list[str] | None = None) -> int: sys.stdout.write(json.dumps(status, separators=(",", ":")) + "\n") return 2 + if envelope.get("source") == "alz-corp-default": + target = args.subscription or "" + if not re.fullmatch(r"[0-9a-fA-F-]{36}", target): + status = { + "status": "FAILED", + "error": "subscription-required", + "detail": "The ALZ Corp default is rendered for a target; pass --subscription ", + } + sys.stdout.write(json.dumps(status, separators=(",", ":")) + "\n") + return 2 + # The baseline's synthetic id must not leak into the project artifact; keep it only as provenance. + provenance = envelope.setdefault("provenance", {}) + provenance["baseline_subscription_id"] = envelope.get("subscription_id") + envelope["subscription_id"] = target + scope = (envelope.get("discovery_metadata") or {}).get("scope") + if isinstance(scope, dict): + scope["subscription_id"] = target + # Static policy set, evaluated now; the generation date says nothing about tenant freshness. + rendered_at = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + envelope["discovered_at"] = rendered_at + if isinstance(envelope.get("discovery_metadata"), dict): + envelope["discovery_metadata"]["discovered_at"] = rendered_at + try: metadata = envelope.get("discovery_metadata") timestamp = metadata.get("discovered_at") if isinstance(metadata, dict) else envelope.get("discovered_at") diff --git a/.github/skills/apex-azure-governance-discovery/scripts/render_governance.py b/.github/skills/apex-azure-governance-discovery/scripts/render_governance.py index be9af257c..34fc57938 100644 --- a/.github/skills/apex-azure-governance-discovery/scripts/render_governance.py +++ b/.github/skills/apex-azure-governance-discovery/scripts/render_governance.py @@ -351,10 +351,20 @@ def emit_preview_md(envelope: dict[str, Any], out_path: Path, arch_resources: li a(f"| Tag Policies | {len(tags_required)} tags required | {discovered_at} |") a(f"| Security Policies | {len(security)} constraints | {discovered_at} |\n") is_cached = envelope.get("source") == "cached_baseline" or envelope.get("cached_baseline", False) + is_assumed = envelope.get("source") == "alz-corp-default" discovery_method = "Cached governance baseline (governance-policy-baseline.json.gz)" if is_cached else "Azure Policy REST API (discover.py)" + if is_assumed: + discovery_method = "ALZ Corp default policy set (assumed governance, not verified against the tenant)" a(f"**Discovery Method**: {discovery_method}") a(f"**Subscription**: {envelope.get('subscription_id', 'unknown')}") a(f"**Scope**: Subscription + management-group inherited\n") + if is_assumed: + provenance = envelope.get("provenance") or {} + a( + "> ⚠️ **Assumed governance.** These are the default Azure landing zone policies for a subscription under " + f"the Corp management group (library `{provenance.get('alz_library_ref', 'unknown')}`). They were not " + "read from this tenant; run live discovery to verify before deployment.\n" + ) if blockers: a(f"> ⚠️ **{len(blockers)} deployment blocker(s)** detected. Review the [Deployment Blockers](#-deployment-blockers) section before proceeding to IaC planning.\n") @@ -640,6 +650,8 @@ def emit_preview_md(envelope: dict[str, Any], out_path: Path, arch_resources: li a("| Tag Governance | [Tagging Strategy](https://learn.microsoft.com/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging) |\n") a("---\n") footer_source = "cached governance baseline" if is_cached else "Azure Policy REST API via discover.py" + if is_assumed: + footer_source = "the ALZ Corp default policy set (assumed, not tenant-verified)" a(f"_Governance constraints discovered from {footer_source}._\n") preview_path.write_text("\n".join(lines) + "\n") diff --git a/.github/skills/apex-azure-governance-discovery/scripts/test_generate_alz_baseline.py b/.github/skills/apex-azure-governance-discovery/scripts/test_generate_alz_baseline.py new file mode 100644 index 000000000..c409dec01 --- /dev/null +++ b/.github/skills/apex-azure-governance-discovery/scripts/test_generate_alz_baseline.py @@ -0,0 +1,284 @@ +"""Tests for generate_alz_baseline.py using a tiny synthetic library and built-in tree (no network).""" + +from __future__ import annotations + +import gzip +import json +import sys +from pathlib import Path +from typing import Any + +import pytest + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE)) + +import discover # noqa: E402 +import generate_alz_baseline as gen # noqa: E402 +from governance_baseline import default_subscription, load_governance_json # noqa: E402 + +DENY_GUID = "11111111-1111-1111-1111-111111111111" +DINE_GUID = "22222222-2222-2222-2222-222222222222" +SET_GUID = "33333333-3333-3333-3333-333333333333" +TARGET_SUBSCRIPTION = "44444444-4444-4444-4444-444444444444" + + +def _write(path: Path, doc: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(doc), encoding="utf-8") + + +def _assignment(name: str, definition_id: str, **props: Any) -> dict[str, Any]: + return { + "name": name, + "properties": { + "displayName": name, + "policyDefinitionId": definition_id, + "enforcementMode": props.pop("enforcementMode", "Default"), + "parameters": props.pop("parameters", {}), + "scope": "/providers/Microsoft.Management/managementGroups/placeholder", + **props, + }, + } + + +@pytest.fixture +def sources(tmp_path: Path) -> tuple[Path, Path]: + library = tmp_path / "lib" + alz = library / "platform" / "alz" + builtins = tmp_path / "builtin" + + for archetype, assignments in { + "root": ["Enforce-Set"], + "landing_zones": ["Deny-Thing"], + "corp": ["Deploy-Thing", "Audit-Only"], + }.items(): + _write( + alz / "archetype_definitions" / f"{archetype}.alz_archetype_definition.json", + {"name": archetype, "policy_assignments": assignments}, + ) + + mg = "/providers/Microsoft.Management/managementGroups/placeholder/providers/Microsoft.Authorization" + builtin = "/providers/Microsoft.Authorization" + for assignment in ( + _assignment("Enforce-Set", f"{mg}/policySetDefinitions/Custom-Set", enforcementMode="DoNotEnforce"), + _assignment("Deny-Thing", f"{builtin}/policyDefinitions/{DENY_GUID}"), + _assignment("Deploy-Thing", f"{builtin}/policyDefinitions/{DINE_GUID}"), + _assignment("Audit-Only", f"{builtin}/policyDefinitions/{DENY_GUID}", parameters={"effect": {"value": "Audit"}}), + ): + _write(alz / "policy_assignments" / f"{assignment['name']}.alz_policy_assignment.json", assignment) + + _write( + alz / "policy_set_definitions" / "Custom-Set.alz_policy_set_definition.json", + { + "name": "Custom-Set", + "properties": { + "parameters": {"effect": {"type": "String", "defaultValue": "Deny"}}, + "policyDefinitions": [ + { + "policyDefinitionReferenceId": "member", + "policyDefinitionId": "${current_scope_resource_id}/providers/Microsoft.Authorization/policyDefinitions/Custom-Def", + "parameters": {"effect": {"value": "[parameters('effect')]"}}, + } + ], + }, + }, + ) + _write( + alz / "policy_definitions" / "Custom-Def.alz_policy_definition.json", + { + "name": "Custom-Def", + "properties": { + "displayName": "Custom definition", + "parameters": {"effect": {"type": "String", "defaultValue": "Deny"}}, + "policyRule": { + "if": {"field": "type", "equals": "Microsoft.Storage/storageAccounts"}, + "then": {"effect": "[parameters('effect')]"}, + }, + }, + }, + ) + + for guid, effect in ((DENY_GUID, "Deny"), (DINE_GUID, "DeployIfNotExists")): + _write( + builtins / "policyDefinitions" / "Cat" / f"{guid}.json", + { + "name": guid, + "id": f"/providers/Microsoft.Authorization/policyDefinitions/{guid}", + "properties": { + "displayName": f"Built-in {effect}", + "parameters": {"effect": {"type": "String", "defaultValue": effect}}, + "policyRule": { + "if": {"field": "type", "equals": "Microsoft.Storage/storageAccounts"}, + "then": {"effect": "[parameters('effect')]"}, + }, + }, + }, + ) + (builtins / "policySetDefinitions").mkdir(parents=True) + return library, builtins + + +def test_baseline_resolves_corp_archetypes_and_marks_provenance(sources: tuple[Path, Path]) -> None: + library, builtins = sources + document = gen.build_baseline(library, builtins, "2026-10-13T00:00:00Z") + + assert document["baseline_kind"] == "alz-corp-default" + assert document["alz_library_ref"] == gen.LIBRARY_REF + entry = document["subscriptions"][gen.SYNTHETIC_SUBSCRIPTION_ID] + assert entry["source"] == "alz-corp-default" + assert entry["provenance"]["verified_against_tenant"] is False + assert entry["provenance"]["archetypes"] == { + "root": ["Enforce-Set"], + "landing_zones": ["Deny-Thing"], + "corp": ["Deploy-Thing", "Audit-Only"], + } + assert entry["discovery_status"] == "COMPLETE" + assert document["summary"]["total_findings"] == len(entry["findings"]) + + +def test_scopes_follow_the_archetype_and_enforcement_mode_is_kept(sources: tuple[Path, Path]) -> None: + library, builtins = sources + entry = gen.build_baseline(library, builtins, "2026-10-13T00:00:00Z")["subscriptions"][ + gen.SYNTHETIC_SUBSCRIPTION_ID + ] + scopes = {a["displayName"]: a["scope"].rsplit("/", 1)[-1] for a in entry["assignment_inventory"]} + assert scopes == { + "Enforce-Set": "alz", + "Deny-Thing": "alz-landingzones", + "Deploy-Thing": "alz-corp", + "Audit-Only": "alz-corp", + } + by_assignment = {f["assignment_display_name"]: f for f in entry["findings"]} + # DoNotEnforce is included but never a blocker. + assert by_assignment["Enforce-Set"]["enforcement_mode"] == "DoNotEnforce" + assert by_assignment["Enforce-Set"]["classification"] == "informational" + assert by_assignment["Deny-Thing"]["classification"] == "blocker" + assert by_assignment["Deploy-Thing"]["classification"] == "auto-remediate" + assert "Audit-Only" not in by_assignment + + +def test_unresolved_definition_fails_loudly(sources: tuple[Path, Path]) -> None: + library, builtins = sources + (builtins / "policyDefinitions" / "Cat" / f"{DENY_GUID}.json").unlink() + with pytest.raises(RuntimeError, match="unresolved"): + gen.build_baseline(library, builtins, "2026-10-13T00:00:00Z") + + +def test_gzip_output_is_deterministic_and_preserves_timestamp(tmp_path: Path, sources: tuple[Path, Path]) -> None: + library, builtins = sources + out = tmp_path / "baseline.json.gz" + first = gen.build_baseline(library, builtins, "2026-10-13T00:00:00Z") + gen._preserve_timestamp(out, first) + gen.write_gzip_json(out, first) + first_bytes = out.read_bytes() + + second = gen.build_baseline(library, builtins, "2026-10-20T00:00:00Z") + gen._preserve_timestamp(out, second) + gen.write_gzip_json(out, second) + + assert out.read_bytes() == first_bytes + assert load_governance_json(out)["generated_at"] == "2026-10-13T00:00:00Z" + + +def test_default_subscription_requires_the_alz_kind(sources: tuple[Path, Path]) -> None: + library, builtins = sources + document = gen.build_baseline(library, builtins, "2026-10-13T00:00:00Z") + sub_id, envelope = default_subscription(document) + assert sub_id == gen.SYNTHETIC_SUBSCRIPTION_ID + assert envelope["source"] == "alz-corp-default" + + document.pop("baseline_kind") + with pytest.raises(KeyError): + default_subscription(document) + + +def test_conditional_effect_follows_the_resource_type_list() -> None: + expression = "[if(contains(parameters('resourceTypeList'),'microsoft.sql/servers'),parameters('effect'),'Disabled')]" + listed = {"resourceTypeList": ["Microsoft.SQL/servers"], "effect": "DeployIfNotExists"} + unlisted = {"resourceTypeList": ["microsoft.web/sites"], "effect": "DeployIfNotExists"} + assert discover._resolve_parameters(expression, listed) == "DeployIfNotExists" + assert discover._resolve_parameters(expression, unlisted) == "Disabled" + # A missing list is not evaluated; the expression stays unresolved instead of guessing. + assert discover._resolve_parameters(expression, {"effect": "Deny"}) == expression + + +def test_enforce_setting_is_not_reported_as_unresolved(sources: tuple[Path, Path]) -> None: + library, builtins = sources + path = builtins / "policyDefinitions" / "Cat" / f"{DENY_GUID}.json" + doc = json.loads(path.read_text(encoding="utf-8")) + doc["properties"]["parameters"]["effect"]["defaultValue"] = "enforceSetting" + path.write_text(json.dumps(doc), encoding="utf-8") + + entry = gen.build_baseline(library, builtins, "2026-10-13T00:00:00Z")["subscriptions"][ + gen.SYNTHETIC_SUBSCRIPTION_ID + ] + assert entry["discovery_status"] == "COMPLETE" + assert all(f["assignment_display_name"] != "Deny-Thing" for f in entry["findings"]) + + +def test_gzip_roundtrip_is_plain_json(tmp_path: Path) -> None: + out = tmp_path / "x.json.gz" + gen.write_gzip_json(out, {"b": 1, "a": 2}) + assert json.loads(gzip.decompress(out.read_bytes())) == {"a": 2, "b": 1} + + +def test_cached_renderer_accepts_the_default_with_a_fresh_timestamp_and_assumed_banner( + tmp_path: Path, sources: tuple[Path, Path] +) -> None: + import render_cached_governance as renderer + + library, builtins = sources + document = gen.build_baseline(library, builtins, "2020-01-01T00:00:00Z") + _, envelope = default_subscription(document) + source = tmp_path / "sub.json" + source.write_text(json.dumps(envelope), encoding="utf-8") + out = tmp_path / "agent-output" / "demo" / "04-governance-constraints.json" + + assert renderer.main(["--in", str(source), "--out", str(out), "--subscription", TARGET_SUBSCRIPTION]) == 0 + + rendered = json.loads(out.read_text(encoding="utf-8")) + assert rendered["source"] == "alz-corp-default" + assert rendered["discovered_at"] > "2026-01-01" + assert rendered["subscription_id"] == TARGET_SUBSCRIPTION + assert rendered["discovery_metadata"]["scope"]["subscription_id"] == TARGET_SUBSCRIPTION + assert rendered["provenance"]["baseline_subscription_id"] == gen.SYNTHETIC_SUBSCRIPTION_ID + preview = (out.parent / "04-governance-constraints.preview.md").read_text(encoding="utf-8") + assert "Assumed governance" in preview + assert gen.SYNTHETIC_SUBSCRIPTION_ID not in preview + + +@pytest.mark.parametrize("extra", [[], ["--subscription", "not-a-guid"]]) +def test_cached_renderer_rejects_the_default_without_a_valid_target_subscription( + tmp_path: Path, sources: tuple[Path, Path], extra: list[str] +) -> None: + import render_cached_governance as renderer + + library, builtins = sources + _, envelope = default_subscription(gen.build_baseline(library, builtins, "2020-01-01T00:00:00Z")) + source = tmp_path / "sub.json" + source.write_text(json.dumps(envelope), encoding="utf-8") + out = tmp_path / "agent-output" / "demo" / "04-governance-constraints.json" + + assert renderer.main(["--in", str(source), "--out", str(out), *extra]) == 2 + assert not out.exists() + + +def test_local_source_trees_require_explicit_refs(tmp_path: Path, sources: tuple[Path, Path]) -> None: + library, builtins = sources + out = tmp_path / "baseline.json.gz" + base = ["--out", str(out), "--library-dir", str(library), "--builtins-dir", str(builtins)] + + with pytest.raises(SystemExit): + gen.main(base) + with pytest.raises(SystemExit): + gen.main([*base, "--library-ref", "platform/alz/9.9.9"]) + assert not out.exists() + + assert gen.main([*base, "--library-ref", "platform/alz/9.9.9", "--azure-policy-ref", "abc123"]) == 0 + document = load_governance_json(out) + assert document["alz_library_ref"] == "platform/alz/9.9.9" + assert document["azure_policy_ref"] == "abc123" + provenance = document["subscriptions"][gen.SYNTHETIC_SUBSCRIPTION_ID]["provenance"] + assert provenance["alz_library_ref"] == "platform/alz/9.9.9" + assert provenance["azure_policy_ref"] == "abc123" diff --git a/.github/skills/apex-azure-reliability/SKILL.md b/.github/skills/apex-azure-reliability/SKILL.md index 922f80ad2..22f3373ae 100644 --- a/.github/skills/apex-azure-reliability/SKILL.md +++ b/.github/skills/apex-azure-reliability/SKILL.md @@ -82,6 +82,8 @@ storage, Health probes, Multi-region failover. Each row has one status and the r - List only relevant resources, one bullet each, with a short reason (`(FC1)`, `(defaulted; no SKU set)`). Credit resources that are already compliant with `— already ON`. - Drop a row only when no resource in scope could apply to it. No `n/a` cells. +- Assess and report every environment, including non-production. The production-only zone-redundancy design + rule in the security baseline governs what plans must require, not what this assessment flags. ```text Reliability Assessment — {scope} @@ -100,7 +102,7 @@ Multi-region failover 🔴 OFF • Single region (swedencentral) — | Critical | No zone redundancy AND production workload | Raise with the IaC owner now | | High | LRS storage on zone-redundant compute | Plan the change within days | | Medium | Single region but zone-redundant | Plan for the next change window | -| Low | Missing health probes or monitoring gaps | Track and fix | +| Low | No zone redundancy in a non-production workload (report only), missing health probes or monitoring gaps | Track and fix | ## Reliability Findings diff --git a/.github/skills/apex-azure-validate/references/policy-validation.md b/.github/skills/apex-azure-validate/references/policy-validation.md index 69e2a95c7..031a8710f 100644 --- a/.github/skills/apex-azure-validate/references/policy-validation.md +++ b/.github/skills/apex-azure-validate/references/policy-validation.md @@ -17,7 +17,7 @@ az account show --query id -o tsv Call the Azure MCP Policy tool to retrieve policies for your subscription: ``` -mcp_azure-mcp_policy(command: "list", parameters: { subscription_id: "" }) +mcp_azure-mcp_policy(command: "policy_assignment_list", parameters: { subscription: "" }) ``` Replace `` with the actual subscription ID obtained from step 1. diff --git a/.github/skills/apex-azure-validate/references/recipes/azd/README.md b/.github/skills/apex-azure-validate/references/recipes/azd/README.md index b99e11adf..f03533676 100644 --- a/.github/skills/apex-azure-validate/references/recipes/azd/README.md +++ b/.github/skills/apex-azure-validate/references/recipes/azd/README.md @@ -27,12 +27,14 @@ mcp_azure-mcp_extension_cli_install(cli-type: "azd") ### 2. Schema Validation -Validate azure.yaml against official schema: +Validate `azure.yaml` against the official schema: ``` mcp_azure-mcp_azd(command: "validate_azure_yaml", parameters: { path: "./azure.yaml" }) ``` +`azd provision --preview` also fails on an invalid manifest. + ### 3. Environment Setup Verify AZD environment exists and is configured. See [Environment Setup](environment.md) for detailed steps. diff --git a/.github/skills/apex-iac-common/references/avm-module-index.md b/.github/skills/apex-iac-common/references/avm-module-index.md index 0fca4ce1c..b78e9dd5a 100644 --- a/.github/skills/apex-iac-common/references/avm-module-index.md +++ b/.github/skills/apex-iac-common/references/avm-module-index.md @@ -20,7 +20,9 @@ indexes** maintained by the Azure Verified Modules team. | `tools/scripts/_data/avm-module-cache.json` | Per-module version cache | MCR + `registry.terraform.io`, pre-warmed by the same refresh script | The refresh script keeps all four files current; there is no need to -hand-edit them. In consumer repositories the **Weekly Maintenance** +hand-edit them. Agents may use Azure MCP `azureterraform` (`avm versions`, `avm get`) for live Terraform +version and README lookup, but this index stays the source for module existence, lifecycle status and +resource-type mapping, and the cache stays pre-warmed by the refresh script. In consumer repositories the **Weekly Maintenance** template (`refresh-avm-module-index` job) runs it and opens a refresh PR. That template does not run in APEX: APEX maintainers run `npm run refresh:avm-module-index` manually at least monthly and before diff --git a/.github/skills/apex-iac-common/references/avm-version-freeze-gate.md b/.github/skills/apex-iac-common/references/avm-version-freeze-gate.md index d20529aab..61885aef1 100644 --- a/.github/skills/apex-iac-common/references/avm-version-freeze-gate.md +++ b/.github/skills/apex-iac-common/references/avm-version-freeze-gate.md @@ -64,7 +64,8 @@ Re-resolve the latest stable version and update both the implementation plan and `04-iac-contract.json`: - Bicep: `curl -sf https://mcr.microsoft.com/v2/bicep/avm/res/{path}/tags/list` -- Terraform: `curl -sf https://registry.terraform.io/v1/modules/Azure/avm-res-{path}/azurerm/versions` +- Terraform: `curl -sf https://registry.terraform.io/v1/modules/Azure/avm-res-{path}/azurerm/versions`, + or Azure MCP `azureterraform avm versions --module-name avm-res-{path}` - Or use the corresponding MCP helpers. Re-run the validator until exit code 0. diff --git a/.github/skills/apex-iac-common/references/contract-emission-and-handoff.md b/.github/skills/apex-iac-common/references/contract-emission-and-handoff.md index f532e99dd..afa853251 100644 --- a/.github/skills/apex-iac-common/references/contract-emission-and-handoff.md +++ b/.github/skills/apex-iac-common/references/contract-emission-and-handoff.md @@ -53,6 +53,17 @@ Schema validation cannot prove evidence truth: the owning independent review ver contracts without this optional map retain schema validation but cannot claim readiness-mode coverage or silently migrate at read time. Preserve original reviewed bytes and use owner-approved successor inputs for migration. +An entry may declare `blocks` (`deploy`, `post-deploy`, `go-live`) to state which stages an unresolved capability stops; +absent means every stage. `validate:iac-contract -- --readiness --stage ` (default `deploy`) fails an +unresolved required capability only when its `blocks` includes that stage or an earlier one, and warns otherwise. +Use `go-live` for the check before users or operators depend on the platform. The validator also rejects a `phase` +or `deployment_phase` parameter that declares a default. + +For private paths, set `phase` on every resource, `consumes` on each resource that reaches another over a private path +(image pull, Key Vault reference, SQL, Blob) and `target` on each `Microsoft.Network/privateEndpoints` resource. The +validator then rejects a `depends_on` into a later phase and a consumer that does not `depends_on` a private endpoint +targeting what it consumes (any one endpoint suffices). Contracts without these fields are not checked. + Before freezing private Log Analytics/Application Insights settings, include the required query and ingestion paths in the plan and resource inventory. Disabled public access does not create an AMPLS, resource association, private endpoint, DNS configuration or client-network route. Establish ownership of existing shared monitoring connectivity @@ -150,6 +161,14 @@ Pass the plan's frozen inputs as `supporting_paths` on the Plan review so the fi `04-governance-constraints.md` and `04-governance-constraints.json` under `agent-output/{project}/`. Exclude `sku-manifest.json`; later steps mutate it. Finalize these inputs first: any later byte change makes the review stale. The subagent generates the hashes with `--metadata`. Never restamp an older review to add them. +`--supporting-input` rejects mutable parent-owned files (`sku-manifest.json`, `*-decisions.json`, `00-handoff.md`, +`README.md`, recall state); strict verification rejects them in existing reviews. + +After each plan edit during an unapproved draft, refresh `plan_ref.sha256` and `l1m_ref.sha256` with +`npm run sync:draft-contract-hashes -- --project --expected-sha --reason "" --write`. +It changes only the digest bytes, keeps the file's formatting, and refuses an approved plan. Without `--write` it prints +the corrected contract to stdout. `validate:iac-contract-consistency` also requires every resource without an AVM +module entry to have a row in the plan's `### Raw Resource Exceptions` section. The review brief includes these provider constraints, exact input/output paths and actual validator commands/results. Ask the first comprehensive reviewer to examine all coupled constraints and return all substantiated findings together, diff --git a/.github/skills/apex-iac-common/references/governance-drift-routing.md b/.github/skills/apex-iac-common/references/governance-drift-routing.md index 8e5d264ab..f97f3d404 100644 --- a/.github/skills/apex-iac-common/references/governance-drift-routing.md +++ b/.github/skills/apex-iac-common/references/governance-drift-routing.md @@ -73,6 +73,11 @@ list` returns child IDs; the constraints envelope's `findings[]` ## Common scenarios +- **Resource types added after Step 3.5** (for example a Step 2 reopen that + adds Bastion, a VM, a NAT Gateway or public IPs) → the approved + governance envelope never saw them. The Planner emits + `▶ Refresh Governance` before plan approval; do not approve a plan whose + resource types are absent from the current reconciliation. - **L0 envelope expired (>= `ttl_days`)** → consumer agent (Planner, CodeGen, Deploy) emits `▶ Refresh Governance` and stops. The Governance agent re-runs discovery and emits a fresh envelope; the diff --git a/.github/skills/apex-iac-common/references/plan-consistency-checks.md b/.github/skills/apex-iac-common/references/plan-consistency-checks.md index b278360a7..43aa7b778 100644 --- a/.github/skills/apex-iac-common/references/plan-consistency-checks.md +++ b/.github/skills/apex-iac-common/references/plan-consistency-checks.md @@ -17,7 +17,7 @@ Rule resolution protocol (Phase 2.5): | Rule ID | Trigger | Deterministic check | Safe default | Escalation | | --------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------ | ---------------------------------- | -| `zone_redundancy` | Any resource with a `zone_redundant`/`zoneRedundant` knob OR App Service Plan P1v4+ in plan | Number of declared instances ≥ 2 AND chosen SKU supports zone-redundancy in target region | None (architectural decision) | Phase 3.5 → `az_posture` | +| `zone_redundancy` | Production environment only: any resource with a `zone_redundant`/`zoneRedundant` knob OR App Service Plan P1v4+ in plan | Number of declared instances ≥ 2 AND chosen SKU supports zone-redundancy in target region | None (architectural decision) | Phase 3.5 → `az_posture` | | `rbac_phase_ordering` | Phased deployment with role assignments | Every `Microsoft.Authorization/roleAssignments` (Bicep) or `azurerm_role_assignment` (TF) deploys in a phase ≥ phase of its principal identity AND its scope target | Defer role assignments to the latest phase that owns the principal | None (mechanical fix; auto-apply) | | `deployment_script` | Plan declares a `Microsoft.Resources/deploymentScripts` or AVM deployment-script wrapper | Identity (`userAssignedIdentities`) declared, network access either `subnetResourceIds` set OR public access justified, container image pinned to digest (not `:latest`) | Pin image to a known-good digest; require `userAssignedIdentities` | Phase 3.5 → `script_runtime_image` | | `public_edge_auth` | Any public-edge resource (Front Door, Application Gateway, APIM, Container Apps ingress, App Service) | Auth model declared in plan (`waf_policy_id` + `managed_identity` for FD/AppGW; `oauth_provider` for App Service) AND `client_cert_required` set true/false explicitly | None (architectural decision) | Phase 3.5 → `public_edge_auth` | @@ -28,9 +28,10 @@ Rule resolution protocol (Phase 2.5): ### `zone_redundancy` -- **What it catches**: a plan that promises HA/zone-redundancy but +- **What it catches**: a production plan that promises HA/zone-redundancy but declares a single instance, a non-zone-redundant SKU, or a region that - does not expose zones. + does not expose zones. Non-production plans are out of scope: single-zone + is the expected posture there and is never flagged. - **Why it matters**: deploys succeed; runtime fails the first time a zone goes down. Discovery cannot catch this — it is a plan-internal contradiction between `02-architecture-assessment.md`'s reliability @@ -72,6 +73,15 @@ Rule resolution protocol (Phase 2.5): - **Auto-fix**: rewrite every phase-gated resource to read the single declared phase parameter. +### `phased_param_wiring` (required parameter) + +- **Also catches**: a phase parameter with a default (for example `phase = 5`), which deploys every + phase when omitted and bypasses per-phase approval. Declare it required with a bounded range and no default. +- **Also catches**: a consumer ordered before a private endpoint or DNS zone it depends on + (image pull, Key Vault reference, SQL, Blob). For each consumer list every private endpoint on its + path and confirm `depends_on` and the phase order match before approval. Record the relation in the + contract: `consumes` on the consumer, `target` on the endpoint, `depends_on` from consumer to endpoint. + ### `phase_monotonicity` - **What it catches**: `Phase 1, Phase 2, Phase 4` (gap) or @@ -100,7 +110,8 @@ Rule resolution protocol (Phase 2.5): mechanical ones. - Do not re-trigger these checks on revision loops if the rule already recorded a decision; reuse the prior `apex-recall decide` value. -- Do not write a new validator script for these rules in this PR — they - ship as agent-enforced documentation. A future PR can add - `npm run validate:plan-consistency` once 5+ projects use the - schema. +- Both phase rules are validator-backed once the contract declares the fields. Emit `phase` on every resource, + `consumes` on each resource that reaches another over a private path, and `target` on each private endpoint; + `validate:iac-contract` then fails a `depends_on` into a later phase, and a consumer that does not `depends_on` + a private endpoint targeting what it consumes. Contracts without the fields are not checked, so emit them for new plans. + A phase parameter with a default is rejected for every contract. diff --git a/.github/skills/apex-terraform-patterns/SKILL.md b/.github/skills/apex-terraform-patterns/SKILL.md index ad9c81679..38d152364 100644 --- a/.github/skills/apex-terraform-patterns/SKILL.md +++ b/.github/skills/apex-terraform-patterns/SKILL.md @@ -34,7 +34,6 @@ Composable architecture building blocks for Azure Terraform. Complements | Plan Interpretation | Pre-deployment validation and change analysis | `references/plan-interpretation.md` | | AVM Pitfalls | Set-type diffs, provider pins, 4.x changes | `references/avm-pitfalls.md` | | Provider Compatibility | Why azurerm 4.x / Terraform 1.11 floors | `references/avm-provider-compatibility.md` | -| AVM Authoring | AVM certification requirements, compliance | `references/avm-authoring-requirements.md` | | Module Refactoring | Monolith → module extraction, state migration | `references/refactor-module.md` | --- @@ -43,8 +42,8 @@ Composable architecture building blocks for Azure Terraform. Complements Wire AVM child modules by passing outputs as inputs (`module..`); never hardcode IDs. **AVM-TF module versions in APEX-generated code MUST be exact semver -(`version = "X.Y.Z"`)** — pinned at plan time from -`registry.terraform.io` (newest stable in `modules[0].versions[]`). Range +(`version = "X.Y.Z"`)** — pinned at plan time to the newest stable release (Azure MCP +`azureterraform avm versions`, or `modules[0].versions[]` from `registry.terraform.io`). Range constraints (`~> X.Y`, `>= X.Y.Z`) are NOT allowed in `04-iac-contract.json` and will be flagged by `npm run validate:avm-versions`. Full code sample (resource group + key vault) and rationale in @@ -115,6 +114,5 @@ Applying a Terraform pattern in a root module: | `references/bootstrap-backend-template.md` | Backend bootstrap template | | `references/deploy-script-template.md` | Deployment script template | | `references/project-scaffold.md` | Project scaffolding structure | -| `references/avm-authoring-requirements.md` | AVM certification: 37 requirements, compliance checklist | | `references/refactor-module.md` | Module extraction, state migration, refactoring patterns | | `references/module-composition.md` | Canonical AVM module composition example with output wiring | diff --git a/.github/skills/apex-terraform-patterns/references/avm-authoring-requirements.md b/.github/skills/apex-terraform-patterns/references/avm-authoring-requirements.md deleted file mode 100644 index 6039470ee..000000000 --- a/.github/skills/apex-terraform-patterns/references/avm-authoring-requirements.md +++ /dev/null @@ -1,218 +0,0 @@ - - -# Azure Verified Modules (AVM) — Authoring Requirements - -Mandatory requirements for AVM-certified Azure Terraform modules. -For runtime issues (set-type diffs, provider pins, 4.x changes) see `avm-pitfalls.md`. - -**References:** - -- [Azure Verified Modules](https://azure.github.io/Azure-Verified-Modules/) -- [AVM Terraform Requirements](https://azure.github.io/Azure-Verified-Modules/specs/tf) - ---- - -## Module Cross-Referencing (TFFR1) - -- Modules **MUST** be referenced using Terraform registry with pinned version: - `source = "Azure/xxx/azurerm"` with `version = "1.2.3"` -- **MUST NOT** use git references or non-AVM modules - -## Azure Provider Requirements (TFFR3) - -| Provider | Constraint | -| -------- | ---------- | -| azurerm | `~> 4.0` | -| azapi | `~> 2.0` | - -Authors **MAY** select either or both. **MUST** use `required_providers` block. - -## Code Style Standards - -### Lower snake_casing (TFNFR4) - -**MUST** use `lower_snake_casing` for: locals, variables, outputs, resources, modules. - -### Resource & Data Source Ordering (TFNFR6) - -Resources that are depended on **SHOULD** come first. - -### Count & for_each Usage (TFNFR7) - -- `count` for conditional creation -- `for_each` **MUST** use `map(xxx)` or `set(xxx)` with static literal keys - -```hcl -resource "azurerm_subnet" "pair" { - for_each = var.subnet_map # map(string) - name = "${each.value}-pair" - resource_group_name = azurerm_resource_group.this.name - virtual_network_name = azurerm_virtual_network.this.name - address_prefixes = ["10.0.1.0/24"] -} -``` - -### Block Internal Ordering (TFNFR8) - -1. **Top meta-arguments**: `provider`, `count`, `for_each` -2. **Arguments/blocks** (alphabetical): required → optional → nested blocks -3. **Bottom meta-arguments**: `depends_on`, `lifecycle` (`create_before_destroy`, `ignore_changes`, `prevent_destroy`) - -### Module Block Ordering (TFNFR9) - -1. `source`, `version`, `count`, `for_each` -2. Arguments (alphabetical) -3. `depends_on`, `providers` - -### Lifecycle ignore_changes (TFNFR10) - -**MUST NOT** quote: `ignore_changes = [tags]` not `["tags"]`. - -### Dynamic Blocks (TFNFR12) - -```hcl -dynamic "identity" { - for_each = var.identity_type != null ? [var.identity_type] : [] - content { - type = identity.value - } -} -``` - -### Default Values (TFNFR13) - -Prefer `coalesce()` over ternary for defaults. - -### Provider Declarations in Modules (TFNFR27) - -**MUST NOT** declare `provider` blocks in modules (except `configuration_aliases`). - -## Variable Requirements - -| Rule | ID | Severity | -| ---------------------------------------------------- | ------- | -------- | -| No `enabled`/`module_depends_on` variables | TFNFR14 | MUST | -| Order: required (alpha) then optional (alpha) | TFNFR15 | SHOULD | -| Positive feature switches (`xxx_enabled`) | TFNFR16 | SHOULD | -| All variables have `description` | TFNFR17 | SHOULD | -| All variables have precise `type` (avoid `any`) | TFNFR18 | MUST | -| Sensitive objects: whole variable `sensitive = true` | TFNFR19 | SHOULD | -| Collections: `nullable = false` | TFNFR20 | SHOULD | -| Avoid `nullable = true` unless semantic need | TFNFR21 | MUST | -| Never write `sensitive = false` | TFNFR22 | MUST | -| No default values for sensitive inputs | TFNFR23 | MUST | -| Deprecated vars → `deprecated_variables.tf` | TFNFR24 | MUST | - -## Output Requirements (TFFR2) - -- **SHOULD NOT** output entire resource objects (anti-corruption layer pattern) -- Output _computed_ attributes as discrete outputs -- Sensitive outputs: `sensitive = true` -- `for_each` resources: output as map structure -- Deprecated outputs → `deprecated_outputs.tf` - -```hcl -output "key_vault_id" { - description = "Resource ID of the Key Vault" - value = azurerm_key_vault.this.id -} - -output "subnet_ids" { - description = "Map of subnet names to IDs" - value = { - for key, value in azurerm_subnet.this : key => value.id - } -} -``` - -## Local Values (TFNFR31-33) - -- `locals.tf` **SHOULD** only contain `locals` blocks -- Expressions **MUST** be arranged alphabetically -- Use precise types - -## Terraform Configuration (TFNFR25-26) - -- `terraform.tf` **MUST** contain exactly one `terraform` block -- First line: `required_version` with `~> #.#` format -- `required_providers`: `source` + `version` for each, sorted alphabetically - -```hcl -terraform { - required_version = "~> 1.9" - required_providers { - azurerm = { - source = "hashicorp/azurerm" - version = "~> 4.0" - } - } -} -``` - -## Testing Requirements (TFNFR5) - -Required tools: `terraform validate`, `terraform fmt`, and `terraform test`. -Test provider: set `prevent_deletion_if_contains_resources = false` (TFNFR36). - -## Breaking Changes & Feature Management - -### Feature Toggles (TFNFR34) - -New resources in minor/patch versions **MUST** have a toggle: - -```hcl -variable "create_route_table" { - type = bool - default = false - nullable = false -} - -resource "azurerm_route_table" "this" { - count = var.create_route_table ? 1 : 0 - # ... -} -``` - -### Potential Breaking Changes (TFNFR35) - -**Resource blocks**: adding without conditional, arguments with non-default values, -nested blocks without `dynamic`, renaming without `moved`, changing `count`↔`for_each`. - -**Variable/Output blocks**: deleting/renaming, changing type/default/nullable/sensitive. - -## Documentation (TFNFR2) - -**MUST** use [terraform-docs](https://github.com/terraform-docs/terraform-docs) -with `.terraform-docs.yml` in module root. - -## Compliance Checklist - -### Module Structure - -- [ ] Registry references with pinned versions (no git refs) -- [ ] Azure providers meet version constraints -- [ ] `.terraform-docs.yml` present - -### Code Style - -- [ ] All names use lower snake_casing -- [ ] `for_each` uses `map()` or `set()` with static keys -- [ ] Block ordering follows TFNFR8/TFNFR9 -- [ ] Dynamic blocks for conditional nested objects -- [ ] `coalesce()`/`try()` for defaults - -### Variables & Outputs - -- [ ] Precise types, descriptions, no `any` -- [ ] Collections `nullable = false` -- [ ] No `sensitive = false`, no defaults for sensitive inputs -- [ ] Anti-corruption layer outputs (discrete attributes) -- [ ] Deprecated items in `deprecated_*.tf` - -### Quality - -- [ ] Feature toggles for new resources -- [ ] Breaking changes documented -- [ ] Required test tools configured - -**Total: 37 requirements (21 MUST, 14 SHOULD, 2 MAY)** diff --git a/.github/skills/apex-workflow-engine/references/orchestrator-handoff-guide.md b/.github/skills/apex-workflow-engine/references/orchestrator-handoff-guide.md index c4d9249e1..f73c06223 100644 --- a/.github/skills/apex-workflow-engine/references/orchestrator-handoff-guide.md +++ b/.github/skills/apex-workflow-engine/references/orchestrator-handoff-guide.md @@ -187,5 +187,5 @@ Key points: the graph owns default and opt-in reviews; cost-estimate-subagent ha at Steps 2 and 7; the `apex-azure-governance-discovery` skill runs at Step 3.5 (Governance agent). **Pricing Accuracy Gate (Steps 2 & 7)**: All prices must originate from -`cost-estimate-subagent` (Azure Resource Manager MCP). Never write dollar +`cost-estimate-subagent` (Azure MCP `pricing_get`). Never write dollar figures from parametric knowledge. diff --git a/.github/skills/apex-workflow-engine/references/subagent-integration.md b/.github/skills/apex-workflow-engine/references/subagent-integration.md index 06a5e1be8..341b3f7e8 100644 --- a/.github/skills/apex-workflow-engine/references/subagent-integration.md +++ b/.github/skills/apex-workflow-engine/references/subagent-integration.md @@ -17,7 +17,7 @@ Discovery does not wire or authorize a call. Read the > [!NOTE] > **Pricing Accuracy Gate (Steps 2 & 7)**: No agent writes dollar figures from > parametric knowledge. All prices must originate from `cost-estimate-subagent` -> (Azure Resource Manager MCP). This policy applies to both the Architect +> (Azure MCP `pricing_get`). This policy applies to both the Architect > (Step 2, `03-des-cost-estimate.md`) and As-Built (Step 7, `07-ab-cost-estimate.md`) > agents. Established after model evaluation found pricing hallucinations > (see `agent-output/model-eval-scoring.md`). diff --git a/.github/skills/apex-workflow-engine/templates/workflow-graph.json b/.github/skills/apex-workflow-engine/templates/workflow-graph.json index 92a498f8c..f9202136b 100644 --- a/.github/skills/apex-workflow-engine/templates/workflow-graph.json +++ b/.github/skills/apex-workflow-engine/templates/workflow-graph.json @@ -346,7 +346,7 @@ "from": "step-2", "to": "step-1", "condition": "on_refine", - "reason": "Architect identifies requirement gaps — return for refinement", + "reason": "Architect identifies requirement gaps, or an owner decision that contradicts approved requirements (budget currency, sign-in scope, access path) — return for amendment", "ui_label_pattern": "^↩ Return to Step 1$" }, { @@ -381,7 +381,7 @@ "from": "step-4", "to": "step-3_5", "condition": "on_refine", - "reason": "IaC Planner needs fresh governance discovery", + "reason": "IaC Planner needs fresh governance discovery, including when resource types were added after Step 3.5 (for example by an owner-driven architecture change)", "ui_label_pattern": "^▶ Refresh Governance$" }, { diff --git a/.vscode/mcp.json b/.vscode/mcp.json index 830f8ddd7..c8424c447 100644 --- a/.vscode/mcp.json +++ b/.vscode/mcp.json @@ -8,7 +8,7 @@ "type": "http", "url": "https://mcp.management.azure.com", "headers": { - "x-mcp-toolset": "CostManagement, Pricing" + "x-mcp-toolset": "CostManagement" } }, "azure-mcp": { @@ -16,7 +16,7 @@ "command": "npx", "args": [ "-y", - "@azure/mcp@2.0.5", + "@azure/mcp@3.0.0", "server", "start" ], diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..b2d0b5ec3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,95 @@ +# Changelog + +Entries are grouped by conventional-commit type. Breaking changes list the migration steps. + +## Unreleased + +### Breaking changes + +#### Reopening a step marks dependent steps stale and supersedes the Plan approval + +`apex-recall start-step --force` on a completed step now sets every dependent step that is `complete` +or `in_progress` to the new `stale` status, clears its `started`, `completed` and `sub_step`, and sets +`decisions.plan_status` to `SUPERSEDED` when it was `APPROVED` or `EXCEPTION_AUTHORIZED` and the reopened step is 1, 2, +3_5 or 4. Artifacts and risk authorizations are kept as history. A stale step is not complete, so the next step cannot +start until it is re-completed in order. + +Who is affected: readers of `session.steps[*].status` that enumerate statuses, and any gate that expects `APPROVED` +after a reopen. CodeGen and the risk gates already require `APPROVED` or a current exception and now fail closed. + +Migration: + +1. Treat any status you do not recognize, including `stale`, as unfinished. +2. After a reopen, re-complete each stale step in order (`start-step`, then `complete-step`, or `transition`); + starting a stale step clears its stale markers. +3. Re-run the reviews and approvals the reopened step owns; the Plan needs a new approval before `plan_status` returns + to `APPROVED`. +4. Existing projects are unchanged until a step is reopened. Validate hand-written state with + `npm run validate:session-state`. + +#### Step 2 cannot complete while scratch or failed cost files remain + +`apex-recall complete-step 2` and `transition --from-step 2 --complete` exit with code 2 (`step2_residue`) +while the project folder holds a `*.tmp.md` file, or a `02-cost-estimate*.json`, `02-cost-candidates*.json` or +`03-des-cost-estimate*.json` file with `"status": "FAILED"`. `--allow-missing-challenger` does not bypass it. +`npm run validate:challenger-presence` reports the same files for projects whose Step 2 is already complete. + +Migration: + +1. Move superseded or failed cost files to `agent-output//_meta/` (only the top level is checked). +2. Delete scratch `*.tmp.md` files such as `02-waf-research.tmp.md`. +3. Re-run the command. + +#### Reviews may not freeze mutable parent-owned files as supporting inputs + +`validate-challenger-findings.mjs --metadata ... --supporting-input` refuses `sku-manifest.json`, `sku-manifest.md`, +`00-handoff.md`, `00-session-state.json`, `README.md` and `*-decisions.json`. `--verify-cache` rejects an existing review +that declares one, so `complete-step` and `transition --complete` fail for it. + +Migration: + +1. Generate a fresh review without the file; a review is never restamped to change its inputs. +2. Quote what you read from it in the review text instead (for `sku-manifest.json`, its `current_revision`). +3. Select the new review with the existing `--plan-review` or `--governance-review` options where they apply. + +#### A `phase` parameter may not declare a default + +`validate:iac-contract` fails a `params[]` entry named `phase` or `deployment_phase` that has a `default`, because an +omitted value would deploy every phase and bypass the per-phase approval gates. + +Migration: remove `default` from the parameter in `04-iac-contract.json` and the Bicep or Terraform source, and pass the +phase explicitly on every deployment. Refresh the contract hashes afterwards (see below). + +#### Raw resources need a row in the plan's exceptions section + +When a plan has a `### Raw Resource Exceptions` heading, `validate:iac-contract-consistency` fails any contract resource +without an AVM module entry that is not named in that section. Plans without the heading only warn. + +Migration: add one row per raw resource (resource type, stack, scope, evidence URL, rationale, review date) under +`### Raw Resource Exceptions`, or write `_None._` when every resource uses AVM, then refresh the contract hashes. + +### Features + +- `apex-recall review-audit --attempt-input ` computes the attempt digest from input files, so the start and the + outcome records agree and a changed input is rejected. It prints `input_digest` and + `permission_granted_by_this_record: false`; `authorization: not_granted` remains for existing readers. +- `validate:iac-contract --stage deploy|post-deploy|go-live` and an optional `blocks` list on `capability_checks` let an + unresolved capability block only the stages it names. A capability without `blocks` still blocks every stage. +- `policy-property-map` entries accept `status` (`satisfied`, `pending`, `not-applicable`, `unsatisfiable`) and + `not_applicable_reason`. A not-applicable row needs a reason; an unsatisfiable row fails and routes to Governance. +- Contract resources accept optional `phase`, `consumes` and `target` (private endpoints). `validate:iac-contract` then + fails a `depends_on` into a later phase and a consumer that does not `depends_on` a private endpoint targeting what it + consumes. Contracts without the fields are not checked. +- `npm run sync:draft-contract-hashes` is now a registered command. It updates only the `plan_ref` and `l1m_ref` digest + bytes, keeps the file's formatting, and `--write` replaces the contract atomically. It still refuses an approved plan. +- `render-session-handoff` shows stale steps with the reason they are stale. + +### Documentation + +- Architect, Orchestrator, Requirements and Planner guidance, the plan and requirements templates, the workflow gates, + the review protocol and the Bicep patterns skill now carry the nordic-foods lessons: ask owner inputs before + architecture approval, never substitute an owner decision to meet a budget, show the architecture's value in the + Planner panel, route new resource types through Refresh Governance, and prefer the MCR tag list over the AVM metadata + tool when deciding whether a module exists. +- New reference: private-registry App Service with certificateless Entra sign-in. +- The Architect validation checklist moved to `apex-azure-defaults/references/architect-validation-checklist.md`. diff --git a/package.json b/package.json index 839180132..728f43171 100644 --- a/package.json +++ b/package.json @@ -15,12 +15,16 @@ "lint:h2-sync": "node tools/scripts/validate-artifacts.mjs", "fix:artifact-h2": "node tools/scripts/validate-artifacts.mjs --fix", "check:h2-order": "node tools/scripts/check-h2-order.mjs", + "check:azure-sla": "node tools/scripts/azure-sla-section.mjs", + "test:azure-sla-section": "node --test tools/tests/azure-sla-section.test.mjs", "check:context-redundancy": "node tools/scripts/check-context-redundancy.mjs", "render:headings-summary": "node tools/scripts/render-headings-summary.mjs", "validate:headings-summary": "node tools/scripts/validate-headings-summary.mjs", "lint:version-sync": "node tools/scripts/validate-version-sync.mjs", "lint:deprecated-refs": "node tools/scripts/validate-no-deprecated-refs.mjs", "lint:mcp-config": "node tools/scripts/validate-mcp-config.mjs", + "validate:mcp-tool-refs": "node tools/scripts/validate-mcp-tool-refs.mjs", + "refresh:mcp-tool-snapshot": "node tools/scripts/refresh-azure-mcp-snapshot.mjs", "check:mcp-release": "node .devcontainer/configure-mcp.mjs --check-release", "validate:extension-bloat": "node tools/scripts/validate-extension-bloat.mjs", "lint:docs-freshness": "node tools/scripts/check-docs-freshness.mjs", @@ -37,7 +41,7 @@ "lint:skills-format": "node tools/scripts/validate-skills.mjs", "lint:governance-refs": "node tools/scripts/validate-governance-refs.mjs", "test:governance-discovery": "python3 -m pytest .github/skills/apex-azure-governance-discovery/scripts/test_discover.py -q", - "test:governance-baseline-python": "python3 -m pytest .github/skills/apex-azure-governance-discovery/scripts/test_governance_baseline.py -q", + "test:governance-baseline-python": "python3 -m pytest .github/skills/apex-azure-governance-discovery/scripts/test_governance_baseline.py .github/skills/apex-azure-governance-discovery/scripts/test_generate_alz_baseline.py -q", "validate:instruction-checks": "node tools/scripts/validate-instruction-checks.mjs", "validate:session-state": "node tools/scripts/validate-session-state.mjs", "lint:prose": "command -v vale >/dev/null 2>&1 || { echo 'Vale is not installed; install it before running lint:prose' >&2; exit 127; }; vale --config .vale.ini .github/ README.md", @@ -58,6 +62,7 @@ "validate:terraform": "status=0; for dir in infra/terraform/*/; do if [ -f \"$dir/main.tf\" ]; then echo \"Validating $dir\"; (cd \"$dir\" && terraform init -backend=false -input=false && terraform validate) || status=1; fi; done; exit \"$status\"", "validate:vscode": "node tools/scripts/validate-vscode-config.mjs", "fetch:deprecations": "node tools/scripts/fetch-azure-deprecations.mjs", + "generate:governance-baseline-alz": "python3 .github/skills/apex-azure-governance-discovery/scripts/generate_alz_baseline.py", "snapshot:baseline": "bash tools/scripts/snapshot-agent-context.sh", "diff:baseline": "bash tools/scripts/diff-context-baseline.sh", "profile:debug-log": "python3 tools/scripts/profile_debug_log.py", @@ -97,6 +102,7 @@ "validate:iac-security-baseline": "node tools/scripts/validate-iac-security-baseline.mjs", "validate:iac-contract": "node tools/scripts/validate-iac-contract.mjs", "validate:iac-contract-consistency": "node tools/scripts/validate-iac-contract-consistency.mjs", + "sync:draft-contract-hashes": "node tools/scripts/sync-draft-contract-hashes.mjs", "validate:avm-versions": "node tools/scripts/validate-avm-module-versions.mjs --mode=local", "validate:avm-versions:ci": "node tools/scripts/validate-avm-module-versions.mjs --mode=ci", "validate:avm-versions:freeze": "node tools/scripts/validate-avm-module-versions.mjs --mode=freeze", @@ -134,9 +140,9 @@ "test:validator-runner": "node --test tools/tests/validate-all.test.mjs tools/tests/generate-explorer-graph.test.mjs tools/tests/lib/json.test.mjs tools/tests/lib/ajv-validator.test.mjs tools/tests/lib/npm-script-graph.test.mjs tools/tests/lib/parse-frontmatter.test.mjs tools/tests/lib/parsing-helpers.test.mjs", "test:devcontainer-verdicts": "node --test tools/tests/compare-devcontainer-verdicts.test.mjs", "validate:_node": "node tools/scripts/validate-all.mjs --suite=validate:_node:legacy --concurrency=4", - "validate:_node:legacy": "run-p validate:artifacts validate:agents validate:orchestrator-handoff validate:review-ceiling validate:question-batching validate:skills lint:version-sync lint:deprecated-refs lint:mcp-config validate:extension-bloat validate:headings-summary validate:instruction-checks lint:governance-refs validate:session-state validate:workflow-graph validate:lens-references validate:challenger-findings validate:challenger-presence lint:workflow-table-sync validate:agent-registry validate:skill-checks lint:json lint:json-schemas lint:glob-audit lint:orphaned-content lint:docs-freshness validate:no-hardcoded-counts validate:hooks validate:context-budget validate:iac-security-baseline validate:governance-baseline validate:policy-precheck validate:sku-manifest validate:sku-iac-coverage validate:decision-keys validate:banned-phrases validate:vscode validate:terminology validate:model-consistency validate:deprecated-models validate:model-catalog validate:explorer-graph validate:region-canonical test:retirement-scan test:azure-defaults-canonical test:governance-baseline-loader test:classify-model test:devcontainer-verdicts test:validator-runner test:tool-contracts test:sku-manifest lint:safe-shell validate:tool-versions", + "validate:_node:legacy": "run-p validate:artifacts validate:agents validate:orchestrator-handoff validate:review-ceiling validate:question-batching validate:skills lint:version-sync lint:deprecated-refs lint:mcp-config validate:mcp-tool-refs validate:extension-bloat validate:headings-summary validate:instruction-checks lint:governance-refs validate:session-state validate:workflow-graph validate:lens-references validate:challenger-findings validate:challenger-presence lint:workflow-table-sync validate:agent-registry validate:skill-checks lint:json lint:json-schemas lint:glob-audit lint:orphaned-content lint:docs-freshness validate:no-hardcoded-counts validate:hooks validate:context-budget validate:iac-security-baseline validate:governance-baseline validate:policy-precheck validate:sku-manifest validate:sku-iac-coverage validate:decision-keys validate:banned-phrases validate:vscode validate:terminology validate:model-consistency validate:deprecated-models validate:model-catalog validate:explorer-graph validate:region-canonical test:retirement-scan test:azure-defaults-canonical test:governance-baseline-loader test:classify-model test:devcontainer-verdicts test:validator-runner test:tool-contracts test:sku-manifest lint:safe-shell validate:tool-versions", "validate:_node-ci": "node tools/scripts/validate-all.mjs --suite=validate:_node-ci:legacy --concurrency=4", - "validate:_node-ci:legacy": "run-p validate:artifacts validate:agents validate:orchestrator-handoff validate:review-ceiling validate:question-batching validate:skills lint:version-sync lint:deprecated-refs lint:mcp-config validate:extension-bloat validate:headings-summary lint:governance-refs validate:instruction-checks validate:session-state validate:workflow-graph validate:lens-references validate:challenger-findings validate:challenger-presence lint:workflow-table-sync validate:agent-registry validate:skill-checks lint:json lint:json-schemas lint:glob-audit lint:orphaned-content validate:vscode validate:model-consistency validate:deprecated-models validate:model-catalog validate:explorer-graph validate:region-canonical validate:governance-baseline test:retirement-scan test:azure-defaults-canonical test:governance-baseline-loader test:classify-model test:devcontainer-verdicts test:validator-runner test:tool-contracts test:sku-manifest validate:no-hardcoded-counts validate:terminology validate:sku-manifest validate:sku-iac-coverage validate:decision-keys validate:banned-phrases validate:context-budget validate:policy-precheck lint:js:ci lint:safe-shell", + "validate:_node-ci:legacy": "run-p validate:artifacts validate:agents validate:orchestrator-handoff validate:review-ceiling validate:question-batching validate:skills lint:version-sync lint:deprecated-refs lint:mcp-config validate:mcp-tool-refs validate:extension-bloat validate:headings-summary lint:governance-refs validate:instruction-checks validate:session-state validate:workflow-graph validate:lens-references validate:challenger-findings validate:challenger-presence lint:workflow-table-sync validate:agent-registry validate:skill-checks lint:json lint:json-schemas lint:glob-audit lint:orphaned-content validate:vscode validate:model-consistency validate:deprecated-models validate:model-catalog validate:explorer-graph validate:region-canonical validate:governance-baseline test:retirement-scan test:azure-defaults-canonical test:governance-baseline-loader test:classify-model test:devcontainer-verdicts test:validator-runner test:tool-contracts test:sku-manifest validate:no-hardcoded-counts validate:terminology validate:sku-manifest validate:sku-iac-coverage validate:decision-keys validate:banned-phrases validate:context-budget validate:policy-precheck lint:js:ci lint:safe-shell", "validate:_external": "run-p lint:md lint:python lint:terraform-fmt lint:bicep-fmt validate:terraform test:apex-recall test:python-tools test:governance-baseline-python", "validate:all": "run-p --continue-on-error validate:_node validate:_external && echo '✅ All validations passed'", "audit:quarterly": "run-p lint:glob-audit lint:orphaned-content lint:docs-freshness", diff --git a/tools/apex-prompts/utility-prompts/eventdesk-orchestrator.prompt.md b/tools/apex-prompts/utility-prompts/eventdesk-orchestrator.prompt.md new file mode 100644 index 000000000..862f7a2a7 --- /dev/null +++ b/tools/apex-prompts/utility-prompts/eventdesk-orchestrator.prompt.md @@ -0,0 +1,53 @@ +--- +description: "Start the Eventdesk project with a fixed intake brief; the only question asked is Bicep or Terraform." +agent: "01-Orchestrator" +argument-hint: "Enter Bicep or Terraform" +--- + +# Start Eventdesk + +Read the [shared entry contract][entry] with `01-Orchestrator` selected and its configured model +verified. Stop on missing owner, model or tools; do not widen tools or run under another agent. + +Start the project with [Starting a New Project][start] using the brief below. Treat each value as an +owner decision and pass it to Requirements unchanged. The IaC tool is the only intake question. If +it is missing or neither Bicep nor Terraform, ask for it once and stop until answered. Do not ask any +other Step 1 intake question; if a value in the brief is contradictory or unsafe, stop and report it +instead of substituting. Never substitute one of these values to fit a budget or a default; stop and +state the owner's choice with the alternative priced. Report which Step 1 answers were inferred +rather than taken from this brief. Do not deploy; stop at the approval gate after the implementation +plan. If an earlier decision changes, state which steps become stale before proceeding. + +Keep all work on the current branch `test/azure-mcp-3-e2e` (PR 764). Do not create or switch branches, +push, or open or update pull requests unless the user asks. + +## Project brief + +- Name: eventdesk +- Purpose and users: Eventdesk demo workload; an authenticated staff web portal for managing events +- Workload: one containerized web app, one SQL database, key vault, registry +- Scale: 500 concurrent users at peak +- Availability and recovery: SLA 99.9%, RTO 4 hours, RPO 1 hour, single instance in one zone, disaster + recovery out of scope +- Environments: dev only +- Region: swedencentral +- Compliance and data: GDPR, EU-only personal data held in SQL, 30-day retention, no personal data in + logs or telemetry +- Sign-in owner: application team owns Entra sign-in; the platform deploys no app registration +- SQL Entra admin group: sql-admins, object ID b792147a-b96c-4c03-a632-1341d156d4a8 +- Operator access to private services: Azure Bastion Standard with a Linux jump VM (Standard_B2s_v2), + Entra sign-in, no public IP on the VM +- Budget: 1000 USD per month, billed in USD +- Budget alert recipient: vella.jonathan@outlook.com, Owner role on the subscription: yes +- Cost monitoring: minimal for dev; operational alerts go to the same recipient +- Tags: owner=demo, costcenter=none, sla=99.9%, backup-policy=sql-pitr-30d-blob-recovery, + maint-window=sat-02:00-04:00, technical-contact=vella.jonathan@outlook.com. Use these unless Azure + Policy enforces a different set. +- IaC and SKUs: ${input:iac:Bicep or Terraform} with Azure Verified Modules, latest stable; no SKU pins. + Recommend the cheapest option that meets the brief and show each price. + +Design the jump host's outbound path for Entra sign-in and package access, and show the cost of +Bastion, the VM, its disk and any NAT Gateway or public IP. + +[entry]: ../../../.github/skills/apex-workflow-engine/references/workflow-entry.md +[start]: ../../../.github/agents/01-orchestrator.agent.md#starting-a-new-project diff --git a/tools/apex-prompts/workflow-prompts/01-new-project.prompt.md b/tools/apex-prompts/workflow-prompts/01-new-project.prompt.md new file mode 100644 index 000000000..89339679f --- /dev/null +++ b/tools/apex-prompts/workflow-prompts/01-new-project.prompt.md @@ -0,0 +1,43 @@ +--- +description: "Start a new APEX project from a complete intake brief, with owner decisions answered up front." +agent: "01-Orchestrator" +argument-hint: "Fill every input; type 'ask me' for a value you want to be asked about in Step 1" +--- + +# Start New Project + +Read the [shared entry contract][entry] with `01-Orchestrator` selected and its configured model +verified. Stop on missing owner, model or tools; do not widen tools or run under another agent. + +Start the project with [Starting a New Project][start] using the brief below. Treat each value as an +owner decision and pass it to Requirements unchanged. Ask only for entries that are `ask me`, missing +or contradictory, in one batch before Step 2 approval. Never substitute one of these values to fit a +budget or a default; stop and ask with the owner's choice and the alternative priced. Report which +Step 1 answers were inferred rather than taken from this brief. Do not deploy; stop at the approval +gate after the implementation plan. If an earlier decision changes, state which steps become stale +before proceeding. + +## Project brief + +- Name: ${input:project:short-kebab-case-name} +- Purpose and users: ${input:purpose:what the workload does and who uses it} +- Workload: ${input:workload:components, for example one containerized web app, one SQL database, key vault, registry} +- Scale: ${input:scale:total users, peak concurrent users, growth} +- Availability and recovery: ${input:availability:SLA, RTO, RPO, single instance or zones, disaster recovery in or out of scope} +- Environments: ${input:environments:dev only} +- Region: ${input:region:swedencentral} +- Compliance and data: ${input:compliance:GDPR, data kinds held, retention periods} +- Sign-in owner: ${input:authOwner:application team owns Entra sign-in; the platform deploys no app registration} +- SQL Entra admin group: ${input:sqlAdminGroup:group name}, object ID ${input:sqlAdminObjectId:object id} +- Operator access to private services: ${input:operatorAccess:Azure Bastion Basic with a Linux jump VM, Entra sign-in, no public IP; ask me before Standard} +- Budget: ${input:budget:1000} ${input:currency:USD} per month, billed in that currency +- Budget alert recipient: ${input:alertEmail:email}, Owner role on the subscription: ${input:alertEmailIsOwner:yes or no}. If they are not an Owner, state how alerts reach them. +- Cost monitoring: ${input:costMonitoring:minimal for dev; operational alerts go to the same recipient} +- Tags: owner=${input:tagOwner:owner}, costcenter=${input:tagCostcenter:costcenter}, sla=${input:tagSla:sla}, backup-policy=${input:tagBackup:backup-policy}, maint-window=${input:tagMaint:maintenance window}, technical-contact=${input:tagContact:contact email}. Use these unless Azure Policy enforces a different set. +- IaC and SKUs: ${input:iac:Bicep with Azure Verified Modules, latest stable}; no SKU pins unless listed here: ${input:skuPins:none}. Recommend the cheapest option that meets the brief and show each price. + +Where the Operator access line names a jump host, design its outbound path for Entra sign-in and +package access, and show the cost of Bastion, the VM, its disk and any NAT Gateway or public IP. + +[entry]: ../../../.github/skills/apex-workflow-engine/references/workflow-entry.md +[start]: ../../../.github/agents/01-orchestrator.agent.md#starting-a-new-project diff --git a/tools/apex-recall/docs/decision-keys.md b/tools/apex-recall/docs/decision-keys.md index d21068763..b0ad41913 100644 --- a/tools/apex-recall/docs/decision-keys.md +++ b/tools/apex-recall/docs/decision-keys.md @@ -97,7 +97,7 @@ The `` suffix is the integer step number (`1`, `2`, `3_5`, `4`). | `public_edge_auth` | `entra-only` \| `app-gateway-waf` \| `front-door` \| `apim` \| `none` | n/a | 05-IaC Planner | 06b/06t CodeGen | | `script_runtime_image` | container image ref (e.g. `mcr.microsoft.com/azure-cli:2.x`) | n/a | 05-IaC Planner | 06b/06t CodeGen (deployment scripts) | | `az_posture` | `private-only` \| `hybrid` \| `public-restricted` | n/a | 05-IaC Planner | 06b/06t CodeGen, 04g-Governance review | -| `plan_status` | `APPROVED` \| `EXCEPTION_AUTHORIZED` | n/a (plan not approved) | 05-IaC Planner, apex-recall | 06b/06t CodeGen | +| `plan_status` | `APPROVED` \| `EXCEPTION_AUTHORIZED` \| `SUPERSEDED` | n/a (plan not approved) | 05-IaC Planner, apex-recall | 06b/06t CodeGen | ### Step 6 (Deploy) keys diff --git a/tools/apex-recall/docs/show-schema.md b/tools/apex-recall/docs/show-schema.md index 74a520fe9..60424b12c 100644 --- a/tools/apex-recall/docs/show-schema.md +++ b/tools/apex-recall/docs/show-schema.md @@ -108,6 +108,29 @@ must reference a failed original invocation in the same step, never another retr The caller must establish that the failure was actually empty output and that the retry is authorized; the audit does not confer this permission. Legacy executed totals cannot decrease; do not equate a filename's pass with totals. +Instead of `--input-digest`, pass each frozen input with `--attempt-input ` (repeatable; files or directories inside +the workspace, no symlinks). The digest is the SHA-256 of the compact JSON array of sorted +`[workspace-relative POSIX path, file SHA-256]` pairs, so the start and the outcome records use the same value and a +changed input is rejected as a reused identity. The command prints `input_digest` and +`permission_granted_by_this_record: false`; the older `authorization: not_granted` field remains for existing readers. +The record is evidence only. + +### Reopening a step + +`start-step --force` on a completed step marks every dependent step that is `complete` or `in_progress` +`stale` (clearing `started`, `completed` and `sub_step`, and adding `stale_since`, `stale_because` and +`previous_completed`). It sets `decisions.plan_status` to `SUPERSEDED` when it was `APPROVED` or `EXCEPTION_AUTHORIZED` +and the reopened step is 1, 2, 3_5 or 4, and appends a `decision_log` entry. Artifacts and risk authorizations are kept +as history. A stale step is not complete, so the next step cannot start until it is re-completed in order. Starting or +transitioning into a stale step clears the stale markers. Readers that do not know `stale` see an unfinished step. + +### Step 2 completion check + +`complete-step` and `transition --complete` for Step 2 exit with code 2 and `step2_residue` while the project folder +contains `*.tmp.md` files or `02-cost-estimate*.json`, `02-cost-candidates*.json` or `03-des-cost-estimate*.json` with +`"status": "FAILED"`. Only the top level is checked; move superseded files to `_meta/`. The check is separate from the +challenger gate and `--allow-missing-challenger` does not bypass it. + ### `session.steps` Keys are **strings** (`"1"`, `"2"`, `"3"`, `"3_5"`, `"4"`, `"5"`, `"6"`, `"7"`). @@ -117,7 +140,7 @@ Each value is an object describing the step's lifecycle: { "name": "IaC Code", "agent": "06b-Bicep CodeGen", - "status": "complete", // pending | in-progress | complete | failed + "status": "complete", // pending | in_progress | complete | skipped | stale "sub_step": "phase_4_validation", "started": "2026-05-12T11:00:00Z", "completed": "2026-05-12T11:30:00Z", diff --git a/tools/apex-recall/src/apex_recall/__main__.py b/tools/apex-recall/src/apex_recall/__main__.py index 641296284..2553ffc08 100644 --- a/tools/apex-recall/src/apex_recall/__main__.py +++ b/tools/apex-recall/src/apex_recall/__main__.py @@ -144,6 +144,13 @@ def build_parser() -> argparse.ArgumentParser: p_ra.add_argument("--attempt-id", default=None, help="Caller-assigned durable attempt identity") p_ra.add_argument("--attempt-kind", choices=["invocation", "repair", "empty-output-retry"], default=None) p_ra.add_argument("--input-digest", default=None, help="SHA-256 of the frozen input set") + p_ra.add_argument( + "--attempt-input", + action="append", + default=None, + metavar="PATH", + help="Frozen input file or directory; the digest is computed from these (repeatable, instead of --input-digest)", + ) p_ra.add_argument("--attempt-outcome", choices=["started", "completed", "failed", "unknown"], default=None) p_ra.add_argument("--retry-of", default=None, help="Original attempt ID for an identical-input empty-output retry") diff --git a/tools/apex-recall/src/apex_recall/commands/complete_step.py b/tools/apex-recall/src/apex_recall/commands/complete_step.py index 507a373fe..46aa0ebdb 100644 --- a/tools/apex-recall/src/apex_recall/commands/complete_step.py +++ b/tools/apex-recall/src/apex_recall/commands/complete_step.py @@ -40,6 +40,7 @@ validate_step_key, write_state, ) +from ..step2_gate import report_residue, step2_residue from ..step_order import check_order, report_order_error # Step -> (gating artifact, required findings sidecar) for review-mandated @@ -347,6 +348,11 @@ def run(args) -> int: except ValueError as error: return report_order_error(project, step, error, as_json) + if step == "2" and data.get("steps", {}).get(step, {}).get("status") != "complete": + residue = step2_residue(project) + if residue: + return report_residue(project, residue, as_json) + try: governance_review, selection = _select_replacement_review(project, step, args, data) watch_review_inputs(data, project, step, governance_review) diff --git a/tools/apex-recall/src/apex_recall/commands/review_audit.py b/tools/apex-recall/src/apex_recall/commands/review_audit.py index e6f15bcc6..1fec9f468 100644 --- a/tools/apex-recall/src/apex_recall/commands/review_audit.py +++ b/tools/apex-recall/src/apex_recall/commands/review_audit.py @@ -5,6 +5,7 @@ import json import re +from ..input_digest import inputs_digest from ..state_writer import ( _iso_now, check_state_revision, @@ -35,8 +36,13 @@ def run(args) -> int: attempt_options = [ getattr(args, name, None) for name in ("attempt_kind", "input_digest", "attempt_outcome", "retry_of") ] - if attempt_id is not None or any(attempt_options): + attempt_inputs = getattr(args, "attempt_input", None) or [] + if attempt_id is not None or any(attempt_options) or attempt_inputs: kind, digest, outcome, retry_of = attempt_options + if attempt_inputs: + if digest: + raise ValueError("Use --input-digest or --attempt-input, not both") + digest = inputs_digest(path.parent.parent.parent, attempt_inputs) if ( not attempt_id or not re.fullmatch(r"[A-Za-z0-9_-]{1,100}", attempt_id) @@ -55,7 +61,7 @@ def run(args) -> int: raise ValueError("Attempt identity cannot be reused for different inputs, kind or step") if previous and previous[-1]["outcome"] == outcome: check_state_revision(data, path) - print(json.dumps({"outcome": "already_applied", "attempt_id": attempt_id})) + print(json.dumps({"outcome": "already_applied", "attempt_id": attempt_id, "input_digest": digest})) return 0 if previous and previous[-1]["outcome"] != "started": raise ValueError( @@ -88,7 +94,18 @@ def run(args) -> int: } ) write_state(project, data) - print(json.dumps({"attempt_id": attempt_id, "outcome": outcome, "authorization": "not_granted"})) + # `authorization` is kept for existing readers; this record is evidence only and permits nothing. + print( + json.dumps( + { + "attempt_id": attempt_id, + "outcome": outcome, + "input_digest": digest, + "permission_granted_by_this_record": False, + "authorization": "not_granted", + } + ) + ) return 0 audit_key = f"step_{step}" diff --git a/tools/apex-recall/src/apex_recall/commands/start_step.py b/tools/apex-recall/src/apex_recall/commands/start_step.py index 2866f069c..31c6d2483 100644 --- a/tools/apex-recall/src/apex_recall/commands/start_step.py +++ b/tools/apex-recall/src/apex_recall/commands/start_step.py @@ -5,6 +5,7 @@ import json import sys +from ..reopen import clear_stale, invalidate_dependents from ..risk_gate import check_entry, ordinary_entry_warning, record_authorization from ..state_writer import ( _iso_now, @@ -50,22 +51,33 @@ def run(args) -> int: return 1 now = _iso_now() + reopened = step_data.get("status") == "complete" step_data["status"] = "in_progress" step_data["started"] = now step_data["completed"] = None + clear_stale(step_data) data["steps"][step] = step_data data["current_step"] = step_to_int(step) + cascade = invalidate_dependents(data, step, now) if reopened else {} record_authorization(data, entry_result, now) write_state(project, data) result = {"project": project, "step": step, "status": "in_progress", "started": now} + if cascade.get("stale_steps"): + result["stale_steps"] = cascade["stale_steps"] + if cascade.get("superseded_plan_status"): + result["plan_status"] = "SUPERSEDED" if warning: result["warnings"] = [warning] if as_json: print(json.dumps(result)) else: print(f"Step {step} started for {project}") + if cascade.get("stale_steps"): + print("Marked stale (re-complete in order): " + ", ".join(cascade["stale_steps"])) + if cascade.get("superseded_plan_status"): + print(f"plan_status {cascade['superseded_plan_status']} -> SUPERSEDED") if warning: print(f"Warning: {warning}", file=sys.stderr) diff --git a/tools/apex-recall/src/apex_recall/commands/transition.py b/tools/apex-recall/src/apex_recall/commands/transition.py index 46b4e8aa1..8290bef4e 100644 --- a/tools/apex-recall/src/apex_recall/commands/transition.py +++ b/tools/apex-recall/src/apex_recall/commands/transition.py @@ -42,6 +42,7 @@ import json import sys +from ..reopen import clear_stale from ..risk_gate import COMPLETION_ACTIONS, check_entry, evaluate_gate, ordinary_entry_warning, record_authorization from ..state_writer import ( _iso_now, @@ -53,6 +54,7 @@ validate_step_key, write_state, ) +from ..step2_gate import report_residue, step2_residue from ..step_order import check_order, report_order_error from .complete_step import ( _challenger_findings_invalid, @@ -116,6 +118,11 @@ def run(args) -> int: # noqa: C901 — one CLI dispatcher, branchy by design except ValueError as exc: return report_order_error(project, to_step, exc, as_json) + if complete and from_step == "2" and data.get("steps", {}).get(from_step, {}).get("status") != "complete": + residue = step2_residue(project) + if residue: + return report_residue(project, residue, as_json) + try: governance_review, selection = _select_replacement_review(project, from_step, args, data) explicit_selection = any( @@ -276,6 +283,7 @@ def run(args) -> int: # noqa: C901 — one CLI dispatcher, branchy by design to_data["status"] = "in_progress" to_data["started"] = now to_data["completed"] = None + clear_stale(to_data) data["steps"][to_step] = to_data data["current_step"] = step_to_int(to_step) diff --git a/tools/apex-recall/src/apex_recall/input_digest.py b/tools/apex-recall/src/apex_recall/input_digest.py new file mode 100644 index 000000000..67db8575c --- /dev/null +++ b/tools/apex-recall/src/apex_recall/input_digest.py @@ -0,0 +1,41 @@ +"""Canonical digest of a review's frozen input paths, so start and finish records agree.""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +_IGNORED = {".git", ".terraform", "node_modules", ".venv", "__pycache__"} + + +def _walk(target: Path): + if target.is_symlink(): + raise ValueError(f"Review inputs cannot contain symlinks: {target}") + if target.is_dir(): + for child in sorted(target.iterdir(), key=lambda item: item.name): + if child.name not in _IGNORED: + yield from _walk(child) + elif target.is_file(): + yield target + else: + raise ValueError(f"Review input not found: {target}") + + +def inputs_digest(root: Path, paths: list[str]) -> str: + """SHA-256 over sorted ``[workspace-relative POSIX path, file SHA-256]`` pairs, compact JSON.""" + root = root.resolve() + entries: dict[str, str] = {} + for raw in paths: + target = (root / raw).resolve() + try: + target.relative_to(root) + except ValueError: + raise ValueError(f"Review input is outside the workspace: {raw}") from None + for file in _walk(root / raw): + relative = file.resolve().relative_to(root).as_posix() + entries[relative] = hashlib.sha256(file.read_bytes()).hexdigest() + if not entries: + raise ValueError("Review inputs contain no files") + payload = json.dumps(sorted(entries.items()), separators=(",", ":"), ensure_ascii=False) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() diff --git a/tools/apex-recall/src/apex_recall/reopen.py b/tools/apex-recall/src/apex_recall/reopen.py new file mode 100644 index 000000000..8e700de3f --- /dev/null +++ b/tools/apex-recall/src/apex_recall/reopen.py @@ -0,0 +1,69 @@ +"""Downstream invalidation when a completed step is reopened with ``start-step --force``.""" + +from __future__ import annotations + +# Keep equal to the `requires` closure in workflow-graph.json; test_reopen.py asserts the match. +DEPENDENTS: dict[str, tuple[str, ...]] = { + "1": ("2", "3", "3_5", "4", "5", "6", "7"), + "2": ("3", "3_5", "4", "5", "6", "7"), + "3": (), + "3_5": ("4", "5", "6", "7"), + "4": ("5", "6", "7"), + "5": ("6", "7"), + "6": ("7",), + "7": (), +} +# Reopening any of these makes an existing Plan approval refer to superseded inputs. +_PLAN_INPUT_STEPS = ("1", "2", "3_5", "4") +_STALE_KEYS = ("previous_completed", "stale_since", "stale_because") + + +def invalidate_dependents(data, step: str, now: str) -> dict: + """Mark dependent steps stale and supersede the Plan approval; return what changed. + + Stale is additive: readers that do not know it see a status that is not ``complete``, so + they treat the step as unfinished. Artifacts and risk authorizations are kept as history. + """ + stale: list[str] = [] + for key in DEPENDENTS.get(step, ()): + entry = data["steps"].get(key) + if not entry or entry.get("status") not in ("complete", "in_progress"): + continue + entry.update( + status="stale", + previous_completed=entry.get("completed"), + stale_since=now, + stale_because=f"Step {step} was reopened", + sub_step=None, + started=None, + completed=None, + ) + stale.append(key) + + superseded = None + decisions = data.setdefault("decisions", {}) + if step in _PLAN_INPUT_STEPS and decisions.get("plan_status") in ("APPROVED", "EXCEPTION_AUTHORIZED"): + superseded = decisions["plan_status"] + decisions["plan_status"] = "SUPERSEDED" + + if stale or superseded: + parts = [] + if stale: + parts.append("steps marked stale: " + ", ".join(stale)) + if superseded: + parts.append(f"plan_status {superseded} -> SUPERSEDED") + data.setdefault("decision_log", []).append( + { + "decision": f"Step {step} reopened; " + "; ".join(parts), + "timestamp": now, + "rationale": "Reopening a step invalidates what was built on it; re-complete each stale step in order", + "step": step, + } + ) + return {"stale_steps": stale, "superseded_plan_status": superseded} + + +def clear_stale(step_data: dict) -> None: + """Drop stale markers when a stale step is started again.""" + for key in _STALE_KEYS: + step_data.pop(key, None) diff --git a/tools/apex-recall/src/apex_recall/step2_gate.py b/tools/apex-recall/src/apex_recall/step2_gate.py new file mode 100644 index 000000000..f04571eb4 --- /dev/null +++ b/tools/apex-recall/src/apex_recall/step2_gate.py @@ -0,0 +1,55 @@ +"""Step 2 completion hygiene: refuse to close Step 2 while scratch or failed cost files remain.""" + +from __future__ import annotations + +import json + +from .state_writer import session_state_path + +_COST_PATTERNS = ("02-cost-estimate*.json", "02-cost-candidates*.json", "03-des-cost-estimate*.json") + + +def step2_residue(project: str) -> list[str]: + """List leftover files in the project folder (top level only; ``_meta/`` is the archive).""" + directory = session_state_path(project).parent + found = [f"{path.name}: scratch file" for path in sorted(directory.glob("*.tmp.md")) if path.is_file()] + seen: set[str] = set() + for pattern in _COST_PATTERNS: + for path in sorted(directory.glob(pattern)): + if path.name in seen or not path.is_file(): + continue + seen.add(path.name) + try: + document = json.loads(path.read_text(encoding="utf-8")) + except OSError, UnicodeError, json.JSONDecodeError: + continue + if isinstance(document, dict) and document.get("status") == "FAILED": + found.append(f"{path.name}: cost estimate with status FAILED") + return found + + +def residue_remediation(project: str) -> str: + return ( + f"Move superseded or failed cost files to agent-output/{project}/_meta/ and delete scratch *.tmp.md " + "files, then re-run the command. This check cannot be bypassed with --allow-missing-challenger." + ) + + +def report_residue(project: str, residue: list[str], as_json: bool) -> int: + remediation = residue_remediation(project) + if as_json: + print( + json.dumps( + { + "project": project, + "step": "2", + "error": "step2_residue", + "files": residue, + "remediation": remediation, + } + ) + ) + else: + listing = "\n".join(f" {item}" for item in residue) + print(f"Refusing to complete step 2: leftover files in the project folder.\n{listing}\n -> {remediation}") + return 2 diff --git a/tools/registry/architecture-explorer-graph.json b/tools/registry/architecture-explorer-graph.json index 8c7580362..abe43b02d 100644 --- a/tools/registry/architecture-explorer-graph.json +++ b/tools/registry/architecture-explorer-graph.json @@ -1,6 +1,6 @@ { "$schema": "../../tools/schemas/explorer-graph.schema.json", - "generatedAt": "2026-10-12T16:19:31.904Z", + "generatedAt": "2026-10-15T13:38:24.876Z", "categories": [ { "id": "agent", "key": "agents", "label": "Agent", "color": "#3b82f6", "shape": "round-rectangle", "count": 14 }, { @@ -20,14 +20,14 @@ "shape": "rectangle", "count": 27 }, - { "id": "prompt", "key": "prompts", "label": "Prompt", "color": "#ec4899", "shape": "tag", "count": 26 }, + { "id": "prompt", "key": "prompts", "label": "Prompt", "color": "#ec4899", "shape": "tag", "count": 28 }, { "id": "validator", "key": "validators", "label": "Validator", "color": "#8b5cf6", "shape": "hexagon", - "count": 62 + "count": 63 }, { "id": "workflow", @@ -39,8 +39,8 @@ }, { "id": "mcp", "key": "mcp_servers", "label": "MCP Server", "color": "#f43f5e", "shape": "barrel", "count": 3 } ], - "nodeCount": 185, - "edgeCount": 730, + "nodeCount": 188, + "edgeCount": 738, "nodes": [ { "id": "agent:01-orchestrator", @@ -96,7 +96,7 @@ "id": "agent:03-architect", "category": "agent", "label": "03-Architect", - "description": "Expert Architect providing guidance using Azure Well-Architected Framework principles and Microsoft best practices. Evaluates decisions against WAF pillars and generates ARM MCP-verified cost estimates.", + "description": "Expert Architect providing guidance using Azure Well-Architected Framework principles and Microsoft best practices. Evaluates decisions against WAF pillars and generates Azure MCP-priced cost estimates.", "path": ".github/agents/03-architect.agent.md", "links": { "source": "https://github.com/jonathan-vella/apex/blob/main/.github/agents/03-architect.agent.md" }, "meta": { @@ -414,7 +414,7 @@ "id": "subagent:cost-estimate-subagent", "category": "subagent", "label": "cost-estimate-subagent", - "description": "Azure cost estimation subagent. Uses Azure Resource Manager MCP retail pricing and cost data, then returns a structured cost breakdown through a file-based contract.", + "description": "Azure cost estimation subagent. Uses Azure MCP pricing (pricing_get) retail data only, then returns a structured cost breakdown through a file-based contract.", "path": ".github/agents/_subagents/cost-estimate-subagent.agent.md", "links": { "source": "https://github.com/jonathan-vella/apex/blob/main/.github/agents/_subagents/cost-estimate-subagent.agent.md" @@ -570,7 +570,7 @@ "id": "skill:apex-azure-compute", "category": "skill", "label": "apex-azure-compute", - "description": "**ANALYSIS SKILL** — Recommend Azure VM sizes and VMSS for workload, performance, and budget. Uses public docs and the Azure Retail Prices API. WHEN: \"recommend VM size\", \"choose Azure VM\", \"GPU VM\", \"compare VM sizes\", \"VMSS vs VM\", \"autoscale VMs\". DO NOT USE FOR: provisioning VMs (apex-azure-prepare), VM pricing for budgets (ARM MCP).", + "description": "**ANALYSIS SKILL** — Recommend Azure VM sizes and VMSS for workload, performance, and budget. Uses public docs and the Azure Retail Prices API. WHEN: \"recommend VM size\", \"choose Azure VM\", \"GPU VM\", \"compare VM sizes\", \"VMSS vs VM\", \"autoscale VMs\". DO NOT USE FOR: provisioning VMs (apex-azure-prepare), VM pricing for budgets (Azure MCP pricing).", "path": ".github/skills/apex-azure-compute/SKILL.md", "links": { "source": "https://github.com/jonathan-vella/apex/blob/main/.github/skills/apex-azure-compute/SKILL.md" @@ -1445,6 +1445,17 @@ }, "meta": {} }, + { + "id": "prompt:eventdesk-orchestrator", + "category": "prompt", + "label": "eventdesk-orchestrator", + "description": "Start the Eventdesk project with a fixed intake brief; the only question asked is Bicep or Terraform.", + "path": "tools/apex-prompts/utility-prompts/eventdesk-orchestrator.prompt.md", + "links": { + "source": "https://github.com/jonathan-vella/apex/blob/main/tools/apex-prompts/utility-prompts/eventdesk-orchestrator.prompt.md" + }, + "meta": {} + }, { "id": "prompt:execution-subagent-gpt", "category": "prompt", @@ -1522,6 +1533,17 @@ }, "meta": {} }, + { + "id": "prompt:01-new-project", + "category": "prompt", + "label": "01-new-project", + "description": "Start a new APEX project from a complete intake brief, with owner decisions answered up front.", + "path": "tools/apex-prompts/workflow-prompts/01-new-project.prompt.md", + "links": { + "source": "https://github.com/jonathan-vella/apex/blob/main/tools/apex-prompts/workflow-prompts/01-new-project.prompt.md" + }, + "meta": {} + }, { "id": "prompt:02-requirements", "category": "prompt", @@ -1744,6 +1766,16 @@ "meta": { "command": "node tools/scripts/validate-mcp-config.mjs", "orphan": true }, "parent": "group:validator-orphans" }, + { + "id": "validator:validate-mcp-tool-refs", + "category": "validator", + "label": "validate:mcp-tool-refs", + "description": "node tools/scripts/validate-mcp-tool-refs.mjs", + "path": "package.json", + "links": { "source": "https://github.com/jonathan-vella/apex/blob/main/package.json" }, + "meta": { "command": "node tools/scripts/validate-mcp-tool-refs.mjs", "orphan": true }, + "parent": "group:validator-orphans" + }, { "id": "validator:validate-extension-bloat", "category": "validator", @@ -2261,11 +2293,11 @@ "id": "validator:test-governance-baseline-python", "category": "validator", "label": "test:governance-baseline-python", - "description": "python3 -m pytest .github/skills/apex-azure-governance-discovery/scripts/test_governance_baseline.py -q", + "description": "python3 -m pytest .github/skills/apex-azure-governance-discovery/scripts/test_governance_baseline.py .github/skills/apex-azure-governance-discovery/scripts/test_generate_alz_baseline.py -q", "path": "package.json", "links": { "source": "https://github.com/jonathan-vella/apex/blob/main/package.json" }, "meta": { - "command": "python3 -m pytest .github/skills/apex-azure-governance-discovery/scripts/test_governance_baseline.py -q", + "command": "python3 -m pytest .github/skills/apex-azure-governance-discovery/scripts/test_governance_baseline.py .github/skills/apex-azure-governance-discovery/scripts/test_generate_alz_baseline.py -q", "orphan": true }, "parent": "group:validator-orphans" @@ -2355,7 +2387,7 @@ "description": "HTTP: https://mcp.management.azure.com", "path": ".vscode/mcp.json", "links": { "source": "https://github.com/jonathan-vella/apex/blob/main/.vscode/mcp.json" }, - "meta": { "type": "http" } + "meta": { "type": "http", "orphan": true } }, { "id": "mcp:azure-mcp", @@ -2369,10 +2401,10 @@ { "id": "group:validator-orphans", "category": "validator", - "label": "Validator (unlinked, 56)", - "description": "56 validator nodes with no cross-references in the current graph.", + "label": "Validator (unlinked, 57)", + "description": "57 validator nodes with no cross-references in the current graph.", "isGroup": true, - "meta": { "groupSize": 56 } + "meta": { "groupSize": 57 } }, { "id": "group:workflow-orphans", @@ -3356,6 +3388,12 @@ "target": "prompt:context-audit", "kind": "applies-to" }, + { + "id": "instruction:agent-authoring--applies-to->prompt:eventdesk-orchestrator", + "source": "instruction:agent-authoring", + "target": "prompt:eventdesk-orchestrator", + "kind": "applies-to" + }, { "id": "instruction:agent-authoring--applies-to->prompt:execution-subagent-gpt", "source": "instruction:agent-authoring", @@ -3398,6 +3436,12 @@ "target": "prompt:00-resume-workflow", "kind": "applies-to" }, + { + "id": "instruction:agent-authoring--applies-to->prompt:01-new-project", + "source": "instruction:agent-authoring", + "target": "prompt:01-new-project", + "kind": "applies-to" + }, { "id": "instruction:agent-authoring--applies-to->prompt:02-requirements", "source": "instruction:agent-authoring", @@ -5474,6 +5518,12 @@ "target": "prompt:context-audit", "kind": "applies-to" }, + { + "id": "instruction:no-interactive-shell--applies-to->prompt:eventdesk-orchestrator", + "source": "instruction:no-interactive-shell", + "target": "prompt:eventdesk-orchestrator", + "kind": "applies-to" + }, { "id": "instruction:no-interactive-shell--applies-to->prompt:execution-subagent-gpt", "source": "instruction:no-interactive-shell", @@ -5516,6 +5566,12 @@ "target": "prompt:00-resume-workflow", "kind": "applies-to" }, + { + "id": "instruction:no-interactive-shell--applies-to->prompt:01-new-project", + "source": "instruction:no-interactive-shell", + "target": "prompt:01-new-project", + "kind": "applies-to" + }, { "id": "instruction:no-interactive-shell--applies-to->prompt:02-requirements", "source": "instruction:no-interactive-shell", @@ -5636,6 +5692,12 @@ "target": "prompt:context-audit", "kind": "applies-to" }, + { + "id": "instruction:prompt--applies-to->prompt:eventdesk-orchestrator", + "source": "instruction:prompt", + "target": "prompt:eventdesk-orchestrator", + "kind": "applies-to" + }, { "id": "instruction:prompt--applies-to->prompt:execution-subagent-gpt", "source": "instruction:prompt", @@ -5678,6 +5740,12 @@ "target": "prompt:00-resume-workflow", "kind": "applies-to" }, + { + "id": "instruction:prompt--applies-to->prompt:01-new-project", + "source": "instruction:prompt", + "target": "prompt:01-new-project", + "kind": "applies-to" + }, { "id": "instruction:prompt--applies-to->prompt:02-requirements", "source": "instruction:prompt", @@ -5918,6 +5986,12 @@ "target": "prompt:context-audit", "kind": "applies-to" }, + { + "id": "instruction:vendor-prompting--applies-to->prompt:eventdesk-orchestrator", + "source": "instruction:vendor-prompting", + "target": "prompt:eventdesk-orchestrator", + "kind": "applies-to" + }, { "id": "instruction:vendor-prompting--applies-to->prompt:execution-subagent-gpt", "source": "instruction:vendor-prompting", @@ -5960,6 +6034,12 @@ "target": "prompt:00-resume-workflow", "kind": "applies-to" }, + { + "id": "instruction:vendor-prompting--applies-to->prompt:01-new-project", + "source": "instruction:vendor-prompting", + "target": "prompt:01-new-project", + "kind": "applies-to" + }, { "id": "instruction:vendor-prompting--applies-to->prompt:02-requirements", "source": "instruction:vendor-prompting", @@ -6213,9 +6293,9 @@ "kind": "uses-mcp" }, { - "id": "subagent:cost-estimate-subagent--uses-mcp->mcp:azure-resource-manager-mcp", + "id": "subagent:cost-estimate-subagent--uses-mcp->mcp:azure-mcp", "source": "subagent:cost-estimate-subagent", - "target": "mcp:azure-resource-manager-mcp", + "target": "mcp:azure-mcp", "kind": "uses-mcp" }, { diff --git a/tools/registry/azure-mcp-tools.json b/tools/registry/azure-mcp-tools.json new file mode 100644 index 000000000..b702048e0 --- /dev/null +++ b/tools/registry/azure-mcp-tools.json @@ -0,0 +1,5117 @@ +{ + "schemaVersion": 1, + "serverMode": "namespace", + "serverVersion": "3.0.0", + "tools": { + "acr": { + "commands": { + "acr_registry_list": { + "parameters": { + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "acr_registry_repository_list": { + "parameters": { + "registry": false, + "resource-group": false, + "subscription": false, + "tenant": false + } + } + } + }, + "advisor": { + "commands": { + "advisor_metadata_get": { + "parameters": { + "language": false, + "recommendation-type-id": true + } + }, + "advisor_metadata_list": { + "parameters": { + "category": false, + "impact": false, + "language": false, + "resource-type": false, + "retirement-date": false, + "sub-category": false, + "tracking-id": false + } + }, + "advisor_recommendation_list": { + "parameters": { + "category": false, + "impact": false, + "recommendation-type-id": false, + "resource": false, + "resource-group": false, + "resource-type": false, + "retirement-date": false, + "search": false, + "status": false, + "sub-category": false, + "subscription": false, + "tenant": false, + "top": false, + "tracking-ids": false + } + }, + "advisor_recommendation_summary": { + "parameters": { + "category": false, + "group-by": false, + "impact": false, + "recommendation-type-id": false, + "resource": false, + "resource-group": false, + "resource-type": false, + "retirement-date": false, + "search": false, + "sub-category": false, + "subscription": false, + "tenant": false, + "top": false + } + }, + "advisor_recommendation_update": { + "parameters": { + "postponed-until-date-time": false, + "recommendation-dismiss-reason": false, + "recommendation-id": true, + "recommendation-status": true, + "subscription": false, + "tenant": false + } + }, + "advisor_remediation_get": { + "parameters": { + "recommendation-type-id": true + } + } + } + }, + "aks": { + "commands": { + "aks_cluster_get": { + "parameters": { + "cluster": false, + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "aks_nodepool_get": { + "parameters": { + "cluster": true, + "nodepool": false, + "resource-group": true, + "subscription": false, + "tenant": false + } + } + } + }, + "appconfig": { + "commands": { + "appconfig_account_list": { + "parameters": { + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "appconfig_kv_delete": { + "parameters": { + "account": true, + "key": true, + "label": false, + "subscription": false, + "tenant": false + } + }, + "appconfig_kv_get": { + "parameters": { + "account": true, + "key": false, + "key-filter": false, + "label": false, + "label-filter": false, + "subscription": false, + "tenant": false + } + }, + "appconfig_kv_lock_set": { + "parameters": { + "account": true, + "key": true, + "label": false, + "lock": false, + "subscription": false, + "tenant": false + } + }, + "appconfig_kv_set": { + "parameters": { + "account": true, + "content-type": false, + "key": true, + "label": false, + "subscription": false, + "tags": false, + "tenant": false, + "value": true + } + } + } + }, + "applens": { + "commands": { + "applens_resource_diagnose": { + "parameters": { + "question": true, + "resource": true, + "resource-group": false, + "resource-type": false, + "subscription": false, + "tenant": false + } + } + } + }, + "applicationinsights": { + "commands": { + "applicationinsights_recommendation_list": { + "parameters": { + "resource-group": false, + "subscription": false, + "tenant": false + } + } + } + }, + "appservice": { + "commands": { + "appservice_database_add": { + "parameters": { + "app": true, + "connection-string": false, + "database": true, + "database-server": true, + "database-type": true, + "resource-group": true, + "subscription": false, + "tenant": false + } + }, + "appservice_webapp_change-state": { + "parameters": { + "app": true, + "resource-group": true, + "soft-restart": false, + "state-change": true, + "subscription": false, + "tenant": false, + "wait-for-completion": false + } + }, + "appservice_webapp_deployment_get": { + "parameters": { + "app": true, + "deployment-id": false, + "resource-group": true, + "subscription": false, + "tenant": false + } + }, + "appservice_webapp_diagnostic_diagnose": { + "parameters": { + "app": true, + "detector-id": true, + "end-time": false, + "interval": false, + "resource-group": true, + "start-time": false, + "subscription": false, + "tenant": false + } + }, + "appservice_webapp_diagnostic_list": { + "parameters": { + "app": true, + "resource-group": true, + "subscription": false, + "tenant": false + } + }, + "appservice_webapp_get": { + "parameters": { + "app": false, + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "appservice_webapp_settings_get-appsettings": { + "parameters": { + "app": true, + "resource-group": true, + "subscription": false, + "tenant": false + } + }, + "appservice_webapp_settings_update-appsettings": { + "parameters": { + "app": true, + "resource-group": true, + "setting-name": true, + "setting-update-type": true, + "setting-value": false, + "subscription": false, + "tenant": false + } + } + } + }, + "arm": { + "commands": { + "cancel_deployment": { + "parameters": { + "request": true + } + }, + "create_deployment": { + "parameters": { + "request": true + } + }, + "create_or_update_resource": { + "parameters": { + "request": true + } + }, + "create_or_update_resource_group": { + "parameters": { + "request": true + } + }, + "execute_query": { + "parameters": { + "options": false, + "query": true + } + }, + "generate_query": { + "parameters": { + "prompt": true + } + }, + "get_async_operation_status": { + "parameters": { + "locationHeaderUrl": true + } + }, + "get_deployment_status": { + "parameters": { + "request": true + } + }, + "get_pricesheet_status": { + "parameters": { + "operationStatusUrl": true + } + }, + "get_resource_type_schema": { + "parameters": { + "apiVersion": true, + "resourceType": true + } + }, + "get_retail_prices": { + "parameters": { + "armRegionName": false, + "armSkuName": false, + "currencyCode": false, + "meterName": false, + "priceType": false, + "serviceName": false + } + }, + "list_resource_and_action_types": { + "parameters": {} + }, + "query_aks_costs": { + "parameters": { + "filterDimension": false, + "filterValues": false, + "from": false, + "granularity": false, + "groupBy": false, + "metric": false, + "scope": true, + "sortBy": false, + "sortDirection": false, + "timeframe": false, + "to": false, + "top": false + } + }, + "query_costs": { + "parameters": { + "filterDimension": false, + "filterValues": false, + "from": false, + "granularity": false, + "groupBy": false, + "metric": false, + "scope": true, + "sortBy": false, + "sortDirection": false, + "timeframe": false, + "to": false, + "top": false + } + }, + "start_pricesheet_download": { + "parameters": { + "agreementType": true, + "billingPeriod": false, + "scope": true + } + }, + "validate_query": { + "parameters": { + "query": true + } + }, + "whatif_deployment": { + "parameters": { + "request": true + } + } + } + }, + "azd": { + "commands": { + "error_troubleshooting": { + "parameters": {} + }, + "provision_common_error": { + "parameters": {} + }, + "validate_azure_yaml": { + "parameters": { + "path": true + } + } + } + }, + "azurebackup": { + "commands": { + "azurebackup_backup_status": { + "parameters": { + "datasource-id": true, + "location": true, + "subscription": false, + "tenant": false + } + }, + "azurebackup_container_get": { + "parameters": { + "container": false, + "resource-group": true, + "storage-account": false, + "subscription": false, + "tenant": false, + "vault": true + } + }, + "azurebackup_container_list-available": { + "parameters": { + "filter": false, + "resource-group": true, + "storage-account": false, + "subscription": false, + "tenant": false, + "vault": true + } + }, + "azurebackup_container_refresh": { + "parameters": { + "backup-management-type": false, + "resource-group": true, + "subscription": false, + "tenant": false, + "vault": true + } + }, + "azurebackup_container_register": { + "parameters": { + "acquire-lock": false, + "resource-group": true, + "storage-account": true, + "subscription": false, + "tenant": false, + "vault": true + } + }, + "azurebackup_disasterrecovery_enable-crr": { + "parameters": { + "resource-group": true, + "subscription": false, + "tenant": false, + "vault": true, + "vault-type": false + } + }, + "azurebackup_governance_find-unprotected": { + "parameters": { + "resource-group": false, + "resource-type-filter": false, + "subscription": false, + "tag-filter": false, + "tenant": false + } + }, + "azurebackup_governance_immutability": { + "parameters": { + "immutability-duration-days": false, + "immutability-state": true, + "immutability-type": true, + "resource-group": true, + "subscription": false, + "tenant": false, + "vault": true, + "vault-type": false + } + }, + "azurebackup_governance_soft-delete": { + "parameters": { + "resource-group": true, + "soft-delete": true, + "soft-delete-retention-days": true, + "subscription": false, + "tenant": false, + "vault": true, + "vault-type": false + } + }, + "azurebackup_job_get": { + "parameters": { + "job": false, + "resource-group": true, + "subscription": false, + "tenant": false, + "vault": true, + "vault-type": false + } + }, + "azurebackup_policy_create": { + "parameters": { + "archive-tier-after-days": false, + "archive-tier-mode": false, + "backup-mode": false, + "daily-retention-days": false, + "differential-retention-days": false, + "differential-schedule-days-of-week": false, + "enable-snapshot-backup": false, + "enable-vault-tier-copy": false, + "full-schedule-days-of-week": false, + "full-schedule-frequency": false, + "hourly-interval-hours": false, + "hourly-window-duration-hours": false, + "hourly-window-start-time": false, + "incremental-retention-days": false, + "incremental-schedule-days-of-week": false, + "instant-rp-resource-group": false, + "instant-rp-retention-days": false, + "is-compression": false, + "is-sql-compression": false, + "log-frequency-minutes": false, + "log-retention-days": false, + "monthly-retention-days-of-month": false, + "monthly-retention-days-of-week": false, + "monthly-retention-months": false, + "monthly-retention-week-of-month": false, + "pitr-retention-days": false, + "policy": true, + "policy-sub-type": false, + "policy-tags": false, + "resource-group": true, + "schedule-days-of-week": false, + "schedule-frequency": false, + "schedule-times": false, + "smart-tier": false, + "snapshot-consistency": false, + "snapshot-instant-rp-resource-group": false, + "snapshot-instant-rp-retention-days": false, + "subscription": false, + "tenant": false, + "time-zone": false, + "vault": true, + "vault-tier-copy-after-days": false, + "vault-type": false, + "weekly-retention-days-of-week": false, + "weekly-retention-weeks": false, + "workload-type": true, + "yearly-retention-days-of-month": false, + "yearly-retention-days-of-week": false, + "yearly-retention-months": false, + "yearly-retention-week-of-month": false, + "yearly-retention-years": false + } + }, + "azurebackup_policy_get": { + "parameters": { + "policy": false, + "resource-group": true, + "subscription": false, + "tenant": false, + "vault": true, + "vault-type": false + } + }, + "azurebackup_policy_update": { + "parameters": { + "daily-retention-days": false, + "monthly-retention-days-of-month": false, + "monthly-retention-days-of-week": false, + "monthly-retention-months": false, + "monthly-retention-week-of-month": false, + "policy": true, + "resource-group": true, + "schedule-days-of-week": false, + "schedule-frequency": false, + "schedule-time": false, + "schedule-times": false, + "subscription": false, + "tenant": false, + "time-zone": false, + "vault": true, + "vault-type": false, + "weekly-retention-days-of-week": false, + "weekly-retention-weeks": false, + "yearly-retention-days-of-month": false, + "yearly-retention-days-of-week": false, + "yearly-retention-months": false, + "yearly-retention-week-of-month": false, + "yearly-retention-years": false + } + }, + "azurebackup_protectableitem_inquire": { + "parameters": { + "container": false, + "resource-group": true, + "storage-account": false, + "subscription": false, + "tenant": false, + "vault": true + } + }, + "azurebackup_protectableitem_list": { + "parameters": { + "resource-group": true, + "subscription": false, + "tenant": false, + "vault": true, + "vault-type": false, + "workload-type": false + } + }, + "azurebackup_protecteditem_get": { + "parameters": { + "container": false, + "protected-item": false, + "resource-group": true, + "subscription": false, + "tenant": false, + "vault": true, + "vault-type": false + } + }, + "azurebackup_protecteditem_protect": { + "parameters": { + "aks-include-cluster-scope-resources": false, + "aks-label-selectors": false, + "aks-snapshot-resource-group": false, + "container": false, + "datasource-id": true, + "datasource-type": false, + "disk-list-setting": false, + "disks-list": false, + "exclude-all-data-disks": false, + "policy": true, + "resource-group": true, + "subscription": false, + "tenant": false, + "vault": true, + "vault-type": false + } + }, + "azurebackup_protecteditem_undelete": { + "parameters": { + "container": false, + "datasource-id": true, + "resource-group": true, + "subscription": false, + "tenant": false, + "vault": true, + "vault-type": false + } + }, + "azurebackup_protecteditem_update-protection": { + "parameters": { + "container": false, + "datasource-id": true, + "disk-list-setting": false, + "disks-list": false, + "exclude-all-data-disks": false, + "policy": false, + "resource-group": true, + "subscription": false, + "tenant": false, + "vault": true, + "vault-type": false + } + }, + "azurebackup_recoverypoint_get": { + "parameters": { + "container": false, + "protected-item": true, + "recovery-point": false, + "resource-group": true, + "subscription": false, + "tenant": false, + "vault": true, + "vault-type": false + } + }, + "azurebackup_resourceguard_create": { + "parameters": { + "excluded-operations": false, + "location": true, + "resource-group": true, + "resource-guard": true, + "subscription": false, + "tags": false, + "tenant": false + } + }, + "azurebackup_resourceguard_delete": { + "parameters": { + "resource-group": true, + "resource-guard": true, + "subscription": false, + "tenant": false + } + }, + "azurebackup_resourceguard_get": { + "parameters": { + "resource-group": false, + "resource-guard": false, + "subscription": false, + "tenant": false + } + }, + "azurebackup_security_configure-encryption": { + "parameters": { + "identity-type": true, + "key-name": true, + "key-vault-uri": true, + "key-version": false, + "resource-group": true, + "subscription": false, + "tenant": false, + "user-assigned-identity-id": false, + "vault": true, + "vault-type": false + } + }, + "azurebackup_security_disable-mua": { + "parameters": { + "resource-group": true, + "subscription": false, + "tenant": false, + "vault": true, + "vault-type": false + } + }, + "azurebackup_security_enable-mua": { + "parameters": { + "resource-group": true, + "resource-guard-id": false, + "subscription": false, + "tenant": false, + "vault": true, + "vault-type": false + } + }, + "azurebackup_vault_create": { + "parameters": { + "enable-public-network-access": false, + "location": true, + "resource-group": true, + "sku": false, + "storage-type": false, + "subscription": false, + "tenant": false, + "vault": true, + "vault-type": false + } + }, + "azurebackup_vault_get": { + "parameters": { + "expand": false, + "resource-group": false, + "subscription": false, + "tenant": false, + "vault": false, + "vault-type": false + } + }, + "azurebackup_vault_privateendpoint_approve-reject": { + "parameters": { + "action": true, + "description": false, + "private-endpoint-name": true, + "resource-group": true, + "subscription": false, + "tenant": false, + "vault": true, + "vault-type": false + } + }, + "azurebackup_vault_privateendpoint_create": { + "parameters": { + "auto-approve": false, + "group-id": false, + "location": false, + "private-dns-zone-group-name": false, + "private-dns-zone-ids": false, + "private-endpoint-name": true, + "resource-group": true, + "subscription": false, + "tenant": false, + "vault": true, + "vault-type": false, + "vnet-subnet-id": true + } + }, + "azurebackup_vault_privateendpoint_delete": { + "parameters": { + "private-endpoint-name": true, + "resource-group": true, + "subscription": false, + "tenant": false, + "vault": true, + "vault-type": false + } + }, + "azurebackup_vault_privateendpoint_get": { + "parameters": { + "private-endpoint-name": false, + "resource-group": true, + "subscription": false, + "tenant": false, + "vault": true, + "vault-type": false + } + }, + "azurebackup_vault_update": { + "parameters": { + "identity-type": false, + "immutability-state": false, + "public-network-access": false, + "redundancy": false, + "resource-group": true, + "soft-delete": false, + "soft-delete-retention-days": false, + "subscription": false, + "tags": false, + "tenant": false, + "user-assigned-identity": false, + "vault": true, + "vault-type": false + } + } + } + }, + "azuremigrate": { + "commands": { + "azuremigrate_platformlandingzone_getguidance": { + "parameters": { + "list-policies": false, + "policy-name": false, + "scenario": true + } + }, + "azuremigrate_platformlandingzone_request": { + "parameters": { + "action": true, + "connectivity-subscription-id": false, + "environment-name": false, + "firewall-type": false, + "identity-subscription-id": false, + "location": false, + "management-subscription-id": false, + "migrate-project-name": true, + "network-architecture": false, + "organization-name": false, + "region-type": false, + "regions": false, + "resource-group": true, + "subscription": false, + "tenant": false, + "version-control-system": false + } + } + } + }, + "azureterraform": { + "commands": { + "azureterraform_avm_get": { + "parameters": { + "module-name": true, + "module-version": true + } + }, + "azureterraform_avm_list": { + "parameters": {} + }, + "azureterraform_avm_versions": { + "parameters": { + "module-name": true + } + }, + "azureterraform_azapi_get": { + "parameters": { + "api-version": false, + "resource-type": true + } + }, + "azureterraform_aztfexport_query": { + "parameters": { + "continue-on-error": false, + "include-role-assignment": false, + "name-pattern": false, + "output-folder": false, + "parallelism": false, + "provider": false, + "query": true + } + }, + "azureterraform_aztfexport_resource": { + "parameters": { + "continue-on-error": false, + "include-role-assignment": false, + "output-folder": false, + "parallelism": false, + "provider": false, + "resource-id": true, + "terraform-resource-name": false + } + }, + "azureterraform_aztfexport_resourcegroup": { + "parameters": { + "continue-on-error": false, + "include-role-assignment": false, + "name-pattern": false, + "output-folder": false, + "parallelism": false, + "provider": false, + "resource-group": true + } + }, + "azureterraform_azurerm_get": { + "parameters": { + "argument": false, + "attribute": false, + "doc-type": false, + "resource-type": true + } + }, + "azureterraform_conftest_plan": { + "parameters": { + "custom-policies": false, + "plan-folder": true, + "policy-set": false, + "severity-filter": false + } + }, + "azureterraform_conftest_workspace": { + "parameters": { + "custom-policies": false, + "policy-set": false, + "severity-filter": false, + "workspace-folder": true + } + } + } + }, + "azureterraformbestpractices": { + "commands": { + "azureterraformbestpractices_get": { + "parameters": {} + } + } + }, + "bicepschema": { + "commands": { + "bicepschema_get": { + "parameters": { + "resource-type": true + } + } + } + }, + "cloudarchitect": { + "commands": { + "cloudarchitect_design": { + "parameters": { + "answer": false, + "confidence-score": false, + "next-question-needed": false, + "question": false, + "question-number": false, + "state": false, + "total-questions": false + } + } + } + }, + "communication": { + "commands": { + "communication_email_send": { + "parameters": { + "bcc": false, + "cc": false, + "endpoint": true, + "from": true, + "is-html": false, + "message": true, + "reply-to": false, + "subject": true, + "tenant": false, + "to": true + } + }, + "communication_sms_send": { + "parameters": { + "enable-delivery-report": false, + "endpoint": true, + "from": true, + "message": true, + "tag": false, + "tenant": false, + "to": true + } + } + } + }, + "compute": { + "commands": { + "compute_disk_create": { + "parameters": { + "disk-access": false, + "disk-encryption-set": false, + "disk-iops-read-write": false, + "disk-mbps-read-write": false, + "disk-name": true, + "enable-bursting": false, + "encryption-type": false, + "gallery-image-reference": false, + "gallery-image-reference-lun": false, + "hyper-v-generation": false, + "location": false, + "max-shares": false, + "network-access-policy": false, + "os-type": false, + "resource-group": true, + "security-type": false, + "size-gb": false, + "sku": false, + "source": false, + "subscription": false, + "tags": false, + "tenant": false, + "tier": false, + "upload-size-bytes": false, + "upload-type": false, + "zone": false + } + }, + "compute_disk_delete": { + "parameters": { + "disk-name": true, + "resource-group": true, + "subscription": false, + "tenant": false + } + }, + "compute_disk_get": { + "parameters": { + "disk-name": false, + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "compute_disk_update": { + "parameters": { + "disk-access": false, + "disk-encryption-set": false, + "disk-iops-read-write": false, + "disk-mbps-read-write": false, + "disk-name": true, + "enable-bursting": false, + "encryption-type": false, + "max-shares": false, + "network-access-policy": false, + "resource-group": false, + "size-gb": false, + "sku": false, + "subscription": false, + "tags": false, + "tenant": false, + "tier": false + } + }, + "compute_vm_create": { + "parameters": { + "admin-password": false, + "admin-username": true, + "image": true, + "location": true, + "network-security-group": false, + "no-public-ip": false, + "os-disk-size-gb": false, + "os-disk-type": false, + "os-type": false, + "public-ip-address": false, + "resource-group": true, + "source-address-prefix": false, + "ssh-public-key": false, + "subnet": false, + "subscription": false, + "tenant": false, + "virtual-network": false, + "vm-name": true, + "vm-size": false, + "zone": false + } + }, + "compute_vm_delete": { + "parameters": { + "force-deletion": false, + "resource-group": true, + "subscription": false, + "tenant": false, + "vm-name": true + } + }, + "compute_vm_get": { + "parameters": { + "instance-view": false, + "resource-group": false, + "subscription": false, + "tenant": false, + "vm-name": false + } + }, + "compute_vm_power-state": { + "parameters": { + "no-wait": false, + "power-action": true, + "resource-group": true, + "skip-shutdown": false, + "subscription": false, + "tenant": false, + "vm-name": true + } + }, + "compute_vm_update": { + "parameters": { + "boot-diagnostics": false, + "license-type": false, + "resource-group": true, + "subscription": false, + "tags": false, + "tenant": false, + "user-data": false, + "vm-name": true, + "vm-size": false + } + }, + "compute_vmss_create": { + "parameters": { + "admin-password": false, + "admin-username": true, + "image": true, + "instance-count": false, + "location": true, + "os-disk-size-gb": false, + "os-disk-type": false, + "os-type": false, + "resource-group": true, + "ssh-public-key": false, + "subnet": false, + "subscription": false, + "tenant": false, + "upgrade-policy": false, + "virtual-network": false, + "vm-size": false, + "vmss-name": true, + "zone": false + } + }, + "compute_vmss_delete": { + "parameters": { + "force-deletion": false, + "resource-group": true, + "subscription": false, + "tenant": false, + "vmss-name": true + } + }, + "compute_vmss_get": { + "parameters": { + "instance-id": false, + "resource-group": false, + "subscription": false, + "tenant": false, + "vmss-name": false + } + }, + "compute_vmss_update": { + "parameters": { + "capacity": false, + "enable-auto-os-upgrade": false, + "overprovision": false, + "resource-group": true, + "scale-in-policy": false, + "subscription": false, + "tags": false, + "tenant": false, + "upgrade-policy": false, + "vm-size": false, + "vmss-name": true + } + } + } + }, + "confidentialledger": { + "commands": { + "confidentialledger_entries_append": { + "parameters": { + "collection-id": false, + "content": true, + "ledger": true + } + }, + "confidentialledger_entries_get": { + "parameters": { + "collection-id": false, + "ledger": true, + "transaction-id": true + } + } + } + }, + "containerapps": { + "commands": { + "containerapps_list": { + "parameters": { + "resource-group": false, + "subscription": false, + "tenant": false + } + } + } + }, + "cosmos": { + "commands": { + "cosmos_database_container_item_get": { + "parameters": { + "account": true, + "auth-method": false, + "container": true, + "database": true, + "id": true, + "partition-key": false, + "subscription": false, + "tenant": false + } + }, + "cosmos_database_container_item_list-recent": { + "parameters": { + "account": true, + "auth-method": false, + "container": true, + "count": false, + "database": true, + "subscription": false, + "tenant": false + } + }, + "cosmos_database_container_item_query": { + "parameters": { + "account": true, + "auth-method": false, + "container": true, + "database": true, + "query": false, + "subscription": false, + "tenant": false + } + }, + "cosmos_database_container_item_text-search": { + "parameters": { + "account": true, + "auth-method": false, + "container": true, + "count": false, + "database": true, + "properties-to-select": false, + "search-phrase": true, + "search-property": true, + "subscription": false, + "tenant": false + } + }, + "cosmos_database_container_item_vector-search": { + "parameters": { + "account": true, + "auth-method": false, + "container": true, + "count": false, + "database": true, + "embedding-deployment": true, + "embedding-dimensions": false, + "openai-endpoint": true, + "properties-to-select": false, + "search-text": true, + "subscription": false, + "tenant": false, + "vector-property": true + } + }, + "cosmos_database_container_schema_infer": { + "parameters": { + "account": true, + "auth-method": false, + "container": true, + "database": true, + "sample-size": false, + "subscription": false, + "tenant": false + } + }, + "cosmos_list": { + "parameters": { + "account": false, + "auth-method": false, + "database": false, + "resource-group": false, + "subscription": false, + "tenant": false + } + } + } + }, + "datadog": { + "commands": { + "datadog_monitoredresources_list": { + "parameters": { + "datadog-resource": true, + "resource-group": true, + "subscription": false + } + } + } + }, + "deploy": { + "commands": { + "deploy_app_logs_get": { + "parameters": { + "azd-env-name": true, + "limit": false, + "subscription": false, + "workspace-folder": true + } + }, + "deploy_architecture_diagram_generate": { + "parameters": { + "projectName": false, + "services": true, + "workspaceFolder": true + } + }, + "deploy_iac_rules_get": { + "parameters": { + "deployment-tool": true, + "iac-type": false, + "resource-types": false + } + }, + "deploy_pipeline_guidance_get": { + "parameters": { + "deploy-option": false, + "is-azd-project": false, + "pipeline-platform": false + } + }, + "deploy_plan_get": { + "parameters": { + "deploy-option": false, + "iac-options": false, + "project-name": true, + "provisioning-tool": false, + "resource-group": false, + "source-type": false, + "subscription": false, + "target-app-service": false + } + } + } + }, + "deviceregistry": { + "commands": { + "deviceregistry_namespace_list": { + "parameters": { + "resource-group": false, + "subscription": false + } + } + } + }, + "documentation": { + "commands": { + "microsoft_code_sample_search": { + "parameters": { + "language": false, + "query": true + } + }, + "microsoft_docs_fetch": { + "parameters": { + "url": true + } + }, + "microsoft_docs_search": { + "parameters": { + "query": false + } + } + } + }, + "eventgrid": { + "commands": { + "eventgrid_events_publish": { + "parameters": { + "data": true, + "resource-group": false, + "schema": false, + "subscription": false, + "tenant": false, + "topic": true + } + }, + "eventgrid_subscription_list": { + "parameters": { + "location": false, + "resource-group": false, + "subscription": false, + "tenant": false, + "topic": false + } + }, + "eventgrid_topic_list": { + "parameters": { + "resource-group": false, + "subscription": false, + "tenant": false + } + } + } + }, + "eventhubs": { + "commands": { + "eventhubs_eventhub_consumergroup_delete": { + "parameters": { + "consumer-group": true, + "eventhub": true, + "namespace": true, + "resource-group": true, + "subscription": false, + "tenant": false + } + }, + "eventhubs_eventhub_consumergroup_get": { + "parameters": { + "consumer-group": false, + "eventhub": true, + "namespace": true, + "resource-group": true, + "subscription": false, + "tenant": false + } + }, + "eventhubs_eventhub_consumergroup_update": { + "parameters": { + "consumer-group": true, + "eventhub": true, + "namespace": true, + "resource-group": true, + "subscription": false, + "tenant": false, + "user-metadata": false + } + }, + "eventhubs_eventhub_delete": { + "parameters": { + "eventhub": true, + "namespace": true, + "resource-group": true, + "subscription": false, + "tenant": false + } + }, + "eventhubs_eventhub_get": { + "parameters": { + "eventhub": false, + "namespace": true, + "resource-group": true, + "subscription": false, + "tenant": false + } + }, + "eventhubs_eventhub_update": { + "parameters": { + "eventhub": true, + "message-retention-in-hours": false, + "namespace": true, + "partition-count": false, + "resource-group": true, + "status": false, + "subscription": false, + "tenant": false + } + }, + "eventhubs_namespace_delete": { + "parameters": { + "namespace": true, + "resource-group": true, + "subscription": false, + "tenant": false + } + }, + "eventhubs_namespace_get": { + "parameters": { + "namespace": false, + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "eventhubs_namespace_update": { + "parameters": { + "disable-local-auth": false, + "is-auto-inflate-enabled": false, + "kafka-enabled": false, + "location": false, + "maximum-throughput-units": false, + "namespace": true, + "resource-group": true, + "sku-capacity": false, + "sku-name": false, + "sku-tier": false, + "subscription": false, + "tags": false, + "tenant": false, + "zone-redundant": false + } + } + } + }, + "extension_azqr": { + "parameters": { + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "extension_cli_generate": { + "parameters": { + "cli-type": true, + "intent": true + } + }, + "extension_cli_install": { + "parameters": { + "cli-type": true + } + }, + "fileshares": { + "commands": { + "fileshares_fileshare_check-name-availability": { + "parameters": { + "location": true, + "name": true, + "subscription": false, + "tenant": false + } + }, + "fileshares_fileshare_create": { + "parameters": { + "allowed-subnets": false, + "location": true, + "media-tier": false, + "mount-name": false, + "name": true, + "nfs-encryption-in-transit": false, + "nfs-root-squash": false, + "protocol": false, + "provisioned-io-per-sec": false, + "provisioned-storage-in-gib": false, + "provisioned-throughput-mib-per-sec": false, + "public-network-access": false, + "redundancy": false, + "resource-group": true, + "subscription": false, + "tags": false, + "tenant": false + } + }, + "fileshares_fileshare_delete": { + "parameters": { + "name": true, + "resource-group": true, + "subscription": false, + "tenant": false + } + }, + "fileshares_fileshare_get": { + "parameters": { + "name": false, + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "fileshares_fileshare_peconnection_get": { + "parameters": { + "connection-name": false, + "file-share-name": true, + "resource-group": true, + "subscription": false, + "tenant": false + } + }, + "fileshares_fileshare_peconnection_update": { + "parameters": { + "connection-name": true, + "description": false, + "file-share-name": true, + "resource-group": true, + "status": true, + "subscription": false, + "tenant": false + } + }, + "fileshares_fileshare_snapshot_create": { + "parameters": { + "file-share-name": true, + "metadata": false, + "resource-group": true, + "snapshot-name": true, + "subscription": false, + "tenant": false + } + }, + "fileshares_fileshare_snapshot_delete": { + "parameters": { + "file-share-name": true, + "resource-group": true, + "snapshot-name": true, + "subscription": false, + "tenant": false + } + }, + "fileshares_fileshare_snapshot_get": { + "parameters": { + "file-share-name": true, + "resource-group": true, + "snapshot-name": false, + "subscription": false, + "tenant": false + } + }, + "fileshares_fileshare_snapshot_update": { + "parameters": { + "file-share-name": true, + "metadata": false, + "resource-group": true, + "snapshot-name": true, + "subscription": false, + "tenant": false + } + }, + "fileshares_fileshare_update": { + "parameters": { + "allowed-subnets": false, + "name": true, + "nfs-encryption-in-transit": false, + "nfs-root-squash": false, + "provisioned-io-per-sec": false, + "provisioned-storage-in-gib": false, + "provisioned-throughput-mib-per-sec": false, + "public-network-access": false, + "resource-group": true, + "subscription": false, + "tags": false, + "tenant": false + } + }, + "fileshares_limits": { + "parameters": { + "location": true, + "subscription": false, + "tenant": false + } + }, + "fileshares_rec": { + "parameters": { + "location": true, + "provisioned-storage-in-gib": true, + "subscription": false, + "tenant": false + } + }, + "fileshares_usage": { + "parameters": { + "location": true, + "subscription": false, + "tenant": false + } + } + } + }, + "foundry": { + "commands": { + "agent_container_control": { + "parameters": { + "action": true, + "agentName": true, + "agentVersion": false, + "maxReplicas": false, + "minReplicas": false, + "projectEndpoint": true + } + }, + "agent_container_status_get": { + "parameters": { + "agentName": true, + "agentVersion": false, + "projectEndpoint": true + } + }, + "agent_definition_schema_get": { + "parameters": { + "schemaType": false + } + }, + "agent_delete": { + "parameters": { + "agentName": true, + "projectEndpoint": true + } + }, + "agent_get": { + "parameters": { + "agentName": false, + "projectEndpoint": true + } + }, + "agent_insights_get": { + "parameters": { + "after": false, + "agentName": true, + "category": false, + "includeDetails": false, + "limit": false, + "order": false, + "projectEndpoint": true, + "severity": false, + "status": false + } + }, + "agent_invoke": { + "parameters": { + "agentName": true, + "agentVersion": false, + "conversationId": false, + "inputText": true, + "projectEndpoint": true, + "protocol": false, + "sessionId": false, + "stream": false, + "useEndpoint": false + } + }, + "agent_update": { + "parameters": { + "agentDefinition": false, + "agentName": true, + "cloneTargetAgentName": false, + "creationOptions": false, + "isCloneRequest": false, + "modelName": false, + "projectEndpoint": true + } + }, + "continuous_eval_create": { + "parameters": { + "agentName": true, + "deploymentName": false, + "enabled": false, + "evaluatorNames": true, + "intervalHours": false, + "maxHourlyRuns": false, + "maxTraces": false, + "projectEndpoint": true, + "samplingRate": false, + "scenario": false + } + }, + "continuous_eval_delete": { + "parameters": { + "agentName": true, + "configId": true, + "projectEndpoint": true + } + }, + "continuous_eval_get": { + "parameters": { + "agentName": true, + "projectEndpoint": true, + "scenario": false + } + }, + "data_generation_job_cancel": { + "parameters": { + "jobId": true, + "projectEndpoint": true + } + }, + "data_generation_job_create": { + "parameters": { + "agentName": false, + "agentSourceDescription": false, + "agentVersion": false, + "datasetName": false, + "datasetSourceDescription": false, + "datasetVersion": false, + "fileId": false, + "fileSourceDescription": false, + "generationModelDeploymentName": false, + "generationType": false, + "jobName": true, + "maxSamples": false, + "maxTraces": false, + "projectEndpoint": true, + "promptSource": false, + "promptSourceDescription": false, + "questionTypes": false, + "scenario": false, + "traceAgentId": false, + "traceAgentName": false, + "traceAgentVersion": false, + "traceEndTime": false, + "traceStartTime": false, + "tracesSourceDescription": false, + "trainSplit": false + } + }, + "data_generation_job_delete": { + "parameters": { + "jobId": true, + "projectEndpoint": true + } + }, + "data_generation_job_get": { + "parameters": { + "jobId": false, + "projectEndpoint": true + } + }, + "evaluation_agent_batch_eval_create": { + "parameters": { + "agentName": true, + "agentVersion": true, + "deploymentName": false, + "evaluationId": false, + "evaluationName": false, + "evaluatorNames": true, + "evaluatorThresholds": false, + "generateSyntheticData": false, + "generationModelDeploymentName": false, + "generationPrompt": false, + "inputData": false, + "outputDatasetName": false, + "projectEndpoint": true, + "runName": false, + "samplesCount": false, + "systemPrompt": false + } + }, + "evaluation_agent_traces_batch_eval_create": { + "parameters": { + "agentId": false, + "agentName": false, + "agentVersion": false, + "deploymentName": false, + "endTime": false, + "evaluationId": false, + "evaluationLevel": false, + "evaluationName": false, + "evaluatorNames": true, + "evaluatorThresholds": false, + "includeRecentTracePadding": false, + "lookbackHours": false, + "maxTraces": false, + "projectEndpoint": true, + "runName": false, + "startTime": false + } + }, + "evaluation_comparison_create": { + "parameters": { + "insightRequest": true, + "projectEndpoint": true + } + }, + "evaluation_comparison_get": { + "parameters": { + "insightId": false, + "projectEndpoint": true + } + }, + "evaluation_dataset_batch_eval_create": { + "parameters": { + "dataKind": false, + "datasetFileId": false, + "deploymentName": false, + "evaluationId": false, + "evaluationLevel": false, + "evaluationName": false, + "evaluatorNames": true, + "evaluatorThresholds": false, + "includeSampleSchema": false, + "itemSchemaFields": false, + "jsonlContent": false, + "projectEndpoint": true, + "runName": false + } + }, + "evaluation_dataset_create": { + "parameters": { + "connectionName": false, + "datasetContentUri": true, + "datasetName": false, + "datasetType": false, + "datasetVersion": false, + "description": false, + "projectEndpoint": true, + "tags": false + } + }, + "evaluation_dataset_get": { + "parameters": { + "datasetName": false, + "datasetVersion": false, + "projectEndpoint": true + } + }, + "evaluation_dataset_sas_url_get": { + "parameters": { + "datasetName": true, + "datasetVersion": false, + "projectEndpoint": true + } + }, + "evaluation_dataset_versions_get": { + "parameters": { + "datasetName": true, + "projectEndpoint": true + } + }, + "evaluation_get": { + "parameters": { + "evalId": false, + "evalRunId": false, + "isRequestForRuns": false, + "projectEndpoint": true + } + }, + "evaluation_suite_create": { + "parameters": { + "dataKind": false, + "datasetName": false, + "datasetVersion": false, + "deploymentName": false, + "description": false, + "displayName": false, + "evaluationLevel": false, + "evaluatorNames": true, + "evaluatorThresholds": false, + "projectEndpoint": true, + "suiteName": true, + "tags": false, + "targetAgentName": false, + "targetAgentVersion": false + } + }, + "evaluation_suite_delete": { + "parameters": { + "projectEndpoint": true, + "suiteName": true, + "version": false + } + }, + "evaluation_suite_generation_job_cancel": { + "parameters": { + "jobId": true, + "projectEndpoint": true + } + }, + "evaluation_suite_generation_job_create": { + "parameters": { + "agentName": false, + "agentSourceDescription": false, + "agentSourceNames": false, + "category": false, + "dataGenerationType": false, + "datasetName": false, + "datasetSourceDescription": false, + "datasetVersion": false, + "deploymentName": false, + "fileId": false, + "fileSourceDescription": false, + "generationModelDeploymentName": true, + "initializationParameters": false, + "maxSamples": false, + "maxTraces": false, + "projectEndpoint": true, + "promptSource": false, + "promptSourceDescription": false, + "suiteName": true, + "traceAgentId": false, + "traceAgentName": false, + "traceAgentVersion": false, + "traceEndTime": false, + "traceStartTime": false, + "tracesSourceDescription": false + } + }, + "evaluation_suite_generation_job_delete": { + "parameters": { + "jobId": true, + "projectEndpoint": true + } + }, + "evaluation_suite_generation_job_get": { + "parameters": { + "continuationToken": false, + "jobId": false, + "limit": false, + "projectEndpoint": true + } + }, + "evaluation_suite_get": { + "parameters": { + "agentName": false, + "continuationToken": false, + "limit": false, + "listVersions": false, + "projectEndpoint": true, + "suiteName": false, + "version": false + } + }, + "evaluation_suite_run": { + "parameters": { + "evaluationLevel": false, + "evaluationName": false, + "projectEndpoint": true, + "suiteName": true, + "version": false + } + }, + "evaluation_suite_update": { + "parameters": { + "description": false, + "displayName": false, + "projectEndpoint": true, + "suiteName": true, + "tags": false, + "version": true + } + }, + "evaluation_traces_batch_eval_create": { + "parameters": { + "conversationIds": false, + "deploymentName": false, + "evaluationId": false, + "evaluationLevel": false, + "evaluationName": false, + "evaluatorNames": true, + "evaluatorThresholds": false, + "projectEndpoint": true, + "runName": false, + "traceIds": false, + "traceSourceType": false + } + }, + "evaluator_catalog_create": { + "parameters": { + "category": true, + "codeText": false, + "description": false, + "desirableDirection": false, + "displayName": false, + "maxScore": false, + "metricName": false, + "minScore": false, + "name": true, + "passThreshold": false, + "projectEndpoint": true, + "promptText": false, + "scoringType": true + } + }, + "evaluator_catalog_delete": { + "parameters": { + "name": true, + "projectEndpoint": true, + "version": true + } + }, + "evaluator_catalog_get": { + "parameters": { + "continuationToken": false, + "limit": false, + "listVersions": false, + "name": false, + "projectEndpoint": true, + "type": false, + "version": false + } + }, + "evaluator_catalog_update": { + "parameters": { + "baseVersion": false, + "category": false, + "createNewVersion": false, + "description": false, + "dimensionDescriptionOverrides": false, + "dimensionWeightOverrides": false, + "displayName": false, + "name": true, + "projectEndpoint": true, + "version": false + } + }, + "evaluator_generation_job_cancel": { + "parameters": { + "jobId": true, + "projectEndpoint": true + } + }, + "evaluator_generation_job_create": { + "parameters": { + "agentSourceDescription": false, + "agentSourceNames": false, + "category": false, + "datasetName": false, + "datasetSourceDescription": false, + "datasetVersion": false, + "evaluatorDescription": false, + "evaluatorDisplayName": false, + "evaluatorName": true, + "modelDeploymentName": true, + "projectEndpoint": true, + "promptSource": false, + "promptSourceDescription": false + } + }, + "evaluator_generation_job_get": { + "parameters": { + "agentName": false, + "continuationToken": false, + "jobId": false, + "limit": false, + "projectEndpoint": true, + "status": false + } + }, + "model_benchmark_get": { + "parameters": {} + }, + "model_benchmark_subset_get": { + "parameters": { + "foundryAccountResourceId": true, + "modelPairs": true + } + }, + "model_catalog_list": { + "parameters": { + "licenseName": false, + "modelName": false, + "publisherName": false, + "searchForFreePlayground": false + } + }, + "model_deploy": { + "parameters": { + "deploymentName": true, + "foundryAccountResourceId": true, + "modelFormat": true, + "modelName": true, + "modelSource": false, + "modelVersion": false, + "scaleCapacity": false, + "scaleType": false, + "skuCapacity": false, + "skuName": false + } + }, + "model_deploy_azure_direct_model": { + "parameters": { + "deploymentName": true, + "foundryAccountResourceId": true, + "modelFormat": true, + "modelName": true, + "modelSource": false, + "modelVersion": false, + "scaleCapacity": false, + "scaleType": false, + "skuCapacity": false, + "skuName": false + } + }, + "model_deploy_managed_compute": { + "parameters": { + "acceleratorType": false, + "deploymentName": true, + "deploymentTemplate": false, + "foundryAccountResourceId": true, + "instanceCount": false, + "modelAssetId": true, + "versionUpgradeOption": false + } + }, + "model_deployment_delete": { + "parameters": { + "deploymentName": true, + "foundryAccountResourceId": true + } + }, + "model_deployment_get": { + "parameters": { + "deploymentName": false, + "foundryAccountResourceId": true + } + }, + "model_deprecation_info_get": { + "parameters": { + "foundryAccountResourceId": true, + "modelDeploymentName": true + } + }, + "model_details_get": { + "parameters": { + "modelName": true + } + }, + "model_monitoring_metrics_get": { + "parameters": { + "foundryAccountResourceId": true, + "metricCategory": false, + "modelDeploymentName": true + } + }, + "model_quota_list": { + "parameters": { + "region": true, + "subscriptionId": true + } + }, + "model_similar_models_get": { + "parameters": { + "foundryAccountResourceId": true, + "modelDeploymentName": false, + "modelName": false, + "modelVersion": false + } + }, + "model_switch_recommendations_get": { + "parameters": { + "foundryAccountResourceId": true, + "modelDeploymentName": false, + "modelName": false, + "modelVersion": false, + "sortBy": false, + "top": false + } + }, + "project_connection_create": { + "parameters": { + "apiKey": false, + "authType": true, + "category": true, + "connectionName": true, + "customKeys": false, + "expiryTime": false, + "foundryProjectResourceId": true, + "metadata": false, + "target": true + } + }, + "project_connection_delete": { + "parameters": { + "connectionName": true, + "foundryProjectResourceId": true + } + }, + "project_connection_get": { + "parameters": { + "connectionName": true, + "foundryProjectResourceId": true + } + }, + "project_connection_list": { + "parameters": { + "category": false, + "foundryProjectResourceId": true, + "includeAll": false, + "target": false + } + }, + "project_connection_list_metadata": { + "parameters": {} + }, + "project_connection_update": { + "parameters": { + "apiKey": false, + "authType": true, + "category": true, + "connectionName": true, + "customKeys": false, + "expiryTime": false, + "foundryProjectResourceId": true, + "metadata": false, + "target": true + } + }, + "prompt_optimize": { + "parameters": { + "deploymentName": true, + "developerMessage": true, + "foundryAccountResourceId": false, + "projectEndpoint": false, + "requestedChanges": false + } + }, + "session_create": { + "parameters": { + "agentName": true, + "projectEndpoint": true, + "sessionId": false + } + }, + "session_delete": { + "parameters": { + "agentName": true, + "projectEndpoint": true, + "sessionId": true + } + }, + "session_file_delete": { + "parameters": { + "agentName": true, + "filePath": true, + "projectEndpoint": true, + "recursive": false, + "sessionId": true + } + }, + "session_file_download": { + "parameters": { + "agentName": true, + "filePath": true, + "projectEndpoint": true, + "sessionId": true + } + }, + "session_file_list": { + "parameters": { + "agentName": true, + "path": false, + "projectEndpoint": true, + "sessionId": true + } + }, + "session_file_mkdir": { + "parameters": { + "agentName": true, + "createParents": false, + "mode": false, + "path": true, + "projectEndpoint": true, + "sessionId": true + } + }, + "session_file_stat": { + "parameters": { + "agentName": true, + "filePath": true, + "projectEndpoint": true, + "sessionId": true + } + }, + "session_file_upload": { + "parameters": { + "agentName": true, + "contentBase64": true, + "filePath": true, + "projectEndpoint": true, + "sessionId": true + } + }, + "session_get": { + "parameters": { + "agentName": true, + "projectEndpoint": true, + "sessionId": true + } + }, + "session_list": { + "parameters": { + "after": false, + "agentName": true, + "before": false, + "limit": false, + "order": false, + "projectEndpoint": true + } + }, + "session_logstream": { + "parameters": { + "agentName": true, + "maxLines": false, + "projectEndpoint": true, + "sessionId": true + } + }, + "toolbox_delete": { + "parameters": { + "projectEndpoint": true, + "toolboxName": true + } + }, + "toolbox_get": { + "parameters": { + "projectEndpoint": true, + "toolboxName": false + } + }, + "toolbox_update": { + "parameters": { + "defaultVersion": true, + "projectEndpoint": true, + "toolboxName": true + } + }, + "toolbox_version_create": { + "parameters": { + "description": false, + "metadata": false, + "policies": false, + "projectEndpoint": true, + "skills": false, + "toolboxName": true, + "tools": true + } + }, + "toolbox_version_delete": { + "parameters": { + "projectEndpoint": true, + "toolboxName": true, + "version": true + } + }, + "toolbox_version_get": { + "parameters": { + "projectEndpoint": true, + "toolboxName": true, + "version": false + } + } + } + }, + "foundryextensions": { + "commands": { + "foundryextensions_knowledge_index_list": { + "parameters": { + "endpoint": true, + "tenant": false + } + }, + "foundryextensions_knowledge_index_schema": { + "parameters": { + "endpoint": true, + "index": true, + "tenant": false + } + }, + "foundryextensions_openai_chat-completions-create": { + "parameters": { + "auth-method": false, + "deployment": true, + "frequency-penalty": false, + "max-tokens": false, + "message-array": true, + "presence-penalty": false, + "resource-group": true, + "resource-name": true, + "seed": false, + "stop": false, + "subscription": false, + "temperature": false, + "tenant": false, + "top-p": false, + "user": false + } + }, + "foundryextensions_openai_create-completion": { + "parameters": { + "auth-method": false, + "deployment": true, + "max-tokens": false, + "prompt-text": true, + "resource-group": true, + "resource-name": true, + "subscription": false, + "temperature": false, + "tenant": false + } + }, + "foundryextensions_openai_embeddings-create": { + "parameters": { + "auth-method": false, + "deployment": true, + "input-text": true, + "resource-group": true, + "resource-name": true, + "subscription": false, + "tenant": false + } + }, + "foundryextensions_openai_models-list": { + "parameters": { + "resource-group": true, + "resource-name": true, + "subscription": false, + "tenant": false + } + }, + "foundryextensions_resource_get": { + "parameters": { + "resource-group": false, + "resource-name": false, + "subscription": false, + "tenant": false + } + } + } + }, + "functionapp": { + "commands": { + "functionapp_get": { + "parameters": { + "function-app": false, + "resource-group": false, + "subscription": false, + "tenant": false + } + } + } + }, + "functions": { + "commands": { + "functions_language_list": { + "parameters": {} + }, + "functions_project_get": { + "parameters": { + "language": true + } + }, + "functions_template_get": { + "parameters": { + "language": true, + "output": false, + "runtime-version": false, + "template": false + } + } + } + }, + "get_azure_bestpractices": { + "commands": { + "get_azure_bestpractices_ai_app": { + "parameters": {} + }, + "get_azure_bestpractices_get": { + "parameters": { + "action": true, + "resource": true + } + } + } + }, + "grafana": { + "commands": { + "grafana_list": { + "parameters": { + "resource-group": false, + "subscription": false, + "tenant": false + } + } + } + }, + "group_list": { + "parameters": { + "subscription": false, + "tenant": false + } + }, + "group_resource_list": { + "parameters": { + "resource-group": true, + "subscription": false, + "tenant": false + } + }, + "insights": { + "commands": { + "insights_get": { + "parameters": { + "nocache": false, + "query": false, + "scope": false, + "subscription": false, + "tenant": false + } + } + } + }, + "iothub": { + "commands": { + "iothub_device_list": { + "parameters": { + "hub-name": true, + "max-count": false, + "resource-group": true, + "subscription": false, + "tenant": false + } + }, + "iothub_device_show": { + "parameters": { + "device-id": true, + "hub-name": true, + "resource-group": true, + "subscription": false, + "tenant": false + } + }, + "iothub_device_stats": { + "parameters": { + "hub-name": true, + "resource-group": true, + "subscription": false, + "tenant": false + } + }, + "iothub_device_twin_get": { + "parameters": { + "device-id": true, + "hub-name": true, + "resource-group": true, + "subscription": false, + "tenant": false + } + }, + "iothub_hub_get": { + "parameters": { + "hub-name": true, + "resource-group": true, + "subscription": false, + "tenant": false + } + }, + "iothub_query_run": { + "parameters": { + "filters": false, + "from": false, + "hub-name": true, + "logical-operator": false, + "max-count": false, + "query": false, + "resource-group": true, + "subscription": false, + "tenant": false + } + } + } + }, + "iotoperations": { + "commands": { + "iotoperations_instance_get": { + "parameters": { + "instance": false, + "resource-group": false, + "subscription": false + } + }, + "iotoperations_instance_list": { + "parameters": { + "resource-group": false, + "subscription": false + } + } + } + }, + "keyvault": { + "commands": { + "keyvault_admin_settings_get": { + "parameters": { + "tenant": false, + "vault": true + } + }, + "keyvault_certificate_create": { + "parameters": { + "certificate": true, + "tenant": false, + "vault": true + } + }, + "keyvault_certificate_get": { + "parameters": { + "certificate": false, + "tenant": false, + "vault": true + } + }, + "keyvault_certificate_import": { + "parameters": { + "certificate": true, + "certificate-data": true, + "password": false, + "tenant": false, + "vault": true + } + }, + "keyvault_key_create": { + "parameters": { + "key": true, + "key-type": true, + "tenant": false, + "vault": true + } + }, + "keyvault_key_get": { + "parameters": { + "include-managed": false, + "key": false, + "tenant": false, + "vault": true + } + }, + "keyvault_secret_create": { + "parameters": { + "secret": true, + "tenant": false, + "value": true, + "vault": true + } + }, + "keyvault_secret_get": { + "parameters": { + "secret": false, + "tenant": false, + "vault": true + } + } + } + }, + "kusto": { + "commands": { + "kusto_cluster_get": { + "parameters": { + "cluster": true, + "subscription": false, + "tenant": false + } + }, + "kusto_cluster_list": { + "parameters": { + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "kusto_database_list": { + "parameters": { + "cluster": false, + "cluster-uri": false, + "subscription": false, + "tenant": false + } + }, + "kusto_query": { + "parameters": { + "cluster": false, + "cluster-uri": false, + "database": true, + "query": true, + "subscription": false, + "tenant": false + } + }, + "kusto_sample": { + "parameters": { + "cluster": false, + "cluster-uri": false, + "database": true, + "limit": false, + "subscription": false, + "table": true, + "tenant": false + } + }, + "kusto_table_list": { + "parameters": { + "cluster": false, + "cluster-uri": false, + "database": true, + "subscription": false, + "tenant": false + } + }, + "kusto_table_schema": { + "parameters": { + "cluster": false, + "cluster-uri": false, + "database": true, + "subscription": false, + "table": true, + "tenant": false + } + } + } + }, + "loadtesting": { + "commands": { + "loadtesting_test_create": { + "parameters": { + "description": false, + "display-name": false, + "duration": false, + "endpoint": false, + "ramp-up-time": false, + "resource-group": false, + "subscription": false, + "tenant": false, + "test-id": true, + "test-resource-name": true, + "virtual-users": false + } + }, + "loadtesting_test_get": { + "parameters": { + "resource-group": false, + "subscription": false, + "tenant": false, + "test-id": true, + "test-resource-name": true + } + }, + "loadtesting_testresource_create": { + "parameters": { + "resource-group": true, + "subscription": false, + "tenant": false, + "test-resource-name": false + } + }, + "loadtesting_testresource_list": { + "parameters": { + "resource-group": false, + "subscription": false, + "tenant": false, + "test-resource-name": false + } + }, + "loadtesting_testrun_createorupdate": { + "parameters": { + "description": false, + "display-name": false, + "old-testrun-id": false, + "resource-group": false, + "subscription": false, + "tenant": false, + "test-id": true, + "test-resource-name": true, + "testrun-id": true + } + }, + "loadtesting_testrun_get": { + "parameters": { + "resource-group": false, + "subscription": false, + "tenant": false, + "test-id": false, + "test-resource-name": true, + "testrun-id": false + } + } + } + }, + "managedlustre": { + "commands": { + "managedlustre_fs_blob_autoexport_cancel": { + "parameters": { + "filesystem-name": true, + "job-name": true, + "resource-group": true, + "subscription": false, + "tenant": false + } + }, + "managedlustre_fs_blob_autoexport_create": { + "parameters": { + "admin-status": false, + "autoexport-prefix": false, + "filesystem-name": true, + "job-name": false, + "resource-group": true, + "subscription": false, + "tenant": false + } + }, + "managedlustre_fs_blob_autoexport_delete": { + "parameters": { + "filesystem-name": true, + "job-name": true, + "resource-group": true, + "subscription": false, + "tenant": false + } + }, + "managedlustre_fs_blob_autoexport_get": { + "parameters": { + "filesystem-name": true, + "job-name": false, + "resource-group": true, + "subscription": false, + "tenant": false + } + }, + "managedlustre_fs_blob_autoimport_cancel": { + "parameters": { + "filesystem-name": true, + "job-name": true, + "resource-group": true, + "subscription": false, + "tenant": false + } + }, + "managedlustre_fs_blob_autoimport_create": { + "parameters": { + "admin-status": false, + "autoimport-prefixes": false, + "conflict-resolution-mode": false, + "enable-deletions": false, + "filesystem-name": true, + "job-name": false, + "maximum-errors": false, + "resource-group": true, + "subscription": false, + "tenant": false + } + }, + "managedlustre_fs_blob_autoimport_delete": { + "parameters": { + "filesystem-name": true, + "job-name": true, + "resource-group": true, + "subscription": false, + "tenant": false + } + }, + "managedlustre_fs_blob_autoimport_get": { + "parameters": { + "filesystem-name": true, + "job-name": false, + "resource-group": true, + "subscription": false, + "tenant": false + } + }, + "managedlustre_fs_blob_import_cancel": { + "parameters": { + "filesystem-name": true, + "job-name": true, + "resource-group": true, + "subscription": false, + "tenant": false + } + }, + "managedlustre_fs_blob_import_create": { + "parameters": { + "conflict-resolution-mode": false, + "filesystem-name": true, + "import-prefixes": false, + "job-name": false, + "maximum-errors": false, + "resource-group": true, + "subscription": false, + "tenant": false + } + }, + "managedlustre_fs_blob_import_delete": { + "parameters": { + "filesystem-name": true, + "job-name": true, + "resource-group": true, + "subscription": false, + "tenant": false + } + }, + "managedlustre_fs_blob_import_get": { + "parameters": { + "filesystem-name": true, + "job-name": false, + "resource-group": true, + "subscription": false, + "tenant": false + } + }, + "managedlustre_fs_create": { + "parameters": { + "custom-encryption": false, + "hsm-container": false, + "hsm-log-container": false, + "import-prefix": false, + "key-url": false, + "location": true, + "maintenance-day": true, + "maintenance-time": true, + "name": true, + "no-squash-nid-list": false, + "resource-group": true, + "root-squash-mode": false, + "size": true, + "sku": true, + "source-vault": false, + "squash-gid": false, + "squash-uid": false, + "subnet-id": true, + "subscription": false, + "tenant": false, + "user-assigned-identity-id": false, + "zone": true + } + }, + "managedlustre_fs_expansion_create": { + "parameters": { + "expansion-job-name": false, + "filesystem-name": true, + "new-size": true, + "resource-group": true, + "subscription": false, + "tenant": false + } + }, + "managedlustre_fs_expansion_delete": { + "parameters": { + "expansion-job-name": true, + "filesystem-name": true, + "resource-group": true, + "subscription": false, + "tenant": false + } + }, + "managedlustre_fs_expansion_get": { + "parameters": { + "expansion-job-name": false, + "filesystem-name": true, + "resource-group": true, + "subscription": false, + "tenant": false + } + }, + "managedlustre_fs_list": { + "parameters": { + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "managedlustre_fs_sku_get": { + "parameters": { + "location": false, + "subscription": false, + "tenant": false + } + }, + "managedlustre_fs_subnetsize_ask": { + "parameters": { + "size": true, + "sku": true, + "subscription": false, + "tenant": false + } + }, + "managedlustre_fs_subnetsize_validate": { + "parameters": { + "location": true, + "size": true, + "sku": true, + "subnet-id": true, + "subscription": false, + "tenant": false + } + }, + "managedlustre_fs_update": { + "parameters": { + "maintenance-day": false, + "maintenance-time": false, + "name": true, + "no-squash-nid-list": false, + "resource-group": true, + "root-squash-mode": false, + "squash-gid": false, + "squash-uid": false, + "subscription": false, + "tenant": false + } + } + } + }, + "marketplace": { + "commands": { + "marketplace_product_get": { + "parameters": { + "include-service-instruction-templates": false, + "include-stop-sold-plans": false, + "language": false, + "lookup-offer-in-tenant-level": false, + "plan-id": false, + "product-id": true, + "sku-id": false, + "subscription": false, + "tenant": false + } + }, + "marketplace_product_list": { + "parameters": { + "expand": false, + "filter": false, + "language": false, + "next-cursor": false, + "orderby": false, + "search": false, + "select": false, + "subscription": false, + "tenant": false + } + } + } + }, + "monitor": { + "commands": { + "monitor_activitylog_list": { + "parameters": { + "event-level": false, + "hours": false, + "resource-group": false, + "resource-name": true, + "resource-type": false, + "subscription": false, + "tenant": false, + "top": false + } + }, + "monitor_healthmodels_get": { + "parameters": { + "health-model": true, + "resource-group": true, + "subscription": false, + "tenant": false + } + }, + "monitor_healthmodels_list": { + "parameters": { + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "monitor_instrumentation_get-learning-resource": { + "parameters": { + "path": false + } + }, + "monitor_instrumentation_orchestrator-next": { + "parameters": { + "completion-note": true, + "session-id": true + } + }, + "monitor_instrumentation_orchestrator-start": { + "parameters": { + "workspace-path": true + } + }, + "monitor_instrumentation_send-brownfield-analysis": { + "parameters": { + "findings-json": true, + "session-id": true + } + }, + "monitor_instrumentation_send-enhancement-select": { + "parameters": { + "enhancement-keys": true, + "session-id": true + } + }, + "monitor_metrics_batchquery": { + "parameters": { + "aggregation": false, + "end-time": false, + "filter": false, + "interval": false, + "max-buckets": false, + "metric-names": true, + "metric-namespace": true, + "order-by": false, + "resource-group": false, + "resource-type": false, + "resources": true, + "start-time": false, + "subscription": false, + "tenant": false, + "top": false + } + }, + "monitor_metrics_definitions": { + "parameters": { + "limit": false, + "metric-namespace": false, + "resource": true, + "resource-group": false, + "resource-type": false, + "search-string": false, + "subscription": false, + "tenant": false + } + }, + "monitor_metrics_query": { + "parameters": { + "aggregation": false, + "end-time": false, + "filter": false, + "interval": false, + "max-buckets": false, + "metric-names": true, + "metric-namespace": true, + "resource": true, + "resource-group": false, + "resource-type": false, + "start-time": false, + "subscription": false, + "tenant": false + } + }, + "monitor_resource_log_query": { + "parameters": { + "hours": false, + "limit": false, + "query": true, + "resource-id": true, + "subscription": false, + "table": true, + "tenant": false + } + }, + "monitor_table_list": { + "parameters": { + "resource-group": true, + "subscription": false, + "table-type": true, + "tenant": false, + "workspace": true + } + }, + "monitor_table_type_list": { + "parameters": { + "resource-group": true, + "subscription": false, + "tenant": false, + "workspace": true + } + }, + "monitor_webtests_createorupdate": { + "parameters": { + "appinsights-component": false, + "description": false, + "enabled": false, + "expected-status-code": false, + "follow-redirects": false, + "frequency": false, + "headers": false, + "http-verb": false, + "ignore-status-code": false, + "location": false, + "parse-requests": false, + "request-body": false, + "request-url": false, + "resource-group": true, + "retry-enabled": false, + "ssl-check": false, + "ssl-lifetime-check": false, + "subscription": false, + "tenant": false, + "timeout": false, + "webtest": false, + "webtest-locations": false, + "webtest-resource": true + } + }, + "monitor_webtests_get": { + "parameters": { + "resource-group": false, + "subscription": false, + "tenant": false, + "webtest-resource": false + } + }, + "monitor_workspace_list": { + "parameters": { + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "monitor_workspace_log_query": { + "parameters": { + "hours": false, + "limit": false, + "query": true, + "subscription": false, + "table": true, + "tenant": false, + "workspace": true + } + }, + "monitor_workspace_log_search": { + "parameters": { + "limit": false, + "query": true, + "resource-group": true, + "subscription": false, + "table": true, + "tenant": false, + "timespan": true, + "workspace": true + } + } + } + }, + "mysql": { + "commands": { + "mysql_database_query": { + "parameters": { + "database": true, + "query": true, + "server": true, + "user": true + } + }, + "mysql_list": { + "parameters": { + "database": false, + "resource-group": false, + "server": false, + "subscription": false, + "user": false + } + }, + "mysql_server_config_get": { + "parameters": { + "resource-group": true, + "server": true, + "subscription": false + } + }, + "mysql_server_param_get": { + "parameters": { + "param": true, + "resource-group": true, + "server": true, + "subscription": false + } + }, + "mysql_server_param_set": { + "parameters": { + "param": true, + "resource-group": true, + "server": true, + "subscription": false, + "value": true + } + }, + "mysql_table_schema_get": { + "parameters": { + "database": true, + "server": true, + "table": true, + "user": true + } + } + } + }, + "optimization": { + "commands": { + "optimization_recommendation_alternatives": { + "parameters": { + "exclude-processor-types": false, + "exclude-skus": false, + "exclude-vm-series": false, + "new-processor-types": false, + "new-skus": false, + "new-vm-series": false, + "resource-id": false, + "subscription": false, + "tenant": false + } + }, + "optimization_recommendation_explain": { + "parameters": { + "resource-id": false, + "subscription": false, + "target-sku": false, + "tenant": false, + "view": false + } + }, + "optimization_recommendation_list": { + "parameters": { + "subscription": false, + "tenant": false, + "top": false + } + } + } + }, + "policy": { + "commands": { + "policy_assignment_list": { + "parameters": { + "scope": false, + "subscription": false, + "tenant": false + } + } + } + }, + "postgres": { + "commands": { + "postgres_database_query": { + "parameters": { + "auth-type": true, + "database": true, + "password": false, + "query": true, + "server": true, + "user": true + } + }, + "postgres_list": { + "parameters": { + "auth-type": false, + "database": false, + "password": false, + "resource-group": false, + "schema": false, + "server": false, + "subscription": false, + "user": false + } + }, + "postgres_server_config_get": { + "parameters": { + "resource-group": true, + "server": true, + "subscription": false, + "tenant": false + } + }, + "postgres_server_param_get": { + "parameters": { + "param": true, + "resource-group": true, + "server": true, + "subscription": false, + "tenant": false + } + }, + "postgres_server_param_set": { + "parameters": { + "param": true, + "resource-group": true, + "server": true, + "subscription": false, + "tenant": false, + "value": true + } + }, + "postgres_table_schema_get": { + "parameters": { + "auth-type": true, + "database": true, + "password": false, + "server": true, + "table": true, + "user": true + } + } + } + }, + "pricing": { + "commands": { + "pricing_get": { + "parameters": { + "currency": false, + "filter": false, + "include-savings-plan": false, + "price-type": false, + "region": false, + "service": false, + "service-family": false, + "sku": false + } + } + } + }, + "quota": { + "commands": { + "quota_region_availability_list": { + "parameters": { + "cognitive-service-deployment-sku-name": false, + "cognitive-service-model-name": false, + "cognitive-service-model-version": false, + "resource-types": true, + "subscription": false + } + }, + "quota_usage_check": { + "parameters": { + "region": true, + "resource-types": true, + "subscription": false + } + } + } + }, + "redis": { + "commands": { + "redis_create": { + "parameters": { + "access-keys-authentication": false, + "location": true, + "modules": false, + "public-network-access": false, + "resource": true, + "resource-group": true, + "sku": false, + "subscription": false, + "tenant": false + } + }, + "redis_list": { + "parameters": { + "subscription": false, + "tenant": false + } + } + } + }, + "resiliency": { + "commands": { + "resiliency_drill_check-resync-readiness": { + "parameters": { + "drill": true, + "service-group": true, + "tenant": false + } + }, + "resiliency_drill_create": { + "parameters": { + "drill": true, + "drill-type": true, + "rbac-setup-mode": true, + "recoveryplan": false, + "region": true, + "resource-group": false, + "service-group": true, + "subscription": true, + "tenant": false + } + }, + "resiliency_drill_delete": { + "parameters": { + "drill": true, + "service-group": true, + "tenant": false + } + }, + "resiliency_drill_end": { + "parameters": { + "attestation": true, + "attestation-notes": true, + "drill": true, + "service-group": true, + "tenant": false + } + }, + "resiliency_drill_get": { + "parameters": { + "name": false, + "service-group": true, + "tenant": false + } + }, + "resiliency_drill_resource_add-or-update": { + "parameters": { + "drill": true, + "exclude-resources": false, + "fault-duration-minutes": true, + "force-inclusion-and-update": false, + "include-resources": false, + "service-group": true, + "tenant": false, + "update-resources": false + } + }, + "resiliency_drill_resource_get": { + "parameters": { + "drill": true, + "name": false, + "service-group": true, + "tenant": false + } + }, + "resiliency_drill_run_add-notes": { + "parameters": { + "drill": true, + "drill-run": true, + "notes": true, + "service-group": true, + "tenant": false + } + }, + "resiliency_drill_run_failover": { + "parameters": { + "auto-failover": false, + "drill": true, + "drill-run": true, + "selected-resource-ids": false, + "service-group": true, + "source-locations": true, + "tenant": false + } + }, + "resiliency_drill_run_get": { + "parameters": { + "drill": true, + "name": false, + "service-group": true, + "tenant": false + } + }, + "resiliency_drill_run_mark-complete": { + "parameters": { + "drill": true, + "drill-run": true, + "service-group": true, + "stage": true, + "tenant": false + } + }, + "resiliency_drill_run_reprotect": { + "parameters": { + "drill": true, + "drill-run": true, + "service-group": true, + "tenant": false + } + }, + "resiliency_drill_run_resource_get": { + "parameters": { + "drill": true, + "drill-run": true, + "name": false, + "service-group": true, + "tenant": false + } + }, + "resiliency_drill_run_resume": { + "parameters": { + "drill": true, + "drill-run": true, + "service-group": true, + "tenant": false + } + }, + "resiliency_drill_start": { + "parameters": { + "drill": true, + "mode": true, + "service-group": true, + "tenant": false + } + }, + "resiliency_drill_update": { + "parameters": { + "drill": true, + "rbac-setup-mode": false, + "recoveryplan": false, + "region": false, + "service-group": true, + "subscription": false, + "tenant": false + } + }, + "resiliency_drill_validate-for-execution": { + "parameters": { + "drill": true, + "service-group": true, + "source-locations": true, + "tenant": false + } + }, + "resiliency_goal_assignment_get": { + "parameters": { + "name": false, + "service-group": true, + "tenant": false + } + }, + "resiliency_goal_resource_get": { + "parameters": { + "goal-assignment": true, + "name": false, + "service-group": true, + "tenant": false + } + }, + "resiliency_recoveryjob_get": { + "parameters": { + "name": false, + "recoveryplan": true, + "service-group": true, + "tenant": false + } + }, + "resiliency_recoveryjob_resource_get": { + "parameters": { + "name": false, + "recoveryjob": true, + "recoveryplan": true, + "service-group": true, + "tenant": false + } + }, + "resiliency_recoveryjob_resume": { + "parameters": { + "description": false, + "recoveryjob": true, + "recoveryplan": true, + "service-group": true, + "tenant": false + } + }, + "resiliency_recoveryjob_retry": { + "parameters": { + "recoveryjob": true, + "recoveryplan": true, + "service-group": true, + "tenant": false + } + }, + "resiliency_recoveryplan_checkreadiness": { + "parameters": { + "recoveryplan": true, + "service-group": true, + "tenant": false + } + }, + "resiliency_recoveryplan_create": { + "parameters": { + "additional-groups": false, + "default-group-description": false, + "default-group-post-actions": false, + "default-group-pre-actions": false, + "identity-type": true, + "plan-description": false, + "plan-type": true, + "recoveryplan": true, + "service-group": true, + "tenant": false, + "user-assigned-identity": false + } + }, + "resiliency_recoveryplan_delete": { + "parameters": { + "recoveryplan": true, + "service-group": true, + "tenant": false + } + }, + "resiliency_recoveryplan_failover": { + "parameters": { + "recoveryplan": true, + "selected-resource-ids": false, + "service-group": true, + "source-locations": false, + "tenant": false, + "user-consent": false + } + }, + "resiliency_recoveryplan_finalize": { + "parameters": { + "recoveryplan": true, + "service-group": true, + "tenant": false + } + }, + "resiliency_recoveryplan_get": { + "parameters": { + "name": false, + "service-group": true, + "tenant": false + } + }, + "resiliency_recoveryplan_reprotect": { + "parameters": { + "recoveryplan": true, + "selected-resource-ids": false, + "service-group": true, + "tenant": false + } + }, + "resiliency_recoveryplan_resource_get": { + "parameters": { + "name": false, + "recoveryplan": true, + "service-group": true, + "tenant": false + } + }, + "resiliency_recoveryplan_resource_update": { + "parameters": { + "recoveryplan": true, + "resources-to-remove": false, + "resources-to-update": false, + "service-group": true, + "tenant": false + } + }, + "resiliency_recoveryplan_validateforfailover": { + "parameters": { + "recoveryplan": true, + "selected-resource-ids": false, + "service-group": true, + "source-locations": false, + "tenant": false, + "user-consent": false + } + }, + "resiliency_recoveryplan_validateforoperation": { + "parameters": { + "operation-name": true, + "recoveryplan": true, + "service-group": true, + "tenant": false + } + }, + "resiliency_recoveryplan_validateforreprotect": { + "parameters": { + "recoveryplan": true, + "selected-resource-ids": false, + "service-group": true, + "tenant": false + } + }, + "resiliency_usageplan_create": { + "parameters": { + "plan-type": true, + "resource-group": true, + "subscription": false, + "tenant": false, + "usage-plan": true + } + }, + "resiliency_usageplan_delete": { + "parameters": { + "resource-group": true, + "subscription": false, + "tenant": false, + "usage-plan": true + } + }, + "resiliency_usageplan_enrollment_create": { + "parameters": { + "enrollment": true, + "resource-group": true, + "service-group": true, + "subscription": false, + "tenant": false, + "usage-plan": true + } + }, + "resiliency_usageplan_enrollment_delete": { + "parameters": { + "enrollment": true, + "resource-group": true, + "subscription": false, + "tenant": false, + "usage-plan": true + } + }, + "resiliency_usageplan_enrollment_get": { + "parameters": { + "name": false, + "resource-group": true, + "subscription": false, + "tenant": false, + "usage-plan": true + } + }, + "resiliency_usageplan_get": { + "parameters": { + "name": false, + "resource-group": false, + "subscription": false, + "tenant": false + } + } + } + }, + "resourcehealth": { + "commands": { + "resourcehealth_availability-status_get": { + "parameters": { + "resource-group": false, + "resourceId": false, + "subscription": false, + "tenant": false + } + }, + "resourcehealth_health-events_list": { + "parameters": { + "event-type": false, + "filter": false, + "query-end-time": false, + "query-start-time": false, + "status": false, + "subscription": false, + "tenant": false, + "tracking-id": false + } + } + } + }, + "role": { + "commands": { + "role_assignment_list": { + "parameters": { + "scope": true, + "subscription": false, + "tenant": false + } + } + } + }, + "search": { + "commands": { + "search_index_get": { + "parameters": { + "index": false, + "service": true + } + }, + "search_index_query": { + "parameters": { + "index": true, + "query": true, + "query-type": false, + "semantic-configuration": false, + "service": true + } + }, + "search_knowledge_base_get": { + "parameters": { + "knowledge-base": false, + "service": true + } + }, + "search_knowledge_base_retrieve": { + "parameters": { + "knowledge-base": true, + "messages": false, + "query": false, + "service": true + } + }, + "search_knowledge_source_get": { + "parameters": { + "knowledge-source": false, + "service": true + } + }, + "search_service_list": { + "parameters": { + "resource-group": false, + "subscription": false, + "tenant": false + } + } + } + }, + "servicebus": { + "commands": { + "servicebus_queue_details": { + "parameters": { + "namespace": true, + "queue": true, + "tenant": false + } + }, + "servicebus_topic_details": { + "parameters": { + "namespace": true, + "tenant": false, + "topic": true + } + }, + "servicebus_topic_subscription_details": { + "parameters": { + "namespace": true, + "subscription-name": true, + "tenant": false, + "topic": true + } + } + } + }, + "servicefabric": { + "commands": { + "servicefabric_managedcluster_node_get": { + "parameters": { + "cluster": true, + "node": false, + "resource-group": true, + "subscription": false, + "tenant": false + } + }, + "servicefabric_managedcluster_nodetype_restart": { + "parameters": { + "cluster": true, + "node-type": true, + "nodes": true, + "resource-group": true, + "subscription": false, + "tenant": false, + "update-type": false + } + } + } + }, + "signalr": { + "commands": { + "signalr_runtime_get": { + "parameters": { + "resource-group": false, + "signalr": false, + "subscription": false, + "tenant": false + } + } + } + }, + "speech": { + "commands": { + "speech_stt_recognize": { + "parameters": { + "endpoint": true, + "file": true, + "format": false, + "language": false, + "phrases": false, + "profanity": false + } + }, + "speech_tts_synthesize": { + "parameters": { + "endpoint": true, + "endpointId": false, + "format": false, + "language": false, + "outputAudio": true, + "text": true, + "voice": false + } + } + } + }, + "sql": { + "commands": { + "sql_db_create": { + "parameters": { + "collation": false, + "database": true, + "elastic-pool-name": false, + "max-size-bytes": false, + "read-scale": false, + "resource-group": true, + "server": true, + "sku-capacity": false, + "sku-name": false, + "sku-tier": false, + "subscription": false, + "zone-redundant": false + } + }, + "sql_db_delete": { + "parameters": { + "database": true, + "resource-group": true, + "server": true, + "subscription": false + } + }, + "sql_db_get": { + "parameters": { + "database": false, + "resource-group": true, + "server": true, + "subscription": false + } + }, + "sql_db_rename": { + "parameters": { + "database": true, + "new-database-name": true, + "resource-group": true, + "server": true, + "subscription": false + } + }, + "sql_db_update": { + "parameters": { + "collation": false, + "database": true, + "elastic-pool-name": false, + "max-size-bytes": false, + "read-scale": false, + "resource-group": true, + "server": true, + "sku-capacity": false, + "sku-name": false, + "sku-tier": false, + "subscription": false, + "zone-redundant": false + } + }, + "sql_elastic-pool_list": { + "parameters": { + "resource-group": true, + "server": true, + "subscription": false + } + }, + "sql_server_create": { + "parameters": { + "administrator-login": true, + "administrator-password": true, + "location": true, + "public-network-access": false, + "resource-group": true, + "server": true, + "subscription": false, + "version": false + } + }, + "sql_server_delete": { + "parameters": { + "resource-group": true, + "server": true, + "subscription": false + } + }, + "sql_server_entra-admin_list": { + "parameters": { + "resource-group": true, + "server": true, + "subscription": false + } + }, + "sql_server_firewall-rule_create": { + "parameters": { + "end-ip-address": true, + "firewall-rule-name": true, + "resource-group": true, + "server": true, + "start-ip-address": true, + "subscription": false + } + }, + "sql_server_firewall-rule_delete": { + "parameters": { + "firewall-rule-name": true, + "resource-group": true, + "server": true, + "subscription": false + } + }, + "sql_server_firewall-rule_list": { + "parameters": { + "resource-group": true, + "server": true, + "subscription": false + } + }, + "sql_server_get": { + "parameters": { + "resource-group": true, + "server": false, + "subscription": false + } + } + } + }, + "sreagent": { + "commands": { + "sreagent_agents_create": { + "parameters": { + "agent": true, + "description": false, + "handoffs": false, + "instructions": false, + "name": true, + "resource-group": false, + "subscription": false, + "tenant": false, + "tools": false + } + }, + "sreagent_agents_delete": { + "parameters": { + "agent": true, + "name": true, + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "sreagent_agents_get": { + "parameters": { + "agent": true, + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "sreagent_agents_list": { + "parameters": { + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "sreagent_agents_tools_create": { + "parameters": { + "agent": true, + "connector": false, + "database": false, + "description": false, + "name": true, + "parameters": false, + "query": false, + "resource-group": false, + "subscription": false, + "tenant": false, + "tool-type": true, + "url-template": false + } + }, + "sreagent_agents_tools_get": { + "parameters": { + "agent": true, + "name": true, + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "sreagent_agents_tools_list": { + "parameters": { + "agent": true, + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "sreagent_architecture_plan": { + "parameters": { + "kusto-connector": false, + "requirements": true, + "trigger-type": false + } + }, + "sreagent_commonprompts_create": { + "parameters": { + "agent": true, + "content": true, + "name": true, + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "sreagent_commonprompts_delete": { + "parameters": { + "agent": true, + "name": true, + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "sreagent_commonprompts_get": { + "parameters": { + "agent": true, + "name": true, + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "sreagent_commonprompts_list": { + "parameters": { + "agent": true, + "resource-group": false, + "search": false, + "subscription": false, + "tenant": false + } + }, + "sreagent_connectors_create_kusto": { + "parameters": { + "agent": true, + "cluster-url": true, + "database": false, + "name": true, + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "sreagent_connectors_create_mcp": { + "parameters": { + "agent": true, + "args": false, + "auth-type": false, + "bearer-token-env": false, + "command": false, + "endpoint": false, + "envs-json": false, + "headers-json": false, + "name": true, + "resource-group": false, + "subscription": false, + "tenant": false, + "type": true + } + }, + "sreagent_connectors_delete": { + "parameters": { + "agent": true, + "name": true, + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "sreagent_connectors_get": { + "parameters": { + "agent": true, + "name": true, + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "sreagent_connectors_list": { + "parameters": { + "agent": true, + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "sreagent_connectors_test": { + "parameters": { + "agent": true, + "name": true, + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "sreagent_docs_get": { + "parameters": { + "topic": true + } + }, + "sreagent_docs_memories_add": { + "parameters": { + "agent": true, + "content": true, + "name": true, + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "sreagent_docs_memories_delete": { + "parameters": { + "agent": true, + "name": true, + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "sreagent_docs_memories_list": { + "parameters": { + "agent": true, + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "sreagent_docs_memories_reindex": { + "parameters": { + "agent": true, + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "sreagent_docs_memories_search": { + "parameters": { + "agent": true, + "query": true, + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "sreagent_hooks_delete": { + "parameters": { + "agent": true, + "name": true, + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "sreagent_hooks_get": { + "parameters": { + "agent": true, + "name": true, + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "sreagent_hooks_list": { + "parameters": { + "agent": true, + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "sreagent_hooks_thread_activate": { + "parameters": { + "agent": true, + "hook-name": true, + "resource-group": false, + "subscription": false, + "tenant": false, + "thread-id": true + } + }, + "sreagent_hooks_thread_deactivate": { + "parameters": { + "agent": true, + "hook-name": true, + "resource-group": false, + "subscription": false, + "tenant": false, + "thread-id": true + } + }, + "sreagent_hooks_thread_list": { + "parameters": { + "agent": true, + "resource-group": false, + "subscription": false, + "tenant": false, + "thread-id": true + } + }, + "sreagent_incidents_active_list": { + "parameters": { + "agent": true, + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "sreagent_incidents_create": { + "parameters": { + "agent": true, + "description": true, + "resource-group": false, + "services": true, + "severity": true, + "subscription": false, + "tenant": false, + "title": true + } + }, + "sreagent_incidents_plans_create": { + "parameters": { + "agent": true, + "agent-mode": false, + "escalation": false, + "name": true, + "resource-group": false, + "runbook-url": false, + "services": true, + "severity": true, + "steps": true, + "subscription": false, + "tenant": false, + "trigger-condition": true + } + }, + "sreagent_incidents_plans_list": { + "parameters": { + "agent": true, + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "sreagent_incidents_setup_pagerduty": { + "parameters": { + "agent": true, + "api-key-env": true, + "name": true, + "resource-group": false, + "subdomain": false, + "subscription": false, + "tenant": false + } + }, + "sreagent_incidents_setup_servicenow": { + "parameters": { + "agent": true, + "auth-type": true, + "instance-url": true, + "name": true, + "password-env": false, + "resource-group": false, + "subscription": false, + "tenant": false, + "token-env": false, + "username-env": false + } + }, + "sreagent_scheduledtasks_create": { + "parameters": { + "agent": true, + "cron-expression": true, + "description": false, + "message": true, + "name": true, + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "sreagent_scheduledtasks_delete": { + "parameters": { + "agent": true, + "resource-group": false, + "subscription": false, + "task-id": true, + "tenant": false + } + }, + "sreagent_scheduledtasks_get": { + "parameters": { + "agent": true, + "resource-group": false, + "subscription": false, + "task-id": true, + "tenant": false + } + }, + "sreagent_scheduledtasks_list": { + "parameters": { + "agent": true, + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "sreagent_scheduledtasks_pause": { + "parameters": { + "agent": true, + "resource-group": false, + "subscription": false, + "task-id": true, + "tenant": false + } + }, + "sreagent_scheduledtasks_resume": { + "parameters": { + "agent": true, + "resource-group": false, + "subscription": false, + "task-id": true, + "tenant": false + } + }, + "sreagent_skills_create": { + "parameters": { + "agent": true, + "content": true, + "description": false, + "name": true, + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "sreagent_skills_delete": { + "parameters": { + "agent": true, + "name": true, + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "sreagent_skills_list": { + "parameters": { + "agent": true, + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "sreagent_threads_create": { + "parameters": { + "agent": true, + "message": true, + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "sreagent_threads_delete": { + "parameters": { + "agent": true, + "resource-group": false, + "subscription": false, + "tenant": false, + "thread-id": true + } + }, + "sreagent_threads_get": { + "parameters": { + "agent": true, + "resource-group": false, + "subscription": false, + "tenant": false, + "thread-id": true + } + }, + "sreagent_threads_investigate": { + "parameters": { + "agent": true, + "max-iterations": false, + "message": true, + "resource-group": false, + "subscription": false, + "tenant": false, + "timeout-seconds": false + } + }, + "sreagent_threads_investigate_yolo": { + "parameters": { + "agent": true, + "max-iterations": false, + "message": true, + "resource-group": false, + "subscription": false, + "tenant": false, + "timeout-seconds": false + } + }, + "sreagent_threads_list": { + "parameters": { + "agent": true, + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "sreagent_threads_send_message": { + "parameters": { + "agent": true, + "message": true, + "resource-group": false, + "subscription": false, + "tenant": false, + "thread-id": true + } + }, + "sreagent_workflows_apply": { + "parameters": { + "agent": true, + "resource-group": false, + "subscription": false, + "tenant": false, + "yaml-content": true + } + }, + "sreagent_workflows_generate": { + "parameters": { + "connector": false, + "database": false, + "description": true, + "handoffs": false, + "kind": true, + "model-or-type": false, + "name": true, + "parameters": false, + "query": false, + "tools": false, + "url-template": false + } + }, + "sreagent_workflows_validate": { + "parameters": { + "yaml-content": true + } + } + } + }, + "storage": { + "commands": { + "storage_account_create": { + "parameters": { + "access-tier": false, + "account": true, + "allow-shared-key-access": false, + "enable-hierarchical-namespace": false, + "location": true, + "resource-group": true, + "sku": false, + "subscription": false, + "tenant": false + } + }, + "storage_account_get": { + "parameters": { + "account": false, + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "storage_blob_container_create": { + "parameters": { + "account": true, + "container": true, + "tenant": false + } + }, + "storage_blob_container_get": { + "parameters": { + "account": true, + "container": false, + "prefix": false, + "tenant": false + } + }, + "storage_blob_get": { + "parameters": { + "account": true, + "blob": false, + "container": true, + "prefix": false, + "tenant": false + } + }, + "storage_blob_upload": { + "parameters": { + "account": true, + "blob": true, + "container": true, + "local-file-path": true, + "tenant": false + } + }, + "storage_table_list": { + "parameters": { + "account": true, + "tenant": false + } + } + } + }, + "storagesync": { + "commands": { + "storagesync_cloudendpoint_changedetection": { + "parameters": { + "change-detection-mode": false, + "cloud-endpoint-name": true, + "directory-path": true, + "name": true, + "paths": false, + "resource-group": true, + "subscription": false, + "sync-group-name": true, + "tenant": false + } + }, + "storagesync_cloudendpoint_create": { + "parameters": { + "azure-file-share-name": true, + "change-enumeration-interval-days": false, + "cloud-endpoint-name": true, + "name": true, + "resource-group": true, + "storage-account-resource-id": true, + "subscription": false, + "sync-group-name": true, + "tenant": false + } + }, + "storagesync_cloudendpoint_delete": { + "parameters": { + "cloud-endpoint-name": true, + "name": true, + "resource-group": true, + "subscription": false, + "sync-group-name": true, + "tenant": false + } + }, + "storagesync_cloudendpoint_get": { + "parameters": { + "cloud-endpoint-name": false, + "name": true, + "resource-group": true, + "subscription": false, + "sync-group-name": true, + "tenant": false + } + }, + "storagesync_cloudendpoint_update": { + "parameters": { + "change-enumeration-interval-days": false, + "cloud-endpoint-name": true, + "name": true, + "resource-group": true, + "subscription": false, + "sync-group-name": true, + "tenant": false + } + }, + "storagesync_registeredserver_get": { + "parameters": { + "name": true, + "resource-group": true, + "server-id": false, + "subscription": false, + "tenant": false + } + }, + "storagesync_registeredserver_unregister": { + "parameters": { + "name": true, + "resource-group": true, + "server-id": true, + "subscription": false, + "tenant": false + } + }, + "storagesync_registeredserver_update": { + "parameters": { + "name": true, + "resource-group": true, + "server-id": true, + "subscription": false, + "tenant": false + } + }, + "storagesync_serverendpoint_create": { + "parameters": { + "cloud-tiering": false, + "local-cache-mode": false, + "name": true, + "resource-group": true, + "server-endpoint-name": true, + "server-local-path": true, + "server-resource-id": true, + "subscription": false, + "sync-group-name": true, + "tenant": false, + "tier-files-older-than-days": false, + "volume-free-space-percent": false + } + }, + "storagesync_serverendpoint_delete": { + "parameters": { + "name": true, + "resource-group": true, + "server-endpoint-name": true, + "subscription": false, + "sync-group-name": true, + "tenant": false + } + }, + "storagesync_serverendpoint_get": { + "parameters": { + "name": true, + "resource-group": true, + "server-endpoint-name": false, + "subscription": false, + "sync-group-name": true, + "tenant": false + } + }, + "storagesync_serverendpoint_update": { + "parameters": { + "cloud-tiering": false, + "local-cache-mode": false, + "name": true, + "resource-group": true, + "server-endpoint-name": true, + "subscription": false, + "sync-group-name": true, + "tenant": false, + "tier-files-older-than-days": false, + "volume-free-space-percent": false + } + }, + "storagesync_service_create": { + "parameters": { + "location": true, + "name": true, + "resource-group": true, + "subscription": false, + "tags": false, + "tenant": false + } + }, + "storagesync_service_delete": { + "parameters": { + "name": true, + "resource-group": true, + "subscription": false, + "tenant": false + } + }, + "storagesync_service_get": { + "parameters": { + "name": false, + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "storagesync_service_update": { + "parameters": { + "identity-type": false, + "incoming-traffic-policy": false, + "name": true, + "resource-group": true, + "subscription": false, + "tags": false, + "tenant": false + } + }, + "storagesync_syncgroup_create": { + "parameters": { + "name": true, + "resource-group": true, + "subscription": false, + "sync-group-name": true, + "tenant": false + } + }, + "storagesync_syncgroup_delete": { + "parameters": { + "name": true, + "resource-group": true, + "subscription": false, + "sync-group-name": true, + "tenant": false + } + }, + "storagesync_syncgroup_get": { + "parameters": { + "name": true, + "resource-group": true, + "subscription": false, + "sync-group-name": false, + "tenant": false + } + } + } + }, + "subscription_list": { + "parameters": { + "tenant": false + } + }, + "virtualdesktop": { + "commands": { + "virtualdesktop_hostpool_host_list": { + "parameters": { + "hostpool": false, + "hostpool-resource-id": false, + "resource-group": false, + "subscription": false, + "tenant": false + } + }, + "virtualdesktop_hostpool_host_user-list": { + "parameters": { + "hostpool": false, + "hostpool-resource-id": false, + "resource-group": false, + "sessionhost": true, + "subscription": false, + "tenant": false + } + }, + "virtualdesktop_hostpool_list": { + "parameters": { + "resource-group": false, + "subscription": false, + "tenant": false + } + } + } + }, + "wellarchitectedframework": { + "commands": { + "wellarchitectedframework_serviceguide_get": { + "parameters": { + "service": false + } + } + } + }, + "workbooks": { + "commands": { + "workbooks_create": { + "parameters": { + "display-name": true, + "resource-group": true, + "serialized-content": true, + "source-id": false, + "subscription": false, + "tenant": false + } + }, + "workbooks_delete": { + "parameters": { + "tenant": false, + "workbook-ids": true + } + }, + "workbooks_list": { + "parameters": { + "category": false, + "include-total-count": false, + "kind": false, + "max-results": false, + "modified-after": false, + "name-contains": false, + "output-format": false, + "resource-group": false, + "source-id": false, + "subscription": false, + "tenant": false + } + }, + "workbooks_show": { + "parameters": { + "tenant": false, + "workbook-ids": true + } + }, + "workbooks_update": { + "parameters": { + "display-name": false, + "serialized-content": false, + "tenant": false, + "workbook-id": true + } + } + } + } + } +} diff --git a/tools/schemas/cost-estimate.schema.json b/tools/schemas/cost-estimate.schema.json new file mode 100644 index 000000000..b0c78c653 --- /dev/null +++ b/tools/schemas/cost-estimate.schema.json @@ -0,0 +1,302 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/jonathan-vella/apex/schemas/cost-estimate.schema.json", + "title": "APEX Cost Estimate", + "description": "Canonical schema for cost-estimate-subagent output files (02-cost-estimate.json, 02-cost-candidates-*.json, 07-ab-cost-estimate.json) and preflight requests.", + "type": "object", + "required": [ + "status", + "project_name", + "region", + "currency", + "monthly_total", + "yearly_total", + "resources", + "optimization_notes", + "savings_status", + "savings_reason", + "eligible_strategies", + "data_source", + "queried_at", + "confidence", + "unresolved_items", + "mcp_calls_used", + "budget_exceeded" + ], + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string" + }, + "schema_version": { + "type": "string", + "const": "cost-estimate-v1" + }, + "status": { + "type": "string", + "enum": ["COMPLETE", "FAILED"] + }, + "project_name": { + "type": "string", + "minLength": 1 + }, + "region": { + "type": "string", + "minLength": 1 + }, + "currency": { + "type": "string", + "pattern": "^[A-Z]{3}$" + }, + "monthly_total": { + "type": ["number", "null"], + "minimum": 0 + }, + "yearly_total": { + "type": ["number", "null"], + "minimum": 0 + }, + "resources": { + "type": "array", + "items": { + "$ref": "#/$defs/resourceLine" + } + }, + "optimization_notes": { + "type": "array", + "items": { + "type": "string" + } + }, + "savings_status": { + "type": "string", + "enum": ["QUANTIFIED", "NOT_QUANTIFIED", "NOT_APPLICABLE"] + }, + "savings_reason": { + "type": "string" + }, + "eligible_strategies": { + "type": "array", + "items": { + "type": "string" + } + }, + "data_source": { + "type": "string", + "enum": [ + "Azure MCP pricing_get", + "Azure MCP pricing_get + Azure Retail Prices API (direct fallback)", + "Azure Retail Prices API (direct fallback)" + ] + }, + "queried_at": { + "type": "string", + "format": "date-time" + }, + "confidence": { + "type": "string", + "enum": ["High", "Medium", "Low"] + }, + "unresolved_items": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "mcp_calls_used": { + "type": "integer", + "minimum": 0, + "maximum": 20 + }, + "direct_api_calls_used": { + "type": "integer", + "minimum": 0, + "maximum": 20 + }, + "budget_exceeded": { + "type": "boolean" + }, + "manifest_writeback": { + "type": "array", + "items": { + "$ref": "#/$defs/manifestWritebackItem" + } + }, + "decisions": { + "type": "array", + "items": { + "$ref": "#/$defs/candidateDecision" + } + }, + "retail_api_evidence": { + "type": "array", + "items": { + "$ref": "#/$defs/retailApiEvidence" + } + }, + "query_ledger": { + "type": "array", + "items": { + "type": "object" + } + } + }, + "$defs": { + "resourceLine": { + "type": "object", + "required": ["name", "service_name", "sku", "region", "quantity", "monthly_cost", "notes"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "manifest_service_id": { + "type": "string", + "minLength": 1 + }, + "source_kind": { + "type": "string", + "enum": ["manifest", "supplemental", "resource_list", "candidate"] + }, + "environment": { + "type": "string" + }, + "stamp": { + "type": ["string", "null"] + }, + "service_name": { + "type": "string", + "minLength": 1 + }, + "sku": { + "type": "string", + "minLength": 1 + }, + "region": { + "type": "string", + "minLength": 1 + }, + "billing_region": { + "type": "string" + }, + "quantity": { + "type": "number", + "exclusiveMinimum": 0 + }, + "hourly_rate": { + "type": ["number", "null"], + "minimum": 0 + }, + "monthly_cost": { + "type": ["number", "null"], + "minimum": 0 + }, + "estimate_kind": { + "type": "string", + "enum": ["exact", "entry_band", "upper_bound"] + }, + "notes": { + "type": "string", + "minLength": 1 + } + } + }, + "manifestWritebackItem": { + "type": "object", + "required": ["id", "cost_estimate_monthly_usd", "cost_estimated_at"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "cost_estimate_monthly_usd": { + "type": "number", + "minimum": 0 + }, + "cost_estimated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "candidateDecision": { + "type": "object", + "required": ["decision_id", "winner_label", "delta_monthly_usd", "candidates"], + "additionalProperties": false, + "properties": { + "decision_id": { + "type": "string", + "minLength": 1 + }, + "winner_label": { + "type": ["string", "null"] + }, + "delta_monthly_usd": { + "type": ["number", "null"], + "minimum": 0 + }, + "candidates": { + "type": "array", + "minItems": 1, + "items": { + "type": "object" + } + } + } + }, + "retailApiEvidence": { + "type": "object", + "required": ["evidence_path", "resource_names", "selected_meters", "calculation"], + "additionalProperties": false, + "properties": { + "evidence_path": { + "type": "string", + "minLength": 1 + }, + "resource_names": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + }, + "selected_meters": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["meterId"], + "properties": { + "meterId": { + "type": "string", + "minLength": 1 + }, + "skuName": { + "type": "string" + }, + "armRegionName": { + "type": "string" + }, + "tierMinimumUnits": { + "type": "number", + "minimum": 0 + }, + "retailPrice": { + "type": "number", + "minimum": 0 + } + } + } + }, + "calculation": { + "type": "string", + "minLength": 1 + } + } + } + } +} diff --git a/tools/schemas/governance-baseline.schema.json b/tools/schemas/governance-baseline.schema.json index f375c2a4b..36772c8f7 100644 --- a/tools/schemas/governance-baseline.schema.json +++ b/tools/schemas/governance-baseline.schema.json @@ -16,11 +16,34 @@ "subscriptions" ], "additionalProperties": false, + "allOf": [ + { + "if": { "required": ["baseline_kind"] }, + "then": { "required": ["alz_library_ref", "azure_policy_ref", "generated_at"] } + } + ], "properties": { "schema_version": { "type": "string", "const": "governance-baseline-v1" }, + "baseline_kind": { + "type": "string", + "description": "Absent for a tenant baseline collected by the workflow. alz-corp-default marks the shipped, offline-generated ALZ Corp policy set, which is assumed governance and not verified against any tenant.", + "enum": ["alz-corp-default"] + }, + "alz_library_ref": { + "type": "string", + "description": "Azure-Landing-Zones-Library tag the baseline was generated from." + }, + "azure_policy_ref": { + "type": "string", + "description": "Azure/azure-policy commit the built-in definitions were read from." + }, + "generated_at": { + "type": "string", + "format": "date-time" + }, "management_group_id": { "type": "string", "minLength": 1 diff --git a/tools/schemas/governance-constraints.schema.json b/tools/schemas/governance-constraints.schema.json index 957223fef..a43a752fe 100644 --- a/tools/schemas/governance-constraints.schema.json +++ b/tools/schemas/governance-constraints.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/jonathan-vella/apex/schemas/governance-constraints.schema.json", "title": "APEX Governance Constraints", - "description": "Shape of agent-output/{project}/04-governance-constraints.json produced by 04g-Governance. Used by 06b/06t codegen agents and enforced in CI by scripts/validate-artifacts.mjs (Step 5b). The optional discovery_metadata envelope is the L0 attestation in the four-layer governance stack (L0 envelope → L1 plan matrix → L2 code attestation → L3 live precheck). discovery_metadata is warn-only for 30 days after rollout, then required.", + "description": "Shape of agent-output/{project}/04-governance-constraints.json produced by 04g-Governance. Generated by discover.py: never hand-edit it, because a re-run overwrites additions and any edit breaks the recorded signature; put additions in owner-controlled artifacts. Used by 06b/06t codegen agents and enforced in CI by scripts/validate-artifacts.mjs (Step 5b). The optional discovery_metadata envelope is the L0 attestation in the four-layer governance stack (L0 envelope → L1 plan matrix → L2 code attestation → L3 live precheck). discovery_metadata is warn-only for 30 days after rollout, then required.", "type": "object", "required": ["schema_version", "subscription_id", "discovered_at", "findings"], "additionalProperties": true, @@ -18,7 +18,7 @@ "discovered_at": { "type": "string", "format": "date-time" }, "source": { "type": "string", - "enum": ["azure-policy-rest-api", "az-cli", "static", "partial", "github-actions-baseline"] + "enum": ["azure-policy-rest-api", "az-cli", "static", "partial", "github-actions-baseline", "alz-corp-default"] }, "discovery_metadata": { "description": "L0 attestation envelope. Mandatory after 30-day rollout window; warn-only before then. Every downstream consumer (05-IaC Planner, 06b/06t CodeGen, 07b/07t Deploy) reads this first and STOPS on staleness, signature drift, or non-COMPLETE status.", diff --git a/tools/schemas/iac-contract.schema.json b/tools/schemas/iac-contract.schema.json index 58b6e656e..c38d370fb 100644 --- a/tools/schemas/iac-contract.schema.json +++ b/tools/schemas/iac-contract.schema.json @@ -46,7 +46,14 @@ "evidence": { "type": "array", "items": { "type": "string", "minLength": 1 } }, "approval_ref": { "type": "string", "minLength": 1 }, "revisit_condition": { "type": "string", "minLength": 1 }, - "runtime_status": { "enum": ["unverified", "verified", "not-applicable"] } + "runtime_status": { "enum": ["unverified", "verified", "not-applicable"] }, + "blocks": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "enum": ["deploy", "post-deploy", "go-live"] }, + "description": "Stages this capability blocks while unresolved. Absent means every stage." + } } } }, @@ -140,6 +147,20 @@ "type": "array", "items": { "type": "string" }, "description": "logical_name references of resources this one depends on. Optional in v0, recommended in v1." + }, + "phase": { + "type": "integer", + "minimum": 1, + "description": "Deployment phase that creates this resource. When present, depends_on targets that also declare a phase must not be in a later phase." + }, + "consumes": { + "type": "array", + "items": { "type": "string" }, + "description": "logical_name references of resources this one reaches over a private path (image pull, Key Vault reference, SQL, Blob). When a private endpoint targets a consumed resource, this resource must depend_on one such endpoint." + }, + "target": { + "type": "string", + "description": "Private endpoints only: logical_name of the resource this endpoint exposes." } } } diff --git a/tools/schemas/policy-property-map.schema.json b/tools/schemas/policy-property-map.schema.json index 54875ef08..be1387e0a 100644 --- a/tools/schemas/policy-property-map.schema.json +++ b/tools/schemas/policy-property-map.schema.json @@ -74,6 +74,16 @@ "rationale": { "type": "string", "description": "WAF pillar mapping and reason. Optional in light mode; required for Deny-effect entries in full mode." + }, + "status": { + "type": "string", + "enum": ["satisfied", "pending", "not-applicable", "unsatisfiable"], + "description": "Optional plan-side status. Mirrors the Governance Compliance Matrix legend; absent means satisfied." + }, + "not_applicable_reason": { + "type": "string", + "minLength": 1, + "description": "Required when status is not-applicable: why the targeted resource type is absent from the plan." } } } diff --git a/tools/schemas/session-state.schema.json b/tools/schemas/session-state.schema.json index 4b7eb8c23..e64a0e0c0 100644 --- a/tools/schemas/session-state.schema.json +++ b/tools/schemas/session-state.schema.json @@ -287,7 +287,7 @@ "agent": { "type": "string" }, "status": { "type": "string", - "enum": ["pending", "in_progress", "complete", "skipped"] + "enum": ["pending", "in_progress", "complete", "skipped", "stale"] }, "sub_step": { "type": ["string", "null"] }, "started": { "type": ["string", "null"] }, diff --git a/tools/scripts/_data/avm-module-cache.json b/tools/scripts/_data/avm-module-cache.json index 1d22743cf..b8f43f9c1 100644 --- a/tools/scripts/_data/avm-module-cache.json +++ b/tools/scripts/_data/avm-module-cache.json @@ -94,7 +94,7 @@ "0.1.0" ], "source": "mcr", - "lookup_timestamp": "2026-09-21T18:57:13.489Z" + "lookup_timestamp": "2026-10-13T10:25:45.465Z" }, "bicep::br/public:avm/res/insights/component": { "latest": "0.8.0", @@ -235,7 +235,7 @@ "0.2.0" ], "source": "mcr", - "lookup_timestamp": "2026-05-22T10:35:22.261Z" + "lookup_timestamp": "2026-10-13T10:25:44.998Z" }, "bicep::br/public:avm/res/storage/storage-account": { "latest": "0.33.1", @@ -423,7 +423,7 @@ "0.1.0" ], "source": "mcr", - "lookup_timestamp": "2026-09-21T18:57:13.752Z" + "lookup_timestamp": "2026-10-13T10:25:47.684Z" }, "bicep::br/public:avm/res/web/site": { "latest": "0.23.0", @@ -652,7 +652,7 @@ "0.1.0" ], "source": "mcr", - "lookup_timestamp": "2026-05-17T07:34:54.345Z" + "lookup_timestamp": "2026-10-13T10:25:39.341Z" }, "bicep::br/public:avm/res/cdn/profile/origin-group": { "latest": "0.1.2", @@ -745,7 +745,7 @@ "0.1.0" ], "source": "mcr", - "lookup_timestamp": "2026-05-17T07:34:54.611Z" + "lookup_timestamp": "2026-10-13T10:25:40.505Z" }, "bicep::br/public:avm/res/data-factory/factory/managed-virtual-network": { "latest": "0.1.1", @@ -869,12 +869,13 @@ "lookup_timestamp": "2026-05-17T07:34:55.045Z" }, "bicep::br/public:avm/res/key-vault/vault/key": { - "latest": "0.1.0", + "latest": "0.1.1", "known_versions": [ + "0.1.1", "0.1.0" ], "source": "mcr", - "lookup_timestamp": "2026-05-20T15:41:07.448Z" + "lookup_timestamp": "2026-10-13T10:25:43.088Z" }, "bicep::br/public:avm/res/kusto/cluster": { "latest": "0.11.0", @@ -992,7 +993,7 @@ "0.1.0" ], "source": "mcr", - "lookup_timestamp": "2026-05-20T15:41:07.753Z" + "lookup_timestamp": "2026-10-13T10:25:44.816Z" }, "bicep::br/public:avm/res/network/private-dns-zone/a": { "latest": "0.1.0", @@ -1054,7 +1055,7 @@ "0.1.0" ], "source": "mcr", - "lookup_timestamp": "2026-06-22T12:09:28.060Z" + "lookup_timestamp": "2026-10-13T10:25:46.108Z" }, "bicep::br/public:avm/res/service-bus/namespace/disaster-recovery-config": { "latest": "0.1.0", @@ -1210,8 +1211,9 @@ "lookup_timestamp": "2026-05-17T07:34:56.116Z" }, "terraform::Azure/avm-res-insights-component/azurerm": { - "latest": "0.3.0", + "latest": "0.4.0", "known_versions": [ + "0.4.0", "0.3.0", "0.2.0", "0.1.5", @@ -1222,7 +1224,7 @@ "0.1.0" ], "source": "terraform-registry", - "lookup_timestamp": "2026-05-17T07:34:56.123Z" + "lookup_timestamp": "2026-10-13T10:25:48.098Z" }, "terraform::Azure/avm-res-maintenance-maintenanceconfiguration/azurerm": { "latest": "0.1.0", @@ -1362,18 +1364,19 @@ "0.0.1" ], "source": "terraform-registry", - "lookup_timestamp": "2026-05-17T07:34:56.198Z" + "lookup_timestamp": "2026-10-13T10:25:48.354Z" }, "bicep::br/public:avm/res/api-management/service/api": { - "latest": "0.2.1", + "latest": "0.2.2", "known_versions": [ + "0.2.2", "0.2.1", "0.2.0", "0.1.1", "0.1.0" ], "source": "mcr", - "lookup_timestamp": "2026-05-17T07:41:09.977Z" + "lookup_timestamp": "2026-10-13T10:25:36.863Z" }, "bicep::br/public:avm/res/api-management/service/identity-provider": { "latest": "0.1.2", @@ -1650,13 +1653,14 @@ "lookup_timestamp": "2026-06-22T12:09:26.956Z" }, "bicep::br/public:avm/res/hybrid-compute/license": { - "latest": "0.2.0", + "latest": "0.3.0", "known_versions": [ + "0.3.0", "0.2.0", "0.1.0" ], "source": "mcr", - "lookup_timestamp": "2026-05-17T07:41:11.571Z" + "lookup_timestamp": "2026-10-13T10:25:43.054Z" }, "bicep::br/public:avm/res/insights/data-collection-rule": { "latest": "0.11.0", @@ -1684,12 +1688,13 @@ "lookup_timestamp": "2026-06-22T12:09:27.127Z" }, "bicep::br/public:avm/res/key-vault/vault/access-policy": { - "latest": "0.1.0", + "latest": "0.1.1", "known_versions": [ + "0.1.1", "0.1.0" ], "source": "mcr", - "lookup_timestamp": "2026-05-17T07:41:11.663Z" + "lookup_timestamp": "2026-10-13T10:25:43.071Z" }, "bicep::br/public:avm/res/kubernetes-runtime/load-balancer": { "latest": "0.1.1", @@ -1744,7 +1749,7 @@ "0.1.0" ], "source": "mcr", - "lookup_timestamp": "2026-10-05T10:34:55.629Z" + "lookup_timestamp": "2026-10-13T10:25:44.017Z" }, "bicep::br/public:avm/res/net-app/net-app-account/capacity-pool": { "latest": "0.1.0", @@ -1773,7 +1778,7 @@ "0.1.0" ], "source": "mcr", - "lookup_timestamp": "2026-09-21T12:43:28.715Z" + "lookup_timestamp": "2026-10-13T10:25:44.049Z" }, "bicep::br/public:avm/res/network/dns-resolver/inbound-endpoint": { "latest": "0.1.0", @@ -1895,7 +1900,7 @@ "0.1.0" ], "source": "mcr", - "lookup_timestamp": "2026-05-17T07:41:12.436Z" + "lookup_timestamp": "2026-10-13T10:25:45.618Z" }, "bicep::br/public:avm/res/power-bi-dedicated/capacity": { "latest": "0.2.2", @@ -1911,7 +1916,7 @@ "0.1.0" ], "source": "mcr", - "lookup_timestamp": "2026-05-20T15:41:08.161Z" + "lookup_timestamp": "2026-10-13T10:25:45.778Z" }, "bicep::br/public:avm/res/service-networking/traffic-controller/frontend": { "latest": "0.1.0", @@ -1941,7 +1946,7 @@ "0.1.0" ], "source": "mcr", - "lookup_timestamp": "2026-05-17T07:41:12.706Z" + "lookup_timestamp": "2026-10-13T10:25:47.089Z" }, "bicep::br/public:avm/res/web/static-site/linked-backend": { "latest": "0.1.0", @@ -2166,7 +2171,7 @@ "0.1.0" ], "source": "mcr", - "lookup_timestamp": "2026-10-05T10:34:50.054Z" + "lookup_timestamp": "2026-10-13T10:25:38.830Z" }, "bicep::br/public:avm/res/automation/automation-account/variable": { "latest": "0.1.0", @@ -2421,7 +2426,7 @@ "0.1.0" ], "source": "mcr", - "lookup_timestamp": "2026-10-05T10:34:58.354Z" + "lookup_timestamp": "2026-10-13T10:25:46.257Z" }, "bicep::br/public:avm/res/sql/managed-instance/vulnerability-assessment": { "latest": "0.1.0", @@ -2581,7 +2586,7 @@ "0.1.0" ], "source": "terraform-registry", - "lookup_timestamp": "2026-10-05T10:35:01.162Z" + "lookup_timestamp": "2026-10-13T10:25:48.170Z" }, "terraform::Azure/avm-res-oracledatabase-cloudexadatainfrastructure/azurerm": { "latest": "0.3.1", @@ -2789,7 +2794,7 @@ "0.1.0" ], "source": "mcr", - "lookup_timestamp": "2026-06-22T12:09:26.197Z" + "lookup_timestamp": "2026-10-13T10:25:40.355Z" }, "bicep::br/public:avm/res/container-registry/registry/credential-set": { "latest": "0.1.0", @@ -2908,7 +2913,7 @@ "0.1.0" ], "source": "mcr", - "lookup_timestamp": "2026-06-01T11:40:33.266Z" + "lookup_timestamp": "2026-10-13T10:25:42.606Z" }, "bicep::br/public:avm/res/kubernetes-configuration/extension": { "latest": "0.3.8", @@ -3051,7 +3056,7 @@ "0.1.0" ], "source": "mcr", - "lookup_timestamp": "2026-06-01T11:40:33.799Z" + "lookup_timestamp": "2026-10-13T10:25:45.796Z" }, "bicep::br/public:avm/res/recovery-services/vault/replication-fabric/replication-protection-container": { "latest": "0.1.0", @@ -3083,7 +3088,7 @@ "0.1.0" ], "source": "mcr", - "lookup_timestamp": "2026-06-01T11:40:33.982Z" + "lookup_timestamp": "2026-10-13T10:25:46.886Z" }, "terraform::Azure/avm-res-app-managedenvironment/azurerm": { "latest": "0.5.0", @@ -3129,7 +3134,7 @@ "0.1.0" ], "source": "terraform-registry", - "lookup_timestamp": "2026-10-05T10:35:00.031Z" + "lookup_timestamp": "2026-10-13T10:25:47.994Z" }, "terraform::Azure/avm-res-databricks-workspace/azurerm": { "latest": "0.5.0", @@ -3141,7 +3146,7 @@ "0.1.0" ], "source": "terraform-registry", - "lookup_timestamp": "2026-06-22T12:09:28.549Z" + "lookup_timestamp": "2026-10-13T10:25:48.035Z" }, "terraform::Azure/avm-res-insights-datacollectionendpoint/azurerm": { "latest": "0.2.0", @@ -3160,7 +3165,7 @@ "0.1.0" ], "source": "terraform-registry", - "lookup_timestamp": "2026-06-01T11:40:34.184Z" + "lookup_timestamp": "2026-10-13T10:25:48.138Z" }, "terraform::Azure/avm-res-network-dnsresolver/azurerm": { "latest": "0.8.0", @@ -3548,7 +3553,7 @@ "0.1.0" ], "source": "mcr", - "lookup_timestamp": "2026-10-05T10:34:58.516Z" + "lookup_timestamp": "2026-10-13T10:25:46.723Z" }, "bicep::br/public:avm/res/sql/instance-pool": { "latest": "0.1.2", @@ -3589,7 +3594,7 @@ "0.1.0" ], "source": "mcr", - "lookup_timestamp": "2026-06-22T12:09:28.449Z" + "lookup_timestamp": "2026-10-13T10:25:47.723Z" }, "terraform::Azure/avm-res-authorization-roleassignment/azurerm": { "latest": "0.3.0", @@ -3920,7 +3925,7 @@ "0.1.0" ], "source": "mcr", - "lookup_timestamp": "2026-10-05T10:34:49.721Z" + "lookup_timestamp": "2026-10-13T10:25:37.967Z" }, "bicep::br/public:avm/res/azure-stack-hci/logical-network": { "latest": "0.3.1", @@ -3987,7 +3992,7 @@ "0.1.0" ], "source": "mcr", - "lookup_timestamp": "2026-10-05T10:34:52.318Z" + "lookup_timestamp": "2026-10-13T10:25:40.971Z" }, "bicep::br/public:avm/res/db-for-postgre-sql/flexible-server/firewall-rule": { "latest": "0.1.1", @@ -4023,7 +4028,7 @@ "0.1.0" ], "source": "mcr", - "lookup_timestamp": "2026-10-05T10:34:53.493Z" + "lookup_timestamp": "2026-10-13T10:25:41.580Z" }, "bicep::br/public:avm/res/document-db/database-account/table": { "latest": "0.1.1", @@ -4249,7 +4254,7 @@ "0.1.0" ], "source": "terraform-registry", - "lookup_timestamp": "2026-10-05T10:35:00.215Z" + "lookup_timestamp": "2026-10-13T10:25:48.003Z" }, "terraform::Azure/avm-res-containerregistry-registry/azurerm": { "latest": "0.8.0", @@ -4299,7 +4304,7 @@ "0.1.0" ], "source": "terraform-registry", - "lookup_timestamp": "2026-10-05T10:35:00.727Z" + "lookup_timestamp": "2026-10-13T10:25:48.075Z" }, "terraform::Azure/avm-res-logic-workflow/azurerm": { "latest": "0.1.2", @@ -4319,6 +4324,493 @@ ], "source": "terraform-registry", "lookup_timestamp": "2026-10-05T10:35:01.215Z" + }, + "bicep::br/public:avm/res/api-management/service/logger": { + "latest": "0.2.2", + "known_versions": [ + "0.2.2", + "0.2.1", + "0.2.0", + "0.1.1", + "0.1.0" + ], + "source": "mcr", + "lookup_timestamp": "2026-10-13T10:25:37.296Z" + }, + "bicep::br/public:avm/res/api-management/service/workspace/api-version-set": { + "latest": "0.1.0", + "known_versions": [ + "0.1.0" + ], + "source": "mcr", + "lookup_timestamp": "2026-10-13T10:25:37.739Z" + }, + "bicep::br/public:avm/res/api-management/service/workspace/product/api-link": { + "latest": "0.1.0", + "known_versions": [ + "0.1.0" + ], + "source": "mcr", + "lookup_timestamp": "2026-10-13T10:25:37.758Z" + }, + "bicep::br/public:avm/res/app/managed-environment": { + "latest": "0.16.0", + "known_versions": [ + "0.16.0", + "0.15.0", + "0.14.0", + "0.13.3", + "0.13.2", + "0.13.1", + "0.13.0", + "0.12.0", + "0.11.3", + "0.11.2", + "0.11.1", + "0.11.0", + "0.10.2", + "0.10.1", + "0.10.0", + "0.9.1", + "0.9.0", + "0.8.2", + "0.8.1", + "0.8.0", + "0.7.0", + "0.6.2", + "0.6.1", + "0.6.0", + "0.5.2", + "0.5.1", + "0.5.0", + "0.4.5", + "0.4.4", + "0.4.3", + "0.4.2", + "0.4.1", + "0.4.0" + ], + "source": "mcr", + "lookup_timestamp": "2026-10-13T10:25:38.393Z" + }, + "bicep::br/public:avm/res/azure-stack-hci/marketplace-gallery-image": { + "latest": "0.1.0", + "known_versions": [ + "0.1.0" + ], + "source": "mcr", + "lookup_timestamp": "2026-10-13T10:25:38.983Z" + }, + "bicep::br/public:avm/res/batch/batch-account": { + "latest": "0.12.0", + "known_versions": [ + "0.12.0", + "0.11.2", + "0.11.1", + "0.11.0", + "0.10.3", + "0.10.2", + "0.10.1", + "0.10.0", + "0.9.2", + "0.9.1", + "0.9.0", + "0.8.0", + "0.7.0", + "0.6.2", + "0.6.1", + "0.6.0", + "0.5.1", + "0.5.0", + "0.3.5", + "0.3.4", + "0.3.3", + "0.3.2", + "0.3.1", + "0.3.0", + "0.2.0" + ], + "source": "mcr", + "lookup_timestamp": "2026-10-13T10:25:39.140Z" + }, + "bicep::br/public:avm/res/cdn/profile/secret": { + "latest": "0.1.0", + "known_versions": [ + "0.1.0" + ], + "source": "mcr", + "lookup_timestamp": "2026-10-13T10:25:39.915Z" + }, + "bicep::br/public:avm/res/db-for-postgre-sql/flexible-server/configuration": { + "latest": "0.1.1", + "known_versions": [ + "0.1.1", + "0.1.0" + ], + "source": "mcr", + "lookup_timestamp": "2026-10-13T10:25:41.415Z" + }, + "bicep::br/public:avm/res/document-db/database-account/cassandra-keyspace": { + "latest": "0.1.1", + "known_versions": [ + "0.1.1", + "0.1.0" + ], + "source": "mcr", + "lookup_timestamp": "2026-10-13T10:25:41.563Z" + }, + "bicep::br/public:avm/res/document-db/database-account/mongodb-database/collection": { + "latest": "0.1.1", + "known_versions": [ + "0.1.1", + "0.1.0" + ], + "source": "mcr", + "lookup_timestamp": "2026-10-13T10:25:42.017Z" + }, + "bicep::br/public:avm/res/elastic-san/elastic-san/volume-group/snapshot": { + "latest": "0.1.0", + "known_versions": [ + "0.1.0" + ], + "source": "mcr", + "lookup_timestamp": "2026-10-13T10:25:42.174Z" + }, + "bicep::br/public:avm/res/healthcare-apis/workspace/iotconnector": { + "latest": "0.2.0", + "known_versions": [ + "0.2.0", + "0.1.0" + ], + "source": "mcr", + "lookup_timestamp": "2026-10-13T10:25:42.622Z" + }, + "bicep::br/public:avm/res/kubernetes-configuration/flux-configuration": { + "latest": "0.3.8", + "known_versions": [ + "0.3.8", + "0.3.7", + "0.3.6", + "0.3.5", + "0.3.4", + "0.3.3", + "0.3.2", + "0.3.1", + "0.3.0", + "0.2.0" + ], + "source": "mcr", + "lookup_timestamp": "2026-10-13T10:25:43.529Z" + }, + "bicep::br/public:avm/res/maintenance/configuration-assignment": { + "latest": "0.2.1", + "known_versions": [ + "0.2.1", + "0.2.0", + "0.1.0" + ], + "source": "mcr", + "lookup_timestamp": "2026-10-13T10:25:43.562Z" + }, + "bicep::br/public:avm/res/network/application-security-group": { + "latest": "0.2.2", + "known_versions": [ + "0.2.2", + "0.2.1", + "0.2.0", + "0.1.5", + "0.1.4", + "0.1.3", + "0.1.2", + "0.1.1", + "0.1.0" + ], + "source": "mcr", + "lookup_timestamp": "2026-10-13T10:25:44.033Z" + }, + "bicep::br/public:avm/res/network/ddos-protection-plan": { + "latest": "0.3.2", + "known_versions": [ + "0.3.2", + "0.3.1", + "0.3.0", + "0.2.0", + "0.1.4", + "0.1.3", + "0.1.2", + "0.1.1", + "0.1.0" + ], + "source": "mcr", + "lookup_timestamp": "2026-10-13T10:25:44.196Z" + }, + "bicep::br/public:avm/res/network/dns-zone/a": { + "latest": "0.1.0", + "known_versions": [ + "0.1.0" + ], + "source": "mcr", + "lookup_timestamp": "2026-10-13T10:25:44.346Z" + }, + "bicep::br/public:avm/res/network/dns-zone/ptr": { + "latest": "0.1.0", + "known_versions": [ + "0.1.0" + ], + "source": "mcr", + "lookup_timestamp": "2026-10-13T10:25:44.362Z" + }, + "bicep::br/public:avm/res/network/private-dns-zone/cname": { + "latest": "0.1.0", + "known_versions": [ + "0.1.0" + ], + "source": "mcr", + "lookup_timestamp": "2026-10-13T10:25:44.981Z" + }, + "bicep::br/public:avm/res/network/private-endpoint/private-dns-zone-group": { + "latest": "0.1.0", + "known_versions": [ + "0.1.0" + ], + "source": "mcr", + "lookup_timestamp": "2026-10-13T10:25:45.439Z" + }, + "bicep::br/public:avm/res/recovery-services/vault/replication-fabric/replication-protection-container/replication-protection-container-mapping": { + "latest": "0.1.0", + "known_versions": [ + "0.1.0" + ], + "source": "mcr", + "lookup_timestamp": "2026-10-13T10:25:45.946Z" + }, + "bicep::br/public:avm/res/sql/server/failover-group": { + "latest": "0.1.0", + "known_versions": [ + "0.1.0" + ], + "source": "mcr", + "lookup_timestamp": "2026-10-13T10:25:47.063Z" + }, + "bicep::br/public:avm/res/storage/storage-account/file-service/share": { + "latest": "0.1.3", + "known_versions": [ + "0.1.3", + "0.1.2", + "0.1.1", + "0.1.0" + ], + "source": "mcr", + "lookup_timestamp": "2026-10-13T10:25:47.528Z" + }, + "bicep::br/public:avm/res/web/site/slot/hybrid-connection-namespace/relay": { + "latest": "0.1.0", + "known_versions": [ + "0.1.0" + ], + "source": "mcr", + "lookup_timestamp": "2026-10-13T10:25:47.758Z" + }, + "bicep::br/public:avm/res/web/static-site/custom-domain": { + "latest": "0.1.0", + "known_versions": [ + "0.1.0" + ], + "source": "mcr", + "lookup_timestamp": "2026-10-13T10:25:47.908Z" + }, + "terraform::Azure/avm-res-compute-diskencryptionset/azurerm": { + "latest": "0.1.1", + "known_versions": [ + "0.1.1", + "0.1.0" + ], + "source": "terraform-registry", + "lookup_timestamp": "2026-10-13T10:25:48.012Z" + }, + "terraform::Azure/avm-res-compute-virtualmachine/azurerm": { + "latest": "0.21.0", + "known_versions": [ + "0.21.0", + "0.20.0", + "0.19.3", + "0.19.2", + "0.19.1", + "0.19.0", + "0.18.1", + "0.18.0", + "0.17.0", + "0.16.0", + "0.15.1", + "0.15.0", + "0.14.0", + "0.13.1", + "0.13.0", + "0.12.0", + "0.11.0", + "0.10.0", + "0.9.0", + "0.8.0", + "0.7.0", + "0.6.0", + "0.5.0", + "0.4.0", + "0.3.0", + "0.2.0", + "0.1.0" + ], + "source": "terraform-registry", + "lookup_timestamp": "2026-10-13T10:25:48.023Z" + }, + "terraform::Azure/avm-res-dataprotection-resourceguard/azurerm": { + "latest": "0.1.0", + "known_versions": [ + "0.1.0" + ], + "source": "terraform-registry", + "lookup_timestamp": "2026-10-13T10:25:48.046Z" + }, + "terraform::Azure/avm-res-desktopvirtualization-workspace/azurerm": { + "latest": "0.3.0", + "known_versions": [ + "0.3.0", + "0.2.2", + "0.2.1", + "0.2.0", + "0.1.7", + "0.1.6", + "0.1.5", + "0.1.4", + "0.1.3", + "0.1.2", + "0.1.1", + "0.1.0" + ], + "source": "terraform-registry", + "lookup_timestamp": "2026-10-13T10:25:48.061Z" + }, + "terraform::Azure/avm-res-insights-actiongroup/azurerm": { + "latest": "0.1.0", + "known_versions": [ + "0.1.0" + ], + "source": "terraform-registry", + "lookup_timestamp": "2026-10-13T10:25:48.086Z" + }, + "terraform::Azure/avm-res-machinelearningservices-workspace/azurerm": { + "latest": "0.9.0", + "known_versions": [ + "0.9.0", + "0.8.0", + "0.7.0", + "0.6.0", + "0.5.0", + "0.4.1", + "0.4.0", + "0.3.11", + "0.3.10", + "0.3.9", + "0.3.8", + "0.3.7", + "0.3.6", + "0.3.5", + "0.3.4", + "0.3.3", + "0.3.2", + "0.3.1", + "0.3.0", + "0.2.1", + "0.2.0", + "0.1.2", + "0.1.1", + "0.1.0" + ], + "source": "terraform-registry", + "lookup_timestamp": "2026-10-13T10:25:48.114Z" + }, + "terraform::Azure/avm-res-network-applicationsecuritygroup/azurerm": { + "latest": "0.1.1", + "known_versions": [ + "0.1.1", + "0.1.0" + ], + "source": "terraform-registry", + "lookup_timestamp": "2026-10-13T10:25:48.159Z" + }, + "terraform::Azure/avm-res-network-networksecuritygroup/azurerm": { + "latest": "0.6.0", + "known_versions": [ + "0.6.0", + "0.5.2", + "0.5.1", + "0.5.0", + "0.4.0", + "0.3.0", + "0.2.0", + "0.1.1", + "0.1.0" + ], + "source": "terraform-registry", + "lookup_timestamp": "2026-10-13T10:25:48.182Z" + }, + "terraform::Azure/avm-res-network-publicipprefix/azurerm": { + "latest": "0.1.0", + "known_versions": [ + "0.1.0" + ], + "source": "terraform-registry", + "lookup_timestamp": "2026-10-13T10:25:48.193Z" + }, + "terraform::Azure/avm-res-operationalinsights-workspace/azurerm": { + "latest": "0.5.1", + "known_versions": [ + "0.5.1", + "0.5.0", + "0.4.2", + "0.4.1", + "0.4.0", + "0.3.5", + "0.3.4", + "0.3.3", + "0.3.2", + "0.3.1", + "0.3.0", + "0.2.2", + "0.2.1", + "0.2.0", + "0.1.5", + "0.1.4", + "0.1.3", + "0.1.2", + "0.1.1", + "0.1.0" + ], + "source": "terraform-registry", + "lookup_timestamp": "2026-10-13T10:25:48.213Z" + }, + "terraform::Azure/avm-res-relay-namespace/azurerm": { + "latest": "0.1.1", + "known_versions": [ + "0.1.1", + "0.1.0" + ], + "source": "terraform-registry", + "lookup_timestamp": "2026-10-13T10:25:48.226Z" + }, + "terraform::Azure/avm-res-search-searchservice/azurerm": { + "latest": "0.3.0", + "known_versions": [ + "0.3.0", + "0.2.0", + "0.1.5", + "0.1.4", + "0.1.3", + "0.1.2", + "0.1.1", + "0.1.0" + ], + "source": "terraform-registry", + "lookup_timestamp": "2026-10-13T10:25:48.235Z" } } } diff --git a/tools/scripts/audit-retirement-candidates.mjs b/tools/scripts/audit-retirement-candidates.mjs index 820eb796b..060244e7e 100644 --- a/tools/scripts/audit-retirement-candidates.mjs +++ b/tools/scripts/audit-retirement-candidates.mjs @@ -223,6 +223,15 @@ function generatedOwner(filePath) { source_of_truth: "Azure Verified Module registries", regeneration_command: "npm run refresh:avm-module-index", }, + ".github/data/governance-policy-baseline.json.gz": { + generated_by: [ + ".github/skills/apex-azure-governance-discovery/scripts/generate_alz_baseline.py", + "tools/scripts/collect-governance-baseline.ps1", + ], + source_of_truth: + "pinned ALZ library archetypes (shipped default) or the tenant policy state (collector workflow)", + regeneration_command: "npm run generate:governance-baseline-alz", + }, }; return owners[filePath] ?? { generated_by: [], source_of_truth: null, regeneration_command: null }; } diff --git a/tools/scripts/azure-sla-section.mjs b/tools/scripts/azure-sla-section.mjs new file mode 100644 index 000000000..d98ac7ef6 --- /dev/null +++ b/tools/scripts/azure-sla-section.mjs @@ -0,0 +1,254 @@ +#!/usr/bin/env node +// Print one service's section from the official Microsoft Online Services SLA document. +// +// Why: the azure.microsoft.com/support/legal/sla/* pages redirect to a licensing index, so +// fetching them replays a very large, repeated page into the chat. The authoritative SLA +// is a downloadable .docx; this script downloads it once, caches it, and prints only the +// requested service section (bounded) so agents can cite figures without the full text. +// +// Usage: +// node tools/scripts/azure-sla-section.mjs "" [--max-lines N] [--month YYYY-MM] +// node tools/scripts/azure-sla-section.mjs --list [filter] +// node tools/scripts/azure-sla-section.mjs "" --file +// +// Examples: +// node tools/scripts/azure-sla-section.mjs "App Service" +// node tools/scripts/azure-sla-section.mjs "Azure SQL Database" --max-lines 120 +// node tools/scripts/azure-sla-section.mjs --list storage +// +// Exit codes: 0 section printed or list shown; 1 service not found; 2 usage, download or parse error. +// Requires the `unzip` binary (present in the dev container). + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const MONTHS = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +]; +const DEFAULT_MAX_LINES = 80; +const LINE_WIDTH = 300; +const LOOKBACK_MONTHS = 4; +const DOWNLOAD_TIMEOUT_MS = 60_000; +const END_MARKER = "table of contents / definitions"; + +export function slaDocUrl(year, monthIndex) { + return `https://www.microsoft.com/licensing/docs/documents/download/OnlineSvcsConsolidatedSLA(WW)(English)(${MONTHS[monthIndex]}_${year})(CR).docx`; +} + +// The SLA is republished monthly; the newest edition may not exist yet, so walk backwards. +export function candidateMonths(now = new Date(), count = LOOKBACK_MONTHS) { + const out = []; + let year = now.getUTCFullYear(); + let month = now.getUTCMonth(); + for (let i = 0; i < count; i++) { + out.push({ year, month }); + month -= 1; + if (month < 0) { + month = 11; + year -= 1; + } + } + return out; +} + +export function decodeEntities(text) { + return text + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, "&"); +} + +// A tag without its closing ">" survives a plain tag regex ("]*>?/g, ""); + } while (current !== previous); + return current; +} + +export function docxXmlToParagraphs(xml) { + const paragraphs = xml.match(/].*?<\/w:p>/gs) ?? []; + return paragraphs.map((p) => decodeEntities(stripTags(p)).trim()).filter((t) => t.length > 0); +} + +export function tocServices(paragraphs) { + const names = new Set(); + for (const line of paragraphs) { + if (!/\bPAGEREF\b/.test(line)) continue; + const name = line.replace(/\s*PAGEREF.*$/, "").trim(); + if (name) names.add(name); + } + return [...names]; +} + +export function extractSections(paragraphs, service, maxLines = DEFAULT_MAX_LINES) { + const wanted = service.trim().toLowerCase(); + const tocNames = new Set(tocServices(paragraphs).map((n) => n.toLowerCase())); + const sections = []; + for (let i = 0; i < paragraphs.length; i++) { + const line = paragraphs[i]; + if (/\bPAGEREF\b/.test(line) || line.toLowerCase() !== wanted) continue; + let end = i + 1; + while (end < paragraphs.length) { + const next = paragraphs[end].toLowerCase(); + if (next === END_MARKER || (tocNames.has(next) && !/\bPAGEREF\b/.test(paragraphs[end]))) break; + end++; + } + const total = end - i; + sections.push({ + startLine: i + 1, + totalLines: total, + truncated: total > maxLines, + lines: paragraphs.slice(i, Math.min(end, i + maxLines)), + }); + } + return sections; +} + +export function parseArgs(argv) { + const args = { service: null, list: false, filter: null, maxLines: DEFAULT_MAX_LINES, file: null, month: null }; + const positional = []; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--list") args.list = true; + else if (a === "--max-lines") args.maxLines = Number.parseInt(argv[++i], 10); + else if (a === "--file") args.file = argv[++i]; + else if (a === "--month") args.month = argv[++i]; + else if (a.startsWith("--")) return { error: `unknown option ${a}` }; + else positional.push(a); + } + if (!Number.isInteger(args.maxLines) || args.maxLines < 1) return { error: "--max-lines needs a positive integer" }; + if (args.month && !/^\d{4}-(0[1-9]|1[0-2])$/.test(args.month)) return { error: "--month needs YYYY-MM" }; + if (args.list) args.filter = positional[0] ?? null; + else if (positional.length === 1) args.service = positional[0]; + else return { error: "provide exactly one service name, or use --list [filter]" }; + return args; +} + +async function downloadDocx(cacheDir, args, fetchImpl, now) { + fs.mkdirSync(cacheDir, { recursive: true }); + const months = args.month + ? [{ year: Number(args.month.slice(0, 4)), month: Number(args.month.slice(5)) - 1 }] + : candidateMonths(now); + for (const { year, month } of months) { + const cached = path.join(cacheDir, `sla-${year}-${String(month + 1).padStart(2, "0")}.docx`); + const url = slaDocUrl(year, month); + if (fs.existsSync(cached) && fs.statSync(cached).size > 0) { + return { file: cached, url, label: `${MONTHS[month]} ${year}`, cached: true }; + } + try { + const res = await fetchImpl(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) }); + if (!res.ok) continue; + const body = Buffer.from(await res.arrayBuffer()); + // A docx is a zip; anything else is an HTML error page for a month not yet published. + if (body.subarray(0, 2).toString("latin1") !== "PK") continue; + fs.writeFileSync(cached, body); + return { file: cached, url, label: `${MONTHS[month]} ${year}`, cached: false }; + } catch { + continue; + } + } + return null; +} + +function readParagraphs(docxPath) { + const xml = execFileSync("unzip", ["-p", docxPath, "word/document.xml"], { + encoding: "utf8", + maxBuffer: 256 * 1024 * 1024, + }); + return docxXmlToParagraphs(xml); +} + +export async function run( + argv, + { + out = console.log, + err = console.error, + fetchImpl = fetch, + now = new Date(), + cacheDir = path.join(os.tmpdir(), "apex-sla"), + } = {}, +) { + const args = parseArgs(argv); + if (args.error) { + err(`error: ${args.error}`); + err('usage: azure-sla-section.mjs "" [--max-lines N] [--month YYYY-MM] | --list [filter] | --file '); + return 2; + } + + let source; + if (args.file) { + if (!fs.existsSync(args.file)) { + err(`error: file not found: ${args.file}`); + return 2; + } + source = { file: args.file, url: "(local file)", label: path.basename(args.file), cached: true }; + } else { + source = await downloadDocx(cacheDir, args, fetchImpl, now); + if (!source) { + err("error: could not download the SLA document for the recent months; pass --file or --month YYYY-MM"); + return 2; + } + } + + let paragraphs; + try { + paragraphs = readParagraphs(source.file); + } catch (e) { + err(`error: could not read ${source.file}: ${e.message}`); + return 2; + } + + out(`Source: ${source.url}`); + out(`Document: ${source.label} (${source.cached ? "cached" : "downloaded"} at ${source.file})`); + + if (args.list) { + const filter = args.filter?.toLowerCase(); + const names = tocServices(paragraphs).filter((n) => !filter || n.toLowerCase().includes(filter)); + out(`Services${filter ? ` matching "${args.filter}"` : ""}: ${names.length}`); + for (const n of names) out(`- ${n}`); + return 0; + } + + const sections = extractSections(paragraphs, args.service, args.maxLines); + if (sections.length === 0) { + const needle = args.service.toLowerCase(); + const close = tocServices(paragraphs) + .filter((n) => n.toLowerCase().includes(needle)) + .slice(0, 10); + err( + `error: no section named "${args.service}".${close.length ? ` Close matches: ${close.join("; ")}` : " Use --list to browse."}`, + ); + return 1; + } + + for (const s of sections) { + out( + `--- "${args.service}" section at paragraph ${s.startLine} (${s.totalLines} paragraphs${s.truncated ? `, truncated at ${args.maxLines}; raise --max-lines` : ""}) ---`, + ); + for (const line of s.lines) out(line.length > LINE_WIDTH ? `${line.slice(0, LINE_WIDTH)}...` : line); + } + return 0; +} + +const invokedAsScript = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (invokedAsScript) process.exit(await run(process.argv.slice(2))); diff --git a/tools/scripts/cost-estimate.mjs b/tools/scripts/cost-estimate.mjs new file mode 100644 index 000000000..e27eacdd6 --- /dev/null +++ b/tools/scripts/cost-estimate.mjs @@ -0,0 +1,953 @@ +#!/usr/bin/env node +/** + * Deterministic cost-estimate preflight, calculation, validation, and atomic + * publication helper for `cost-estimate-subagent`. + * + * Replaces ad-hoc `node -e` shell validation/rename commands (which hit Bash + * history expansion when containing `!identifier` inside double quotes) and + * supports full-scope `manifest_path` runs with `supplemental_resources[]`. + */ + +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const RENDERER = path.join(ROOT, "tools/scripts/render-sku-manifest-md.mjs"); +const MAX_SHARED_REQUESTS = 20; +const HOURS_PER_MONTH = 730; +const DAYS_PER_MONTH = 365 / 12; + +const ALLOWED_DATA_SOURCES = new Set([ + "Azure MCP pricing_get", + "Azure MCP pricing_get + Azure Retail Prices API (direct fallback)", + "Azure Retail Prices API (direct fallback)", +]); + +const VARIABLE_SERVICE_NAMES = new Set(["Key Vault", "Log Analytics", "Azure DNS"]); + +export function round6(value) { + return Math.round((Number(value) + Number.EPSILON) * 1e6) / 1e6; +} + +export function resolveWorkspacePath(targetPath, cwd = process.cwd()) { + if (typeof targetPath !== "string" || !targetPath.trim()) { + throw new Error("Path must be a non-empty string"); + } + if (path.isAbsolute(targetPath)) return path.normalize(targetPath); + const resolvedCwd = path.resolve(cwd); + const normalizedRel = targetPath.replaceAll("\\", "/"); + if ( + (resolvedCwd === ROOT || resolvedCwd.startsWith(`${ROOT}${path.sep}`)) && + (normalizedRel.startsWith("agent-output/") || + normalizedRel.startsWith("tools/") || + normalizedRel.startsWith(".github/")) + ) { + return path.resolve(ROOT, targetPath); + } + return path.resolve(resolvedCwd, targetPath); +} + +function assertNotSymlink(filePath, label) { + try { + const stat = fs.lstatSync(filePath); + if (stat.isSymbolicLink()) { + throw new Error(`${label} must not be a symbolic link: ${filePath}`); + } + } catch (error) { + if (error.code !== "ENOENT") throw error; + } +} + +function mergeSparseService(baseService, envOverride = {}, stampOverride = {}) { + const mergedCapacity = { + ...(baseService.capacity ?? {}), + ...(envOverride.capacity ?? {}), + ...(stampOverride.capacity ?? {}), + }; + const mergedCommitment = { + ...(baseService.commitment ?? {}), + ...(envOverride.commitment ?? {}), + ...(stampOverride.commitment ?? {}), + }; + return { + ...baseService, + ...envOverride, + ...stampOverride, + capacity: mergedCapacity, + commitment: mergedCommitment, + }; +} + +function expandManifestServices(manifest, manifestUsage = {}) { + if (!manifest || typeof manifest !== "object" || !Array.isArray(manifest.services)) { + throw new Error("Manifest must contain a services[] array"); + } + const topEnvs = + Array.isArray(manifest.environments) && manifest.environments.length > 0 ? manifest.environments : ["dev"]; + const stamps = Array.isArray(manifest.stamps) && manifest.stamps.length > 0 ? manifest.stamps : [null]; + const expanded = []; + + for (const service of manifest.services) { + if (!service.id || !service.service || !service.size) { + throw new Error(`Manifest service entry is missing id, service, or size: ${JSON.stringify(service)}`); + } + const usageSpec = manifestUsage[service.id] ?? service.usage ?? null; + for (const stamp of stamps) { + const stampEnvs = + stamp && Array.isArray(stamp.environments) && stamp.environments.length > 0 ? stamp.environments : topEnvs; + const stampSvcOverride = stamp?.service_overrides?.[service.id] ?? {}; + for (const env of stampEnvs) { + const envOverride = service.environment_overrides?.[env] ?? {}; + const effective = mergeSparseService(service, envOverride, stampSvcOverride); + const regions = + stamp && Array.isArray(stamp.regions) && stamp.regions.length > 0 + ? stamp.regions + : Array.isArray(effective.regions) && effective.regions.length > 0 + ? effective.regions + : manifest.default_region + ? [manifest.default_region] + : []; + if (regions.length === 0) { + throw new Error(`Manifest service '${service.id}' has no effective deployment region`); + } + const quantity = Number(effective.capacity?.default ?? 1); + if (!Number.isFinite(quantity) || quantity <= 0) { + throw new Error(`Manifest service '${service.id}' in '${env}' has invalid capacity.default`); + } + for (const region of regions) { + const stampLabel = stamp?.id ? `, ${stamp.id}` : ""; + expanded.push({ + name: `${service.id} (${env}${stampLabel}, ${region})`, + manifest_service_id: service.id, + source_kind: "manifest", + environment: env, + stamp: stamp?.id ?? null, + service_name: effective.service, + sku: effective.size, + region, + quantity, + commitment: effective.commitment ?? { type: "on-demand" }, + usage: usageSpec, + meter_name: effective.meter_name ?? usageSpec?.meter_name ?? null, + upper_bound_ok: Boolean(usageSpec?.upper_bound_ok), + }); + } + } + } + } + return expanded; +} + +function validateUsageEntry(lineName, serviceName, sku, usage) { + const problems = []; + const items = Array.isArray(usage) ? usage : usage && typeof usage === "object" ? [usage] : []; + if (items.length === 0) { + if (VARIABLE_SERVICE_NAMES.has(serviceName)) { + problems.push(`${lineName}: variable-usage service '${serviceName}' requires explicit usage`); + } + if (serviceName === "Azure Monitor" && /alert/i.test(`${sku} ${lineName}`)) { + problems.push(`${lineName}: Azure Monitor alert line requires explicit usage and evaluation_frequency`); + } + return problems; + } + + for (const [idx, item] of items.entries()) { + const label = items.length > 1 ? `${lineName}.usage[${idx}]` : `${lineName}.usage`; + if (!item || typeof item !== "object" || Array.isArray(item)) { + problems.push(`${label}: usage entry must be an object`); + continue; + } + if (Object.hasOwn(item, "amount")) { + const amount = Number(item.amount); + if (!Number.isFinite(amount) || amount < 0 || (amount === 0 && !item.zero_usage_confirmed)) { + problems.push(`${label}: usage amount must be a positive number (or 0 with zero_usage_confirmed: true)`); + } + if (typeof item.unit !== "string" || !item.unit.trim()) { + problems.push(`${label}: usage unit is required (e.g. 'GB', '10K', '1M', 'monitored-resources')`); + } + } else { + const numericMetrics = Object.entries(item).filter( + ([k, v]) => + ![ + "scope", + "unit", + "evaluation_frequency", + "billable_days_per_month", + "zero_usage_confirmed", + "meter_name", + "upper_bound_ok", + ].includes(k) && + typeof v === "number" && + Number.isFinite(v) && + v >= 0, + ); + const hasPositive = numericMetrics.some(([, v]) => v > 0) || Boolean(item.zero_usage_confirmed); + if (numericMetrics.length === 0 || !hasPositive) { + problems.push(`${label}: usage object must specify amount+unit or positive numeric usage metrics`); + } + } + if (item.scope !== "per-instance" && item.scope !== "aggregate") { + problems.push(`${label}: usage scope must be 'per-instance' or 'aggregate'`); + } + if (serviceName === "Azure Monitor" && /alert/i.test(`${sku} ${lineName}`)) { + if (typeof item.evaluation_frequency !== "string" || !item.evaluation_frequency.trim()) { + problems.push(`${label}: alert usage must specify evaluation_frequency (e.g. '1m', '5m', '10m', '15m')`); + } + } + } + return problems; +} + +export function normalizeCostRequest(rawRequest, options = {}) { + const cwd = options.cwd ?? process.cwd(); + if (!rawRequest || typeof rawRequest !== "object" || Array.isArray(rawRequest)) { + throw new Error("Cost request must be a JSON object"); + } + const primaryModes = ["resource_list", "manifest_path", "candidate_sets"].filter((key) => + Object.hasOwn(rawRequest, key), + ); + if (primaryModes.length !== 1) { + throw new Error( + `Exactly one primary input mode (resource_list, manifest_path, or candidate_sets) is required; got ${primaryModes.length}`, + ); + } + const mode = primaryModes[0]; + if (typeof rawRequest.project_name !== "string" || !rawRequest.project_name.trim()) { + throw new Error("project_name is required"); + } + if (typeof rawRequest.region !== "string" || !rawRequest.region.trim()) { + throw new Error("region is required"); + } + if (typeof rawRequest.output_path !== "string" || !rawRequest.output_path.trim()) { + throw new Error("output_path is required"); + } + + const currency = rawRequest.currency ?? "USD"; + if (!/^[A-Z]{3}$/.test(currency)) { + throw new Error(`Invalid currency code: ${currency}`); + } + const overwrite = Boolean(rawRequest.overwrite); + const outputPath = resolveWorkspacePath(rawRequest.output_path, cwd); + const draftPath = `${outputPath}.tmp`; + const remainingRequests = Number(rawRequest.recovery?.remaining_requests ?? MAX_SHARED_REQUESTS); + + let manifestPath = null; + let manifestServiceIds = []; + let lines = []; + + if (mode === "manifest_path") { + manifestPath = resolveWorkspacePath(rawRequest.manifest_path, cwd); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + manifestServiceIds = manifest.services.map((s) => s.id); + const manifestIdSet = new Set(manifestServiceIds); + const expanded = expandManifestServices(manifest, rawRequest.manifest_usage ?? {}); + const supplemental = Array.isArray(rawRequest.supplemental_resources) ? rawRequest.supplemental_resources : []; + const seenSupplemental = new Set(); + + for (const sup of supplemental) { + if (!sup || typeof sup !== "object" || !sup.name || !sup.service_name || !sup.sku) { + throw new Error(`Each supplemental_resources entry requires name, service_name, and sku`); + } + if (manifestIdSet.has(sup.name)) { + throw new Error(`Supplemental resource '${sup.name}' collides with manifest service id '${sup.name}'`); + } + if (seenSupplemental.has(sup.name)) { + throw new Error(`Duplicate supplemental resource name: '${sup.name}'`); + } + seenSupplemental.add(sup.name); + lines.push({ + name: sup.name, + source_kind: "supplemental", + service_name: sup.service_name, + sku: sup.sku, + region: sup.region ?? rawRequest.region, + quantity: Number(sup.quantity ?? 1), + usage: sup.usage ?? null, + meter_name: sup.meter_name ?? null, + upper_bound_ok: Boolean(sup.upper_bound_ok), + }); + } + lines = [...expanded, ...lines]; + } else if (mode === "resource_list") { + if (!Array.isArray(rawRequest.resource_list) || rawRequest.resource_list.length === 0) { + throw new Error("resource_list must be a non-empty array"); + } + lines = rawRequest.resource_list.map((item, idx) => ({ + name: item.name ?? `${item.service_name}-${idx + 1}`, + source_kind: "resource_list", + service_name: item.service_name, + sku: item.sku, + region: item.region ?? rawRequest.region, + quantity: Number(item.quantity ?? 1), + usage: item.usage ?? null, + meter_name: item.meter_name ?? null, + upper_bound_ok: Boolean(item.upper_bound_ok), + })); + } else { + if (!Array.isArray(rawRequest.candidate_sets) || rawRequest.candidate_sets.length === 0) { + throw new Error("candidate_sets must be a non-empty array"); + } + for (const set of rawRequest.candidate_sets) { + if (!set.decision_id || !Array.isArray(set.candidates) || set.candidates.length === 0) { + throw new Error("Each candidate_sets entry requires decision_id and a non-empty candidates[] array"); + } + for (const candidate of set.candidates) { + lines.push({ + name: candidate.label, + decision_id: set.decision_id, + source_kind: "candidate", + service_name: candidate.service_name, + sku: candidate.sku, + region: candidate.region ?? rawRequest.region, + quantity: Number(candidate.quantity ?? 1), + usage: candidate.usage ?? null, + meter_name: candidate.meter_name ?? null, + upper_bound_ok: Boolean(candidate.upper_bound_ok), + }); + } + } + } + + const queryGroups = new Set( + lines.map((line) => `${line.service_name}|${line.region}|${line.meter_name ?? "*"}|${currency}`), + ); + + return { + mode, + project_name: rawRequest.project_name.trim(), + region: rawRequest.region.trim(), + currency, + output_path: outputPath, + draft_path: draftPath, + manifest_path: manifestPath, + manifest_writeback: mode === "manifest_path" ? rawRequest.manifest_writeback !== false : false, + manifest_service_ids: manifestServiceIds, + overwrite, + remaining_requests: remainingRequests, + estimated_query_groups: queryGroups.size, + lines, + recovery: rawRequest.recovery ?? null, + }; +} + +export function validateCostRequest(rawRequest, options = {}) { + const unresolved = []; + let normalized; + try { + normalized = normalizeCostRequest(rawRequest, options); + } catch (error) { + return { ok: false, normalized: null, unresolved_items: [error.message] }; + } + + assertNotSymlink(normalized.output_path, "output_path"); + assertNotSymlink(normalized.draft_path, "draft_path"); + if (normalized.manifest_path) assertNotSymlink(normalized.manifest_path, "manifest_path"); + + if (fs.existsSync(normalized.output_path) && !normalized.overwrite && !normalized.recovery) { + unresolved.push(`output_path already exists and overwrite is false: ${normalized.output_path}`); + } + if (fs.existsSync(normalized.draft_path) && !normalized.recovery?.draft_path) { + unresolved.push( + `Stale temporary draft already exists (preserve or clear before running): ${normalized.draft_path}`, + ); + } + if ( + !Number.isInteger(normalized.remaining_requests) || + normalized.remaining_requests < 1 || + normalized.remaining_requests > MAX_SHARED_REQUESTS + ) { + unresolved.push(`remaining_requests must be an integer between 1 and ${MAX_SHARED_REQUESTS}`); + } else if (normalized.estimated_query_groups > normalized.remaining_requests) { + unresolved.push( + `Estimated distinct query groups (${normalized.estimated_query_groups}) exceed remaining request budget (${normalized.remaining_requests})`, + ); + } + + for (const line of normalized.lines) { + if (!line.service_name || !line.sku || !line.region) { + unresolved.push(`${line.name}: service_name, sku, and region are required`); + } + if (!Number.isFinite(line.quantity) || line.quantity <= 0) { + unresolved.push(`${line.name}: quantity must be a positive number`); + } + if (line.usage !== null && line.usage !== undefined) { + unresolved.push(...validateUsageEntry(line.name, line.service_name, line.sku, line.usage)); + } else if ( + VARIABLE_SERVICE_NAMES.has(line.service_name) || + (line.service_name === "Azure Monitor" && /alert/i.test(`${line.sku} ${line.name}`)) + ) { + unresolved.push(...validateUsageEntry(line.name, line.service_name, line.sku, null)); + } + } + + return { + ok: unresolved.length === 0, + normalized, + unresolved_items: unresolved, + }; +} + +function computeTieredCharge(totalUnits, tierBands) { + if (!Array.isArray(tierBands) || tierBands.length === 0) { + throw new Error("tier_bands must be a non-empty array"); + } + const sorted = [...tierBands].sort((a, b) => Number(a.tierMinimumUnits) - Number(b.tierMinimumUnits)); + if (Number(sorted[0].tierMinimumUnits) !== 0) { + throw new Error("tier_bands must start at tierMinimumUnits = 0"); + } + for (let i = 1; i < sorted.length; i++) { + if (Number(sorted[i].tierMinimumUnits) <= Number(sorted[i - 1].tierMinimumUnits)) { + throw new Error("tier_bands must have strictly increasing tierMinimumUnits"); + } + } + let cost = 0; + for (let i = 0; i < sorted.length; i++) { + const start = Number(sorted[i].tierMinimumUnits); + const end = i + 1 < sorted.length ? Number(sorted[i + 1].tierMinimumUnits) : Infinity; + const rate = Number(sorted[i].retailPrice); + if (!Number.isFinite(rate) || rate < 0) { + throw new Error("tier_bands retailPrice must be a nonnegative number"); + } + if (totalUnits > start) { + const unitsInBand = Math.min(totalUnits, end) - start; + cost += unitsInBand * rate; + } + } + return cost; +} + +function computeComponentCost(component, defaultQuantity = 1) { + const quantity = Number(component.quantity ?? defaultQuantity); + if (!Number.isFinite(quantity) || quantity <= 0) { + throw new Error("Component quantity must be a positive number"); + } + const unit = (component.unit_of_measure ?? component.unitOfMeasure ?? "1 Hour").trim(); + const usageAmount = component.usage_amount ?? component.usage?.amount; + const scope = component.usage_scope ?? component.usage?.scope ?? "per-instance"; + const multiplier = scope === "aggregate" ? 1 : quantity; + const freeGrant = Number(component.free_grant_units ?? 0); + + if (Array.isArray(component.tier_bands) && component.tier_bands.length > 0) { + if (!Number.isFinite(Number(usageAmount)) || Number(usageAmount) < 0) { + throw new Error(`Tiered component requires finite usage_amount`); + } + const rawUnits = Math.max(0, Number(usageAmount) * multiplier - freeGrant); + const unitScale = /^10k\b/i.test(unit) ? 10000 : /^1m\b/i.test(unit) ? 1000000 : 1; + return computeTieredCharge(rawUnits / unitScale, component.tier_bands); + } + + const rate = Number(component.retail_price ?? component.retailPrice ?? component.hourly_rate); + if (!Number.isFinite(rate) || rate < 0) { + throw new Error(`Component rate must be a nonnegative number`); + } + + if (/^(1\s*)?hours?$/i.test(unit)) { + const hours = Number(component.hours_per_month ?? HOURS_PER_MONTH); + return rate * hours * quantity; + } + if (/^(1\s*\/\s*)?days?$/i.test(unit)) { + const days = Number(component.billable_days ?? DAYS_PER_MONTH); + return rate * days * quantity; + } + if (/^(1\s*\/\s*)?months?$/i.test(unit)) { + const units = usageAmount !== undefined && usageAmount !== null ? Number(usageAmount) * multiplier : quantity; + return rate * Math.max(0, units - freeGrant); + } + if (/^(1\s*)?gb(\s*\/\s*month)?$/i.test(unit)) { + if (!Number.isFinite(Number(usageAmount)) || Number(usageAmount) < 0) { + throw new Error(`Unit '${unit}' requires explicit usage_amount`); + } + return rate * Math.max(0, Number(usageAmount) * multiplier - freeGrant); + } + if (/^10k\b|^10,000\b/i.test(unit)) { + if (!Number.isFinite(Number(usageAmount)) || Number(usageAmount) < 0) { + throw new Error(`Unit '${unit}' requires explicit usage_amount`); + } + const billableOps = Math.max(0, Number(usageAmount) * multiplier - freeGrant); + return rate * (billableOps / 10000); + } + if (/^1m\b|^1,000,000\b/i.test(unit)) { + if (!Number.isFinite(Number(usageAmount)) || Number(usageAmount) < 0) { + throw new Error(`Unit '${unit}' requires explicit usage_amount`); + } + const billableOps = Math.max(0, Number(usageAmount) * multiplier - freeGrant); + return rate * (billableOps / 1000000); + } + if (unit === "1" && component.unit_interpretation === "per-unit-month") { + const units = usageAmount !== undefined && usageAmount !== null ? Number(usageAmount) * multiplier : quantity; + return rate * Math.max(0, units - freeGrant); + } + throw new Error(`Unsupported or ambiguous unitOfMeasure '${unit}'`); +} + +export function calculateLineCost(line) { + if (!line || typeof line !== "object") throw new Error("Resource line must be an object"); + if (Array.isArray(line.components) && line.components.length > 0) { + const total = line.components.reduce((sum, comp) => sum + computeComponentCost(comp, line.quantity ?? 1), 0); + return round6(total); + } + if (line.unit_of_measure || line.unitOfMeasure || line.retail_price !== undefined || line.tier_bands) { + return round6(computeComponentCost(line, line.quantity ?? 1)); + } + if (line.monthly_cost !== undefined && line.monthly_cost !== null) { + const val = Number(line.monthly_cost); + if (!Number.isFinite(val) || val < 0) throw new Error(`Invalid monthly_cost on '${line.name}'`); + return round6(val); + } + return null; +} + +function validateEvidenceReferences(estimate, cwd) { + const errors = []; + const entries = estimate.retail_api_evidence; + if (!Array.isArray(entries) || entries.length === 0) return errors; + + for (const [idx, entry] of entries.entries()) { + const label = `retail_api_evidence[${idx}]`; + if (!entry || typeof entry !== "object") { + errors.push(`${label}: must be an object`); + continue; + } + if (typeof entry.evidence_path !== "string" || !entry.evidence_path.trim()) { + errors.push(`${label}: evidence_path is required`); + continue; + } + if (!Array.isArray(entry.resource_names) || entry.resource_names.length === 0) { + errors.push(`${label}: resource_names must be a non-empty array`); + } + if (!Array.isArray(entry.selected_meters) || entry.selected_meters.length === 0) { + errors.push(`${label}: selected_meters must be a non-empty array`); + } + if (typeof entry.calculation !== "string" || !entry.calculation.trim()) { + errors.push(`${label}: calculation is required`); + } + const evidenceFile = resolveWorkspacePath(entry.evidence_path, cwd); + if (!fs.existsSync(evidenceFile)) { + errors.push(`${label}: evidence file does not exist: ${entry.evidence_path}`); + continue; + } + try { + const payload = JSON.parse(fs.readFileSync(evidenceFile, "utf8")); + if (payload.schema_version !== "retail-price-evidence-v1" || !Array.isArray(payload.pages)) { + errors.push(`${label}: invalid retail-price-evidence-v1 structure in ${entry.evidence_path}`); + continue; + } + const catalogMeterIds = new Set(); + for (const page of payload.pages) { + const digest = createHash("sha256") + .update(String(page.raw_response ?? "")) + .digest("hex"); + if (digest !== page.response_sha256) { + errors.push(`${label}: SHA-256 mismatch in ${entry.evidence_path}`); + } + const parsedPage = JSON.parse(page.raw_response); + for (const item of parsedPage.Items ?? []) { + if (item.meterId) catalogMeterIds.add(item.meterId); + } + } + for (const selected of entry.selected_meters ?? []) { + if (!selected.meterId || !catalogMeterIds.has(selected.meterId)) { + errors.push(`${label}: selected meterId '${selected.meterId}' not found in ${entry.evidence_path}`); + } + } + } catch (error) { + errors.push(`${label}: failed to parse evidence file ${entry.evidence_path}: ${error.message}`); + } + } + return errors; +} + +export function validateEstimate(estimate, options = {}) { + const cwd = options.cwd ?? process.cwd(); + const errors = []; + const warnings = []; + + if (!estimate || typeof estimate !== "object" || Array.isArray(estimate)) { + return { ok: false, errors: ["Estimate must be a JSON object"], warnings }; + } + + for (const field of [ + "status", + "project_name", + "region", + "currency", + "resources", + "optimization_notes", + "savings_status", + "savings_reason", + "eligible_strategies", + "data_source", + "queried_at", + "confidence", + "unresolved_items", + "mcp_calls_used", + "budget_exceeded", + ]) { + if (!Object.hasOwn(estimate, field)) { + errors.push(`Missing required field: ${field}`); + } + } + if (errors.length > 0) return { ok: false, errors, warnings }; + + if (estimate.status !== "COMPLETE" && estimate.status !== "FAILED") { + errors.push(`status must be 'COMPLETE' or 'FAILED' (never 'PARTIAL'); got '${estimate.status}'`); + } + if (!["High", "Medium", "Low"].includes(estimate.confidence)) { + errors.push(`confidence must be 'High', 'Medium', or 'Low'; got '${estimate.confidence}'`); + } + if (estimate.status === "COMPLETE" && estimate.confidence === "Low") { + errors.push("COMPLETE estimate must never have Low confidence"); + } + if (!ALLOWED_DATA_SOURCES.has(estimate.data_source)) { + errors.push(`Unsupported data_source: '${estimate.data_source}'`); + } + if (!/^[A-Z]{3}$/.test(estimate.currency ?? "")) { + errors.push(`Invalid currency: '${estimate.currency}'`); + } + if (!Number.isFinite(Date.parse(estimate.queried_at))) { + errors.push(`queried_at must be a valid ISO-8601 timestamp`); + } + + const mcpCalls = Number(estimate.mcp_calls_used); + const directCalls = Number(estimate.direct_api_calls_used ?? 0); + if (!Number.isInteger(mcpCalls) || mcpCalls < 0 || !Number.isInteger(directCalls) || directCalls < 0) { + errors.push("mcp_calls_used and direct_api_calls_used must be nonnegative integers"); + } else if (mcpCalls + directCalls > MAX_SHARED_REQUESTS) { + errors.push(`Total requests (${mcpCalls} MCP + ${directCalls} direct) exceed ceiling of ${MAX_SHARED_REQUESTS}`); + } + + if (!Array.isArray(estimate.resources)) { + errors.push("resources must be an array"); + return { ok: false, errors, warnings }; + } + + let hasUpperBound = false; + for (const [idx, res] of estimate.resources.entries()) { + const label = `resources[${idx}] (${res?.name ?? "unnamed"})`; + if (!res || typeof res !== "object") { + errors.push(`${label}: must be an object`); + continue; + } + for (const req of ["name", "service_name", "sku", "region", "quantity", "notes"]) { + if (res[req] === undefined || res[req] === null || res[req] === "") { + errors.push(`${label}: missing required field '${req}'`); + } + } + if (!Number.isFinite(Number(res.quantity)) || Number(res.quantity) <= 0) { + errors.push(`${label}: quantity must be a positive number`); + } + if (res.monthly_cost !== null && res.monthly_cost !== undefined) { + if (!Number.isFinite(Number(res.monthly_cost)) || Number(res.monthly_cost) < 0) { + errors.push(`${label}: monthly_cost must be a finite nonnegative number or null`); + } + } else if (estimate.status === "COMPLETE") { + errors.push(`${label}: monthly_cost cannot be null when status is COMPLETE`); + } + if (res.estimate_kind === "upper_bound" || /\bupper[- ]bound\b/i.test(res.notes ?? "")) { + hasUpperBound = true; + } + if (options.legacyAudit && /closest published (catalog )?meter/i.test(res.notes ?? "")) { + warnings.push(`${label}: uses 'closest published meter' substitution assumption in notes`); + } + } + + if (hasUpperBound && estimate.confidence === "High") { + errors.push("Estimate containing an upper-bound line cannot report High confidence"); + } + + const isCandidateMode = Array.isArray(estimate.decisions) && estimate.decisions.length > 0; + + if (estimate.status === "COMPLETE") { + if (!Array.isArray(estimate.unresolved_items) || estimate.unresolved_items.length > 0) { + errors.push("COMPLETE estimate must have an empty unresolved_items[] array"); + } + if (estimate.budget_exceeded === true) { + errors.push("COMPLETE estimate cannot have budget_exceeded = true"); + } + if (isCandidateMode) { + if (Number(estimate.monthly_total) !== 0 || Number(estimate.yearly_total) !== 0) { + errors.push("candidate_sets mode must report monthly_total = 0 and yearly_total = 0"); + } + for (const decision of estimate.decisions) { + const candidates = decision.candidates ?? []; + const completeCandidates = candidates.filter( + (c) => Number.isFinite(Number(c.monthly_cost)) && c.estimate_kind !== "upper_bound", + ); + if (completeCandidates.length === 0) { + errors.push( + `decisions[${decision.decision_id}]: COMPLETE candidate_sets decision requires resolved candidates`, + ); + continue; + } + const sorted = [...completeCandidates].sort((a, b) => { + const diff = Number(a.monthly_cost) - Number(b.monthly_cost); + if (Math.abs(diff) > 1e-6) return diff; + return String(a.label).localeCompare(String(b.label)); + }); + if (decision.winner_label !== sorted[0].label) { + errors.push( + `decisions[${decision.decision_id}]: winner_label '${decision.winner_label}' does not match lowest complete candidate '${sorted[0].label}'`, + ); + } + } + } else { + const sum = round6(estimate.resources.reduce((acc, r) => acc + Number(r.monthly_cost ?? 0), 0)); + const monthly = Number(estimate.monthly_total); + const yearly = Number(estimate.yearly_total); + if (!Number.isFinite(monthly) || Math.abs(sum - monthly) > 1e-5) { + errors.push(`monthly_total (${estimate.monthly_total}) does not equal sum of resources (${sum})`); + } + if (!Number.isFinite(yearly) || Math.abs(yearly - monthly * 12) > 1e-4) { + errors.push( + `yearly_total (${estimate.yearly_total}) does not equal monthly_total * 12 (${round6(monthly * 12)})`, + ); + } + } + } else if (estimate.status === "FAILED") { + if (!Array.isArray(estimate.unresolved_items) || estimate.unresolved_items.length === 0) { + errors.push("FAILED estimate must list at least one entry in unresolved_items[]"); + } + } + + if (Array.isArray(estimate.retail_api_evidence) && estimate.retail_api_evidence.length > 0) { + if (!estimate.data_source.includes("direct fallback")) { + errors.push("Estimate with retail_api_evidence[] must identify direct fallback in data_source"); + } + if (options.verifyEvidenceFiles !== false) { + errors.push(...validateEvidenceReferences(estimate, cwd)); + } + } + + return { ok: errors.length === 0, errors, warnings }; +} + +function matchManifestServiceId(resource, serviceIds) { + if (resource.source_kind === "supplemental") return null; + if (resource.manifest_service_id && serviceIds.has(resource.manifest_service_id)) { + return resource.manifest_service_id; + } + const name = String(resource.name ?? ""); + if (serviceIds.has(name)) return name; + for (const id of serviceIds) { + if (name.startsWith(`${id} (`) || name.startsWith(`${id}-`)) { + return id; + } + } + return null; +} + +export function buildCompactSummary(estimate, outputPath) { + const relPath = outputPath.startsWith(`${ROOT}${path.sep}`) + ? path.relative(ROOT, outputPath).replaceAll("\\", "/") + : outputPath; + const lines = [ + `COST ESTIMATE ${estimate.status}`, + `file_path: ${relPath}`, + `status: ${estimate.status}`, + `region: ${estimate.region}`, + `currency: ${estimate.currency}`, + `monthly_total: ${estimate.monthly_total}`, + `yearly_total: ${estimate.yearly_total}`, + `resource_count: ${estimate.resources.length}`, + `unresolved_items: ${estimate.unresolved_items.length}`, + `savings_status: ${estimate.savings_status}`, + `confidence: ${estimate.confidence}`, + `mcp_calls_used: ${estimate.mcp_calls_used}/${MAX_SHARED_REQUESTS}`, + `budget_exceeded: ${Boolean(estimate.budget_exceeded)}`, + ]; + return lines.join("\n"); +} + +export function publishEstimate(options = {}) { + const cwd = options.cwd ?? process.cwd(); + const draftPath = resolveWorkspacePath(options.draftPath, cwd); + const outputPath = resolveWorkspacePath(options.outputPath, cwd); + const manifestPath = options.manifestPath ? resolveWorkspacePath(options.manifestPath, cwd) : null; + const overwrite = Boolean(options.overwrite); + + assertNotSymlink(draftPath, "draftPath"); + assertNotSymlink(outputPath, "outputPath"); + if (manifestPath) assertNotSymlink(manifestPath, "manifestPath"); + + if (!fs.existsSync(draftPath)) { + throw new Error(`Draft file does not exist: ${draftPath}`); + } + if (fs.existsSync(outputPath) && !overwrite) { + const err = new Error(`refuse_on_exists: ${outputPath} already exists and overwrite is false`); + err.code = "REFUSE_ON_EXISTS"; + throw err; + } + + const estimate = JSON.parse(fs.readFileSync(draftPath, "utf8")); + for (const res of estimate.resources ?? []) { + if (Array.isArray(res.components) || res.unit_of_measure || res.tier_bands) { + res.monthly_cost = calculateLineCost(res); + delete res.components; + delete res.unit_of_measure; + delete res.tier_bands; + } else if (res.monthly_cost !== null && res.monthly_cost !== undefined) { + res.monthly_cost = round6(res.monthly_cost); + } + } + + const isCandidateMode = Array.isArray(estimate.decisions) && estimate.decisions.length > 0; + if (isCandidateMode) { + estimate.monthly_total = 0; + estimate.yearly_total = 0; + } else if ( + estimate.status === "COMPLETE" || + (Array.isArray(estimate.resources) && + estimate.resources.length > 0 && + estimate.resources.every((r) => r.monthly_cost !== null && r.monthly_cost !== undefined)) + ) { + const sum = round6(estimate.resources.reduce((acc, r) => acc + Number(r.monthly_cost ?? 0), 0)); + estimate.monthly_total = sum; + estimate.yearly_total = round6(sum * 12); + } + + let manifest = null; + if (manifestPath && estimate.status === "COMPLETE") { + manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + const serviceIds = new Set((manifest.services ?? []).map((s) => s.id)); + const totalsByService = new Map(); + for (const res of estimate.resources ?? []) { + const matchedId = matchManifestServiceId(res, serviceIds); + if (!matchedId) continue; + totalsByService.set(matchedId, round6((totalsByService.get(matchedId) ?? 0) + Number(res.monthly_cost))); + } + estimate.manifest_writeback = [...totalsByService.entries()].map(([id, cost]) => ({ + id, + cost_estimate_monthly_usd: cost, + cost_estimated_at: estimate.queried_at, + })); + } + + const validation = validateEstimate(estimate, { cwd, verifyEvidenceFiles: options.verifyEvidenceFiles !== false }); + if (!validation.ok) { + throw new Error(`Estimate validation failed: ${validation.errors.join("; ")}`); + } + + fs.writeFileSync(draftPath, `${JSON.stringify(estimate, null, 2)}\n`, "utf8"); + fs.renameSync(draftPath, outputPath); + + if (manifest && Array.isArray(estimate.manifest_writeback) && options.manifestWriteback !== false) { + const writebackMap = new Map(estimate.manifest_writeback.map((w) => [w.id, w])); + for (const service of manifest.services ?? []) { + const wb = writebackMap.get(service.id); + if (wb) { + service.cost_estimate_monthly_usd = wb.cost_estimate_monthly_usd; + service.cost_estimated_at = wb.cost_estimated_at; + } + } + const manifestTmp = `${manifestPath}.tmp`; + assertNotSymlink(manifestTmp, "manifestTmp"); + fs.writeFileSync(manifestTmp, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + fs.renameSync(manifestTmp, manifestPath); + + const manifestMdPath = path.join(path.dirname(manifestPath), "sku-manifest.md"); + if (fs.existsSync(RENDERER) && (fs.existsSync(manifestMdPath) || manifestPath.startsWith(ROOT))) { + spawnSync(process.execPath, [RENDERER, "--in", manifestPath, "--out", manifestMdPath], { + cwd: ROOT, + encoding: "utf8", + }); + } + } + + const summary = buildCompactSummary(estimate, outputPath); + return { estimate, summary }; +} + +export async function main(argv = process.argv.slice(2)) { + try { + const subcommand = argv[0] && !argv[0].startsWith("--") ? argv[0] : null; + const rawArgs = subcommand ? argv.slice(1) : argv; + const { values } = parseArgs({ + args: rawArgs, + options: { + preflight: { type: "boolean" }, + publish: { type: "boolean" }, + validate: { type: "boolean" }, + "legacy-audit": { type: "boolean" }, + input: { type: "string" }, + request: { type: "string" }, + draft: { type: "string" }, + output: { type: "string" }, + manifest: { type: "string" }, + overwrite: { type: "boolean" }, + help: { type: "boolean" }, + }, + }); + + const mode = + subcommand ?? + (values.preflight ? "preflight" : values.publish ? "publish" : values.validate ? "validate" : "help"); + + if (values.help || mode === "help") { + console.log( + "Usage:\n" + + " node tools/scripts/cost-estimate.mjs preflight --request REQUEST.json\n" + + " node tools/scripts/cost-estimate.mjs publish --draft DRAFT.json.tmp --output OUTPUT.json [--manifest sku-manifest.json] [--overwrite]\n" + + " node tools/scripts/cost-estimate.mjs validate --input OUTPUT.json [--legacy-audit]", + ); + return 0; + } + + if (mode === "preflight") { + const reqArg = values.request ?? values.input; + if (!reqArg) throw new Error("--request (or --input) is required for preflight"); + const reqPath = resolveWorkspacePath(reqArg); + const raw = JSON.parse(fs.readFileSync(reqPath, "utf8")); + const res = validateCostRequest(raw); + console.log( + JSON.stringify( + { + status: res.ok ? "PREFLIGHT_OK" : "PREFLIGHT_FAILED", + mode: res.normalized?.mode ?? null, + line_count: res.normalized?.lines.length ?? 0, + estimated_query_groups: res.normalized?.estimated_query_groups ?? 0, + unresolved_items: res.unresolved_items, + }, + null, + 2, + ), + ); + return res.ok ? 0 : 1; + } + + if (mode === "validate") { + if (!values.input) throw new Error("--input is required for validate"); + const target = resolveWorkspacePath(values.input); + const est = JSON.parse(fs.readFileSync(target, "utf8")); + const res = validateEstimate(est, { legacyAudit: Boolean(values["legacy-audit"]) }); + console.log(JSON.stringify({ ok: res.ok, errors: res.errors, warnings: res.warnings }, null, 2)); + return res.ok ? 0 : 1; + } + + if (mode === "publish") { + if (!values.draft || !values.output) { + throw new Error("--draft and --output are required for publish"); + } + const { summary } = publishEstimate({ + draftPath: values.draft, + outputPath: values.output, + manifestPath: values.manifest ?? null, + overwrite: Boolean(values.overwrite), + }); + console.log(summary); + return 0; + } + + throw new Error(`Unknown subcommand: ${mode}`); + } catch (error) { + console.error(`Cost estimate helper FAILED: ${error.message}`); + return 1; + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.exit(await main()); +} diff --git a/tools/scripts/fetch-azure-deprecations.mjs b/tools/scripts/fetch-azure-deprecations.mjs index 0f55a6976..b337cbb27 100644 --- a/tools/scripts/fetch-azure-deprecations.mjs +++ b/tools/scripts/fetch-azure-deprecations.mjs @@ -8,24 +8,16 @@ */ import { writeFileSync, existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import { XMLParser } from "fast-xml-parser"; const OUTPUT_FILE = ".github/data/azure-deprecations.json"; -const AZURE_UPDATES_RSS = "https://azure.microsoft.com/en-us/updates/feed/"; - -// Keywords that indicate deprecation in Azure Updates -const DEPRECATION_KEYWORDS = [ - "deprecat", - "retir", - "sunset", - "end of life", - "end-of-life", - "no longer support", - "will be removed", - "migration required", - "upgrade required", - "classic", -]; +// The old /updates/feed/ URL now 301s to an HTML page, which parsed as zero items. +const AZURE_UPDATES_RSS = "https://www.microsoft.com/releasecommunications/api/v2/azure/rss"; + +// is a comma-separated tag list, e.g. "Compute,Virtual Machines,Retirements". +const RETIREMENT_CATEGORY = "Retirements"; // Known deprecated SKUs/services (fallback when RSS fails) const KNOWN_DEPRECATIONS = [ @@ -77,6 +69,32 @@ const KNOWN_DEPRECATIONS = [ source: "manual", notes: "Basic tier has limited features, consider Premium for production", }, + { + service: "Azure Cache for Redis", + sku: "Enterprise, Enterprise Flash", + sunsetDate: "2027-03-31", + replacement: "Azure Managed Redis", + source: "manual", + notes: "https://learn.microsoft.com/azure/azure-cache-for-redis/retirement-faq", + addedDate: "2026-10-13", + }, + { + service: "Azure Cache for Redis", + sku: "Basic, Standard, Premium", + sunsetDate: "2028-09-30", + replacement: "Azure Managed Redis", + source: "manual", + notes: "https://learn.microsoft.com/azure/azure-cache-for-redis/retirement-faq", + addedDate: "2026-10-13", + }, + { + service: "Azure Functions", + sku: "Linux Consumption", + sunsetDate: "2028-09-30", + replacement: "Flex Consumption", + source: "manual", + addedDate: "2026-10-13", + }, ]; async function fetchAzureUpdates() { @@ -99,33 +117,7 @@ async function fetchAzureUpdates() { } const xml = await response.text(); - const parser = new XMLParser(); - const result = parser.parse(xml); - - const items = result?.rss?.channel?.item || []; - const deprecations = []; - - for (const item of items) { - const title = (item.title || "").toLowerCase(); - const description = (item.description || "").toLowerCase(); - const content = `${title} ${description}`; - - // Check if this update is about deprecation - const isDeprecation = DEPRECATION_KEYWORDS.some((keyword) => content.includes(keyword)); - - if (isDeprecation) { - deprecations.push({ - service: extractServiceName(item.title), - title: item.title, - link: item.link, - pubDate: item.pubDate, - source: "azure-updates-rss", - sunsetDate: extractSunsetDate(item.description), - replacement: null, // Would need NLP to extract - }); - } - } - + const deprecations = parseFeed(xml); console.log(`Found ${deprecations.length} deprecation notices in RSS feed`); return deprecations; } catch (error) { @@ -134,31 +126,113 @@ async function fetchAzureUpdates() { } } +export function parseFeed(xml) { + const result = new XMLParser().parse(xml); + const items = result?.rss?.channel?.item || []; + const deprecations = []; + + for (const item of items) { + const isRetirement = String(item.category ?? "") + .split(",") + .some((tag) => tag.trim() === RETIREMENT_CATEGORY); + if (!isRetirement) continue; + deprecations.push({ + service: extractServiceName(item.title), + title: item.title, + link: item.link, + pubDate: item.pubDate, + source: "azure-updates-rss", + sunsetDate: extractSunsetDate(item.title, item.description), + replacement: null, // Would need NLP to extract + }); + } + return deprecations; +} + function extractServiceName(title) { // Extract service name from title (basic extraction) - const match = title?.match(/Azure\s+([A-Za-z\s]+?)(?:\s+(?:is|will|retirement|deprecation))/i); - return match ? match[1].trim() : title?.split(" ").slice(0, 3).join(" ") || "Unknown"; + const bare = title?.replace(/^Retirement(?: Update)?:\s*/i, ""); + const match = bare?.match(/Azure\s+([A-Za-z\s]+?)(?:\s+(?:is|will|retirement|deprecation))/i); + return match ? match[1].trim() : bare?.split(" ").slice(0, 3).join(" ") || "Unknown"; } -function extractSunsetDate(description) { - // Try to extract date from description - const datePatterns = [ - /(?:by|on|after|before)\s+(\w+\s+\d{1,2},?\s+\d{4})/i, - /(\d{4}-\d{2}-\d{2})/, - /(\d{1,2}\/\d{1,2}\/\d{4})/, - ]; - - for (const pattern of datePatterns) { - const match = description?.match(pattern); - if (match) { - try { - return new Date(match[1]).toISOString().split("T")[0]; - } catch { - continue; - } - } +const MONTHS = [ + "january", + "february", + "march", + "april", + "may", + "june", + "july", + "august", + "september", + "october", + "november", + "december", +]; +const DATE_PATTERN = /\b(?:([A-Z][a-z]+)\s+(\d{1,2}),?\s+(\d{4})|(\d{1,2})\s+([A-Z][a-z]+)\s+(\d{4}))\b/g; +// A date introduced by one of these is a milestone (for example the start of wind-down), not the retirement. +const MILESTONE_LEAD = /\b(?:beginning|starting|starts?|from|since)\s+(?:on\s+)?$/i; +const RETIREMENT_CUE = /\b(?:retire[sd]?|retirement|ends?|sunset|removed|no longer)\b/i; + +function datesIn(text) { + const found = []; + for (const match of String(text ?? "").matchAll(DATE_PATTERN)) { + const monthName = (match[1] ?? match[5]).toLowerCase(); + const month = MONTHS.indexOf(monthName); + if (month < 0) continue; + const day = Number(match[2] ?? match[4]); + const year = Number(match[3] ?? match[6]); + const date = new Date(Date.UTC(year, month, day)); + if (date.getUTCMonth() !== month || date.getUTCDate() !== day) continue; + found.push({ index: match.index, iso: date.toISOString().split("T")[0] }); } - return null; + return found; +} + +/** The title states the retirement date when it carries one; descriptions often lead with an earlier milestone. */ +export function extractSunsetDate(title, description) { + const fromTitle = datesIn(title)[0]; + if (fromTitle) return fromTitle.iso; + + const text = String(description ?? ""); + const all = datesIn(text); + const isMilestone = ({ index }) => MILESTONE_LEAD.test(text.slice(Math.max(0, index - 20), index)); + const candidates = all.filter((date) => !isMilestone(date)); + const cued = candidates.find(({ index }) => { + const sentence = text + .slice(Math.max(0, index - 90), index) + .split(".") + .pop(); + return RETIREMENT_CUE.test(sentence); + }); + if (cued) return cued.iso; + // "Beginning , support for X is retiring": a milestone date whose own sentence says so is the retirement. + const milestoneRetirement = all.find( + (date) => isMilestone(date) && /\bretir/i.test(text.slice(date.index, date.index + 160).split(".")[0]), + ); + return (milestoneRetirement ?? candidates[0])?.iso ?? null; +} + +/** Merge feed items into the known list (feed items are identified by link) and stamp retired/upcoming status. */ +export function mergeDeprecations(known, feedItems, now = new Date()) { + const merged = [...known]; + for (const item of feedItems) { + const exists = merged.some((d) => + d.link ? d.link === item.link : d.service === item.service && d.sku === item.sku, + ); + if (!exists) merged.push(item); + } + for (const d of merged) { + if (d.sunsetDate) d.status = new Date(d.sunsetDate) <= now ? "retired" : "upcoming"; + } + // Soonest first; undated entries last. + merged.sort((a, b) => { + if (!a.sunsetDate) return 1; + if (!b.sunsetDate) return -1; + return new Date(a.sunsetDate) - new Date(b.sunsetDate); + }); + return merged; } async function main() { @@ -178,21 +252,7 @@ async function main() { const rssDeprecations = await fetchAzureUpdates(); // Merge with known deprecations (deduplicate by service+sku) - const allDeprecations = [...KNOWN_DEPRECATIONS]; - - for (const rss of rssDeprecations) { - const exists = allDeprecations.some((d) => d.service === rss.service && d.sku === rss.sku); - if (!exists) { - allDeprecations.push(rss); - } - } - - // Sort by sunset date (soonest first) - allDeprecations.sort((a, b) => { - if (!a.sunsetDate) return 1; - if (!b.sunsetDate) return -1; - return new Date(a.sunsetDate) - new Date(b.sunsetDate); - }); + const allDeprecations = mergeDeprecations(KNOWN_DEPRECATIONS, rssDeprecations); const output = { lastUpdated: new Date().toISOString(), @@ -222,4 +282,6 @@ async function main() { } } -main().catch(console.error); +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch(console.error); +} diff --git a/tools/scripts/fetch-retail-price-evidence.mjs b/tools/scripts/fetch-retail-price-evidence.mjs index 1cba5b725..f71ae9adb 100644 --- a/tools/scripts/fetch-retail-price-evidence.mjs +++ b/tools/scripts/fetch-retail-price-evidence.mjs @@ -5,7 +5,7 @@ import { createHash } from "node:crypto"; import { parseArgs } from "node:util"; const ENDPOINT = "https://prices.azure.com/api/retail/prices"; -const FILTERS = ["serviceName", "armSkuName", "armRegionName", "meterName", "priceType"]; +const FILTERS = ["serviceName", "armSkuName", "armRegionName", "productName", "skuName", "meterName", "priceType"]; const QUERY_KEYS = [...FILTERS, "currencyCode"]; function validateQuery(query) { @@ -60,15 +60,22 @@ function validateFailure(request) { if (retailQueryUrl(attempt.query).href === retailQueryUrl(request.query).href) matching = true; const response = attempt.response?.result ?? attempt.response; if (failure.kind === "empty_result") { + // Azure MCP pricing_get reports no paging metadata; status 200 with prices [] is its exhausted empty result. + const emptyPricingGet = + attempt.response?.status === 200 && + Array.isArray(attempt.response?.results?.prices) && + attempt.response.results.prices.length === 0; + const emptyRetailPage = + Array.isArray(response?.Items) && + response.Items.length === 0 && + response.Count === 0 && + response.NextPageLink === null; if ( attempt.response?.error || attempt.response?.isError || response?.error || !response || - !Array.isArray(response.Items) || - response.Items.length || - response.Count !== 0 || - response.NextPageLink !== null + !(emptyPricingGet || emptyRetailPage) ) { throw new Error("Empty-result fallback requires actual empty, exhausted MCP responses"); } @@ -80,6 +87,34 @@ function validateFailure(request) { ) { throw new Error("Transient fallback requires recorded retryable HTTP errors, not authentication failures"); } + } else if (failure.kind === "missing_tier_metadata") { + const prices = attempt.response?.results?.prices; + const matchingRows = Array.isArray(prices) + ? prices.filter( + (r) => + r && + typeof r === "object" && + r.serviceName === attempt.query.serviceName && + (!attempt.query.meterName || r.meterName === attempt.query.meterName) && + (!attempt.query.productName || r.productName === attempt.query.productName) && + (!attempt.query.skuName || r.skuName === attempt.query.skuName) && + Number.isFinite(r.retailPrice), + ) + : []; + const distinctPrices = new Set(matchingRows.map((r) => r.retailPrice)); + const omitsTierMinimum = + matchingRows.length >= 2 && matchingRows.every((r) => !Object.hasOwn(r, "tierMinimumUnits")); + if ( + attempt.response?.status !== 200 || + attempt.response?.error || + attempt.response?.isError || + !omitsTierMinimum || + distinctPrices.size < 2 + ) { + throw new Error( + "Missing-tier-metadata fallback requires a status-200 pricing_get response with multiple rate rows that omit tierMinimumUnits", + ); + } } else throw new Error("Unsupported MCP failure kind"); } if (!matching) throw new Error("Direct query must match a recorded failed MCP query"); diff --git a/tools/scripts/profile_debug_log.py b/tools/scripts/profile_debug_log.py index 91de838d5..8e928d4e5 100755 --- a/tools/scripts/profile_debug_log.py +++ b/tools/scripts/profile_debug_log.py @@ -57,22 +57,128 @@ ) +def _jsonl_record_to_span(rec: dict[str, Any]) -> dict[str, Any]: + """Convert a VS Code Copilot Chat JSONL event record into an OTel-shaped span.""" + if not isinstance(rec, dict) or ("type" not in rec and "name" not in rec): + raise ValueError("JSONL line is not a valid event object") + + raw_attrs = rec.get("attrs") if isinstance(rec.get("attrs"), dict) else {} + rtype = str(rec.get("type") or "") + rname = str(rec.get("name") or "") + + start_ns: str | None = None + end_ns: str | None = None + if isinstance(rec.get("ts"), (int, float)): + s_ns = int(rec["ts"] * 1_000_000) + dur_ms = int(rec.get("dur") or 0) if isinstance(rec.get("dur"), (int, float)) else 0 + start_ns = str(s_ns) + end_ns = str(s_ns + max(0, dur_ms) * 1_000_000) + + attributes: list[dict[str, Any]] = [] + + def add_attr(key: str, val: Any) -> None: + if val is None: + return + if isinstance(val, bool): + attributes.append({"key": key, "value": {"boolValue": val}}) + elif isinstance(val, int): + attributes.append({"key": key, "value": {"intValue": val}}) + elif isinstance(val, float): + attributes.append({"key": key, "value": {"doubleValue": val}}) + elif isinstance(val, str): + attributes.append({"key": key, "value": {"stringValue": val}}) + else: + attributes.append({"key": key, "value": {"stringValue": json.dumps(val)}}) + + span_name = rname + if rtype == "llm_request": + model = raw_attrs.get("model") or (rname.split(":", 1)[1] if rname.startswith("chat:") else rname or "unknown") + span_name = rname if rname.startswith("chat:") else f"chat:{model}" + add_attr("gen_ai.operation.name", "chat") + add_attr("gen_ai.request.model", str(model)) + add_attr("gen_ai.usage.input_tokens", raw_attrs.get("inputTokens")) + add_attr("gen_ai.usage.output_tokens", raw_attrs.get("outputTokens")) + elif rtype == "tool_call": + add_attr("gen_ai.operation.name", "execute_tool") + add_attr("gen_ai.tool.name", rname) + args_val = raw_attrs.get("args") + if args_val is not None: + add_attr( + "gen_ai.tool.call.arguments", + args_val if isinstance(args_val, str) else json.dumps(args_val), + ) + if raw_attrs.get("result") is not None: + add_attr("gen_ai.tool.call.result", str(raw_attrs.get("result"))) + + raw_status = rec.get("status") + status_obj: dict[str, Any] = {} + if isinstance(raw_status, dict): + status_obj = raw_status + elif raw_status in ("error", "failed", "ERROR", "FAILED") or raw_attrs.get("error"): + status_obj = { + "code": 2, + "message": str(raw_attrs.get("error") or raw_attrs.get("message") or raw_status or "error"), + } + + span: dict[str, Any] = { + "name": span_name, + "attributes": attributes, + "status": status_obj, + } + if rec.get("tid"): + span["traceId"] = str(rec["tid"]) + if rec.get("sid"): + span["spanId"] = str(rec["sid"]) + if rec.get("pid"): + span["parentSpanId"] = str(rec["pid"]) + if start_ns is not None: + span["startTimeUnixNano"] = start_ns + if end_ns is not None: + span["endTimeUnixNano"] = end_ns + if rtype == "subagent": + span["_jsonl_subagent"] = str(raw_attrs.get("agentName") or rname or "(unknown)") + return span + + def load_spans(path: Path) -> list[dict[str, Any]]: - """Read the log file and return a flat list of spans. + """Read an OTel JSON or VS Code JSONL debug log file and return a flat list of spans. Raises ``ValueError`` on malformed input so the CLI can surface a clean diagnostic instead of a stack trace. """ + text = path.read_text(encoding="utf-8") + stripped = text.strip() + if not stripped: + raise ValueError(f"{path}: empty log file") + try: - raw = json.loads(path.read_text()) + raw = json.loads(stripped) except json.JSONDecodeError as exc: - raise ValueError(f"{path}: not valid JSON ({exc.msg})") from exc + lines = [line.strip() for line in stripped.splitlines() if line.strip()] + if len(lines) <= 1 and path.suffix.lower() != ".jsonl": + raise ValueError(f"{path}: not valid JSON ({exc.msg})") from exc + spans: list[dict[str, Any]] = [] + for idx, line in enumerate(lines, start=1): + try: + rec = json.loads(line) + spans.append(_jsonl_record_to_span(rec)) + except (json.JSONDecodeError, ValueError) as line_exc: + raise ValueError(f"{path}:{idx}: invalid JSONL record ({line_exc})") from line_exc + if not spans: + raise ValueError(f"{path}: no spans found") from exc + return spans + + if isinstance(raw, dict) and ("ts" in raw or "type" in raw) and "resourceSpans" not in raw: + return [_jsonl_record_to_span(raw)] + + if not isinstance(raw, dict): + raise ValueError(f"{path}: missing resourceSpans[]") resource_spans = raw.get("resourceSpans") if not isinstance(resource_spans, list) or not resource_spans: raise ValueError(f"{path}: missing resourceSpans[]") - spans: list[dict[str, Any]] = [] + spans = [] for rs in resource_spans: for ss in rs.get("scopeSpans", []): spans.extend(ss.get("spans", [])) @@ -117,6 +223,23 @@ def _duration_s(span: dict[str, Any]) -> float: return max(0.0, (end - start) / 1e9) +def _interval_union_s(intervals_ns: list[tuple[int, int]]) -> float: + """Return merged non-overlapping duration in seconds from (start_ns, end_ns) pairs.""" + valid = sorted((s, e) for s, e in intervals_ns if e > s) + if not valid: + return 0.0 + total_ns = 0 + cur_s, cur_e = valid[0] + for s, e in valid[1:]: + if s <= cur_e: + cur_e = max(cur_e, e) + else: + total_ns += cur_e - cur_s + cur_s, cur_e = s, e + total_ns += cur_e - cur_s + return total_ns / 1e9 + + def _bucket(n: int) -> str: """Coarse histogram bucket for input-token distribution.""" if n < 10_000: @@ -209,6 +332,10 @@ def profile( session_start: int | None = None session_end: int | None = None agent_chat_wall = 0.0 + chat_intervals_ns: list[tuple[int, int]] = [] + tool_intervals_ns: list[tuple[int, int]] = [] + pricing_learn_calls = 0 + tool_stalls_over_60s = 0 seen_spans: set[tuple[str, str]] = set() duplicate_exported_spans = 0 @@ -223,11 +350,13 @@ def profile( attrs = _attrs(span) # Track overall session bounds for wall-time reporting. + span_bounds_ns: tuple[int, int] | None = None try: s = int(span["startTimeUnixNano"]) e = int(span["endTimeUnixNano"]) session_start = s if session_start is None else min(session_start, s) session_end = e if session_end is None else max(session_end, e) + span_bounds_ns = (s, e) except KeyError, ValueError, TypeError: pass @@ -263,6 +392,8 @@ def profile( row["output"] += out_tok row["output_samples"] += 1 agent_chat_wall += _duration_s(span) + if span_bounds_ns is not None: + chat_intervals_ns.append(span_bounds_ns) chat_since_boundary += 1 continue @@ -281,9 +412,19 @@ def profile( if attrs.get("gen_ai.operation.name") == "execute_tool": tname = attrs.get("gen_ai.tool.name") or name tool_calls[tname] += 1 + dur_s = _duration_s(span) + if span_bounds_ns is not None and tname not in ("vscode_askQuestions", "runSubagent"): + tool_intervals_ns.append(span_bounds_ns) + if dur_s >= 60.0 and tname not in ("vscode_askQuestions", "runSubagent"): + tool_stalls_over_60s += 1 args_blob = str(attrs.get("gen_ai.tool.call.arguments", "")) result_blob = str(attrs.get("gen_ai.tool.call.result", "")) tool_payload_bytes[tname] += len(args_blob.encode("utf-8")) + len(result_blob.encode("utf-8")) + if tname in ("mcp_azure_mcp_ser_pricing", "azure-mcp/pricing"): + with contextlib.suppress(json.JSONDecodeError, TypeError): + parsed_args = json.loads(args_blob) if args_blob else {} + if isinstance(parsed_args, dict) and parsed_args.get("learn") is True: + pricing_learn_calls += 1 if tname == "read_file": # Pull filePath out of the JSON arguments to map dupes. try: @@ -309,7 +450,10 @@ def profile( continue # Subagent invocations. - if name in ("challenger-review-subagent", "execution_subagent"): + subagent_label = span.get("_jsonl_subagent") or ( + name if name in ("challenger-review-subagent", "execution_subagent", "cost-estimate-subagent") else None + ) + if subagent_label: current = span visited = set() covered = identity in delegation_ancestors @@ -324,7 +468,7 @@ def profile( continue dur = _duration_s(span) subagent_wall += dur - subagent_calls.append({"name": name, "duration_s": round(dur, 2)}) + subagent_calls.append({"name": subagent_label, "duration_s": round(dur, 2)}) # Final inter-boundary check after the loop. inter_boundary_max = max(inter_boundary_max, chat_since_boundary) @@ -382,10 +526,14 @@ def profile( "challenger_invocations": sum(1 for c in subagent_calls if c["name"] == "challenger-review-subagent"), "error_spans_total": error_spans_raw_count, "error_spans_non_benign": len(error_spans), + "pricing_learn_calls": pricing_learn_calls, + "tool_stalls_over_60s": tool_stalls_over_60s, }, "wall_time_s": { "session_total": round(session_wall, 1), "agent_chat": round(agent_chat_wall, 1), + "agent_chat_union": round(_interval_union_s(chat_intervals_ns), 1), + "tool_execution_union": round(_interval_union_s(tool_intervals_ns), 1), "subagent": round(subagent_wall, 1), "user_wait_askquestions": round(user_wait_wall, 1), }, diff --git a/tools/scripts/refresh-azure-mcp-snapshot.mjs b/tools/scripts/refresh-azure-mcp-snapshot.mjs new file mode 100644 index 000000000..a6a12feb0 --- /dev/null +++ b/tools/scripts/refresh-azure-mcp-snapshot.mjs @@ -0,0 +1,140 @@ +#!/usr/bin/env node +// Regenerates tools/registry/azure-mcp-tools.json from a live Azure MCP server in the default namespace mode. +// Needs network access, azqr >= 3.0.0 on PATH (otherwise extension_azqr is not listed) and the npm allow-remote opt-in. + +import { spawn } from "node:child_process"; +import { readFileSync, renameSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseJsonc } from "./_lib/parse-jsonc.mjs"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const snapshotPath = resolve(repoRoot, "tools/registry/azure-mcp-tools.json"); +const REQUEST_TIMEOUT_MS = 120_000; + +export function sortKeys(value) { + if (Array.isArray(value)) return value.map(sortKeys); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, sortKeys(value[key])]), + ); + } + return value; +} + +export function parametersOf(inputSchema) { + const required = new Set(inputSchema?.required ?? []); + return Object.fromEntries(Object.keys(inputSchema?.properties ?? {}).map((name) => [name, required.has(name)])); +} + +// The learn reply is prose followed by a JSON array of { command, inputSchema } objects. +export function parseLearnReply(text) { + const start = text.indexOf("[{"); + const end = text.lastIndexOf("]"); + if (start < 0 || end < start) throw new Error("learn reply contains no command list"); + return JSON.parse(text.slice(start, end + 1)); +} + +export function buildSnapshot(serverVersion, listedTools, learnedByTool) { + const tools = {}; + for (const tool of listedTools) { + const wrapped = Object.hasOwn(tool.inputSchema?.properties ?? {}, "command"); + if (!wrapped) { + tools[tool.name] = { parameters: parametersOf(tool.inputSchema) }; + continue; + } + const commands = {}; + for (const entry of learnedByTool[tool.name] ?? []) { + commands[entry.command] = { parameters: parametersOf(entry.inputSchema) }; + } + tools[tool.name] = { commands }; + } + return sortKeys({ schemaVersion: 1, serverMode: "namespace", serverVersion, tools }); +} + +function pinnedVersion() { + const config = parseJsonc(readFileSync(resolve(repoRoot, ".vscode/mcp.json"), "utf8")); + const arg = (config.servers?.["azure-mcp"]?.args ?? []).find((a) => String(a).startsWith("@azure/mcp@")); + if (!arg) throw new Error("azure-mcp has no pinned @azure/mcp@ argument in .vscode/mcp.json"); + return arg.slice("@azure/mcp@".length); +} + +function connect(version) { + const child = spawn("npx", ["-y", `@azure/mcp@${version}`, "server", "start"], { + env: { ...process.env, npm_config_allow_remote: "all" }, + stdio: ["pipe", "pipe", "ignore"], + }); + let buffer = ""; + let nextId = 1; + const waiting = new Map(); + child.stdout.on("data", (chunk) => { + buffer += chunk; + let index; + while ((index = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, index).trim(); + buffer = buffer.slice(index + 1); + try { + const message = JSON.parse(line); + if (message.id && waiting.has(message.id)) waiting.get(message.id)(message); + } catch { + // Non-JSON lines are server logging. + } + } + }); + const send = (method, params) => + new Promise((resolveReply, reject) => { + const id = nextId++; + const timer = setTimeout(() => reject(new Error(`${method} timed out`)), REQUEST_TIMEOUT_MS); + waiting.set(id, (message) => { + clearTimeout(timer); + resolveReply(message); + }); + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`); + }); + child.on("error", (error) => { + throw error; + }); + return { child, send }; +} + +async function main() { + const version = pinnedVersion(); + const { child, send } = connect(version); + try { + await send("initialize", { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "refresh", version: "0" }, + }); + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })}\n`); + const listed = (await send("tools/list", {})).result?.tools ?? []; + if (!listed.some((tool) => tool.name === "extension_azqr")) { + throw new Error("extension_azqr is not listed; put azqr 3.0.0 or later on PATH (.devcontainer/install-azqr.sh)"); + } + const learned = {}; + for (const tool of listed.filter((t) => Object.hasOwn(t.inputSchema?.properties ?? {}, "command"))) { + const reply = await send("tools/call", { name: tool.name, arguments: { learn: true, intent: "" } }); + const text = (reply.result?.content ?? []).map((c) => c.text).join("\n"); + learned[tool.name] = parseLearnReply(text); + } + const snapshot = buildSnapshot(version, listed, learned); + const temp = `${snapshotPath}.tmp`; + writeFileSync(temp, `${JSON.stringify(snapshot, null, 2)}\n`); + renameSync(temp, snapshotPath); + console.log(`Wrote ${snapshotPath} for @azure/mcp@${version}: ${Object.keys(snapshot.tools).length} tools`); + } finally { + child.kill(); + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().then( + () => process.exit(0), + (error) => { + console.error(`Snapshot refresh failed; the committed snapshot is unchanged: ${error.message}`); + process.exit(1); + }, + ); +} diff --git a/tools/scripts/render-session-handoff.mjs b/tools/scripts/render-session-handoff.mjs index add565dda..c306e0d32 100644 --- a/tools/scripts/render-session-handoff.mjs +++ b/tools/scripts/render-session-handoff.mjs @@ -45,7 +45,11 @@ export function renderHandoff(view, { owner, operation }) { ]; const sections = ARTIFACT_HEADINGS["00-handoff.md"]; const bodies = [ - Object.entries(session.steps).map(([step, value]) => `- Step ${inline(step)}: ${inline(value.status)}.`), + Object.entries(session.steps).map(([step, value]) => + value.status === "stale" + ? `- Step ${inline(step)}: stale (${inline(value.stale_because || "an upstream step was reopened")}); redo after its predecessor.` + : `- Step ${inline(step)}: ${inline(value.status)}.`, + ), ["iac_tool", "region", "deployment_strategy", "plan_status"] .filter((key) => session.decisions?.[key] !== undefined) .map((key) => `- ${key}: ${inline(session.decisions[key])}`), diff --git a/tools/scripts/safe-shell.mjs b/tools/scripts/safe-shell.mjs index 3e24e6ccf..a2371307b 100644 --- a/tools/scripts/safe-shell.mjs +++ b/tools/scripts/safe-shell.mjs @@ -91,6 +91,15 @@ const RULES = [ fix: 'append `|| true`, `|| echo ""`, or pipe to another command', context: "set-e", }, + { + id: "node-e-double-quoted-bang", + // Double-quoted `node -e "... !identifier ..."` triggers Bash history + // expansion (`bash: !fs.existsSync: event not found`) and stalls the + // persistent terminal session for ~300s waiting for prompt recovery. + pattern: /\bnode\s+(?:--input-type=\S+\s+)?-(?:e|-eval)\s+"[^"]*![a-zA-Z_$!(]/, + why: 'double-quoted `node -e "... !x ..."` triggers Bash history expansion and hangs the terminal session', + fix: "prefer a committed script in `tools/scripts/*.mjs`, or pass single-quoted `node -e '...'` after `set +H`", + }, ]; // Portability tools that should not be invoked bare in committed shell diff --git a/tools/scripts/sync-draft-contract-hashes.mjs b/tools/scripts/sync-draft-contract-hashes.mjs index 6504925ef..f96701591 100644 --- a/tools/scripts/sync-draft-contract-hashes.mjs +++ b/tools/scripts/sync-draft-contract-hashes.mjs @@ -25,6 +25,7 @@ export function syncDraft(contractPath, { expectedSha, reason, state }) { if (path.basename(contractPath) !== "04-iac-contract.json") throw new Error("Only the draft IaC contract is supported"); const inputs = new Map(); + const digests = new Map(); for (const key of ["plan_ref", "l1m_ref"]) { const reference = contract[key]; if (!reference?.path) throw new Error(`Missing ${key} reference`); @@ -34,12 +35,28 @@ export function syncDraft(contractPath, { expectedSha, reason, state }) { } const digest = hash(fs.readFileSync(source)); inputs.set(source, digest); + digests.set(key, digest); reference.sha256 = digest; } if (hash(fs.readFileSync(contractPath)) !== expectedSha) throw new Error("Contract changed during synchronization"); for (const [source, digest] of inputs) if (hash(fs.readFileSync(source)) !== digest) throw new Error("Referenced input changed"); - return { text: `${JSON.stringify(contract, null, 2)}\n`, reason, input_hashes: Object.fromEntries(inputs) }; + return { + text: stampText(original.toString("utf8"), digests, contract), + reason, + input_hashes: Object.fromEntries(inputs), + }; +} + +// Replacing only the digest bytes keeps the file's formatting; re-serializing reformats it and breaks later edits. +function stampText(original, digests, contract) { + let text = original; + for (const [key, digest] of digests) { + const pattern = new RegExp(`("${key}"\\s*:\\s*\\{[^{}]*?"sha256"\\s*:\\s*")[a-f0-9]{64}(")`); + if (!pattern.test(text)) return `${JSON.stringify(contract, null, 2)}\n`; + text = text.replace(pattern, `$1${digest}$2`); + } + return text; } export function main(argv = process.argv.slice(2)) { @@ -49,12 +66,13 @@ export function main(argv = process.argv.slice(2)) { project: { type: "string" }, "expected-sha": { type: "string" }, reason: { type: "string" }, + write: { type: "boolean" }, help: { type: "boolean" }, }, }); if (values.help) { console.log( - "Usage: sync-draft-contract-hashes.mjs --project NAME --expected-sha SHA --reason TEXT\nProduces corrected draft JSON on stdout only. Never writes findings or approved artifacts.", + "Usage: sync-draft-contract-hashes.mjs --project NAME --expected-sha SHA --reason TEXT [--write]\nUpdates only plan_ref/l1m_ref sha256 values and keeps the file's formatting. Prints the corrected draft on stdout unless --write replaces the contract atomically. Never writes findings or approved artifacts.", ); return 0; } @@ -62,12 +80,17 @@ export function main(argv = process.argv.slice(2)) { const root = process.env.APEX_ROOT || path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const result = spawnSync("apex-recall", ["show", values.project, "--json"], { cwd: root, encoding: "utf8" }); if (result.status !== 0) throw new Error("Authoritative project state unavailable"); - const output = syncDraft(path.join(root, "agent-output", values.project, "04-iac-contract.json"), { + const contractPath = path.join(root, "agent-output", values.project, "04-iac-contract.json"); + const output = syncDraft(contractPath, { expectedSha: values["expected-sha"], reason: values.reason, state: JSON.parse(result.stdout).session, }); - process.stdout.write(output.text); + if (values.write) { + const temporary = `${contractPath}.sync-${process.pid}`; + fs.writeFileSync(temporary, output.text, { flag: "wx" }); + fs.renameSync(temporary, contractPath); + } else process.stdout.write(output.text); return 0; } diff --git a/tools/scripts/validate-challenger-findings.mjs b/tools/scripts/validate-challenger-findings.mjs index 17cbff341..e5fcc283d 100644 --- a/tools/scripts/validate-challenger-findings.mjs +++ b/tools/scripts/validate-challenger-findings.mjs @@ -77,6 +77,24 @@ const REQUIRED_CACHE_FIELDS = [ "artifact_hash", ]; const VALID_SEVERITY = new Set(["must_fix", "should_fix", "suggestion"]); +const MUTABLE_INPUT_NAMES = new Set([ + "sku-manifest.json", + "sku-manifest.md", + "00-handoff.md", + "00-session-state.json", + "README.md", +]); + +// A later step must edit these files, so freezing one as review input guarantees a stale review. +export function mutableInputProblem(inputPath) { + const name = path.basename(String(inputPath)); + if (!MUTABLE_INPUT_NAMES.has(name) && !name.endsWith("-decisions.json")) return null; + return ( + `${name} is a mutable parent-owned file that a later step must change, so a review that freezes it ` + + "fails strict freshness. Remove it from supporting inputs and quote what you read from it in the review " + + "text instead (for sku-manifest.json, its current_revision)." + ); +} let r; @@ -144,6 +162,8 @@ function verifyCache(file, doc) { const resolved = path.resolve(input.path); if (seen.has(resolved)) throw new Error("duplicate supporting input"); seen.add(resolved); + const mutable = mutableInputProblem(input.path); + if (mutable) throw new Error(mutable); if (cacheInputs(input.path).artifact_sha !== input.sha256) throw new Error("supporting bytes changed"); } catch (error) { r.error(`${file}: invalid supporting input: ${error.message}`); @@ -286,6 +306,10 @@ export function runValidator(args = process.argv.slice(2)) { if (values.metadata !== undefined) metadata.cache_inputs = cacheInputs(values.metadata); if (values["supporting-input"]) { if (values.metadata === undefined) throw new Error("--supporting-input requires --metadata"); + for (const input of values["supporting-input"]) { + const mutable = mutableInputProblem(input); + if (mutable) throw new Error(mutable); + } metadata.supporting_inputs = [...new Set(values["supporting-input"])].map((input) => ({ path: input, sha256: cacheInputs(input).artifact_sha, diff --git a/tools/scripts/validate-challenger-presence.mjs b/tools/scripts/validate-challenger-presence.mjs index 599b4dbc3..b15d3da14 100644 --- a/tools/scripts/validate-challenger-presence.mjs +++ b/tools/scripts/validate-challenger-presence.mjs @@ -106,6 +106,34 @@ function reviewSidecar(sessionStatePath, defaultSidecar) { return defaultSidecar; } +const COST_FILES = /^(02-cost-estimate|02-cost-candidates|03-des-cost-estimate).*\.json$/; + +// Same rule as apex-recall's Step 2 completion check; top level only, `_meta/` is the archive. +function step2Residue(projectDir) { + const found = []; + for (const entry of fs.readdirSync(projectDir, { withFileTypes: true })) { + if (!entry.isFile()) continue; + if (entry.name.endsWith(".tmp.md")) found.push(`${entry.name}: scratch file`); + else if (COST_FILES.test(entry.name)) { + try { + const doc = JSON.parse(fs.readFileSync(path.join(projectDir, entry.name), "utf-8")); + if (doc?.status === "FAILED") found.push(`${entry.name}: cost estimate with status FAILED`); + } catch { + // unreadable files are not this check's concern + } + } + } + return found; +} + +function step2Complete(sessionStatePath) { + try { + return JSON.parse(fs.readFileSync(sessionStatePath, "utf-8"))?.steps?.["2"]?.status === "complete"; + } catch { + return false; + } +} + const projects = listProjects(ROOT); if (projects.length === 0) { console.log(" ℹ️ No projects under agent-output/ — nothing to validate.\n"); @@ -148,6 +176,14 @@ for (const project of projects) { `challenger-review-subagent (or 10-Challenger agent) to produce it.`, ); } + if (step2Complete(sessionState)) { + for (const item of step2Residue(projectDir)) { + r.error( + `${project}/Step 2 Architecture`, + `${item}. Move superseded or failed cost files to ${projectDir}/_meta/ and delete scratch *.tmp.md files.`, + ); + } + } } r.summary(); diff --git a/tools/scripts/validate-iac-contract-consistency.mjs b/tools/scripts/validate-iac-contract-consistency.mjs index 73799c15d..9ff1a6bec 100644 --- a/tools/scripts/validate-iac-contract-consistency.mjs +++ b/tools/scripts/validate-iac-contract-consistency.mjs @@ -54,6 +54,15 @@ function extractPlanReferences(plan) { }; } +// Returns the body of the plan's `### Raw Resource Exceptions` section, or null when the plan has none. +function rawExceptionsSection(plan) { + const lines = plan.split("\n"); + const start = lines.findIndex((line) => /^###\s+Raw Resource Exceptions\s*$/i.test(line)); + if (start < 0) return null; + const end = lines.findIndex((line, index) => index > start && /^#{1,3}\s/.test(line)); + return lines.slice(start + 1, end < 0 ? undefined : end).join("\n"); +} + function defaultGlobs() { return ["agent-output/*/04-iac-contract.json"]; } @@ -148,6 +157,26 @@ function main() { } } } + // A resource without an AVM module entry is raw and needs a structured exception record in the plan. + const moduled = new Set((contract.modules?.[track] ?? []).map((m) => m.resource_logical_name)); + const raw = (contract.resources ?? []).filter((res) => !moduled.has(res.logical_name)); + const exceptions = raw.length > 0 ? rawExceptionsSection(planText) : null; + if (raw.length > 0 && exceptions === null) { + r.warn( + contractRel, + `Plan has no "### Raw Resource Exceptions" section; raw resources without an exception record: ${raw.map((res) => res.logical_name).join(", ")}`, + ); + } else if (exceptions !== null) { + const body = exceptions.toLowerCase(); + for (const res of raw) { + if (!body.includes(res.type.toLowerCase()) && !body.includes(res.logical_name.toLowerCase())) { + r.error( + contractRel, + `Raw resource "${res.logical_name}" (${res.type}) has no row in "### Raw Resource Exceptions"; add a record per cost-alerts-baseline.md.`, + ); + } + } + } r.ok(contractRel, `contract ↔ plan consistent (${contract.resources.length} resources, plan_ref hash OK)`); } diff --git a/tools/scripts/validate-iac-contract.mjs b/tools/scripts/validate-iac-contract.mjs index bf91f9881..71db63666 100644 --- a/tools/scripts/validate-iac-contract.mjs +++ b/tools/scripts/validate-iac-contract.mjs @@ -24,6 +24,9 @@ * 7. plan_ref.sha256 + l1m_ref.sha256 match the actual file content when * the referenced files exist on disk (WARN when files missing — common * during local dev before plan is finalised). + * 8. Private paths (only for resources declaring phase/consumes/target): + * a depends_on target may not be in a later phase, and a resource that + * consumes a resource with a private endpoint must depend_on one. * * Usage: * node tools/scripts/validate-iac-contract.mjs @@ -40,6 +43,7 @@ import { readJson, sha256File } from "./_lib/json.mjs"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const SCHEMA_PATH = path.join(ROOT, "tools/schemas/iac-contract.schema.json"); +const STAGES = ["deploy", "post-deploy", "go-live"]; function checkUniqueLogicalNames(data, fileRel, r) { const seen = new Set(); @@ -135,11 +139,14 @@ function checkScheduledActions(data, fileRel, reporter) { } } -function checkCapabilityReadiness(data, fileRel, reporter, required) { +function checkCapabilityReadiness(data, fileRel, reporter, required, stage = "deploy") { if (!data.capability_checks) { if (required) reporter.error(fileRel, "Capability coverage absent; owner migration required for readiness mode"); return; } + // A capability without `blocks` blocks every stage, which is the behaviour before the field existed. + const blocksStage = (capability) => + !capability.blocks?.length || capability.blocks.some((blocked) => STAGES.indexOf(blocked) <= STAGES.indexOf(stage)); const checks = new Map(data.capability_checks.map((entry) => [entry.id, entry])); const visit = (capability, trail = new Set()) => { if (trail.has(capability.id)) { @@ -166,7 +173,13 @@ function checkCapabilityReadiness(data, fileRel, reporter, required) { if (capability.status === "designed" && (capability.required_now || capability.security_obligation)) visit(capability); if ((capability.required_now || capability.security_obligation) && capability.status === "unresolved") { - reporter.error(fileRel, `Required design path unresolved: ${capability.id}`); + if (blocksStage(capability)) reporter.error(fileRel, `Required design path unresolved: ${capability.id}`); + else { + reporter.warn( + fileRel, + `Capability ${capability.id} is unresolved and blocks ${capability.blocks.join(", ")}; not blocking stage ${stage}`, + ); + } } if ( capability.status === "deferred" && @@ -186,6 +199,17 @@ function checkCapabilityReadiness(data, fileRel, reporter, required) { } } +function checkPhaseParameter(data, fileRel, r) { + for (const param of data.params ?? []) { + if ((param.name === "phase" || param.name === "deployment_phase") && "default" in param) { + r.error( + fileRel, + `params["${param.name}"] must be required with no default: a default deploys every phase when it is omitted and bypasses the per-phase approval gates`, + ); + } + } +} + function checkDependsOn(data, fileRel, r) { const names = new Set((data.resources ?? []).map((res) => res.logical_name)); const graph = new Map(); @@ -204,6 +228,46 @@ function checkDependsOn(data, fileRel, r) { detectCycles(graph, fileRel, r); } +// Applies only to resources that declare `phase`, `consumes` or `target`; contracts without them are unchanged. +function checkPrivatePaths(data, fileRel, r) { + const resources = new Map((data.resources ?? []).map((res) => [res.logical_name, res])); + const endpointsByTarget = new Map(); + for (const res of resources.values()) { + if (res.target === undefined) continue; + if (res.type !== "Microsoft.Network/privateEndpoints") { + r.error(fileRel, `resources["${res.logical_name}"].target is only valid on Microsoft.Network/privateEndpoints`); + } else if (!resources.has(res.target)) { + r.error(fileRel, `resources["${res.logical_name}"].target "${res.target}" not in resources[]`); + } else { + endpointsByTarget.set(res.target, [...(endpointsByTarget.get(res.target) ?? []), res.logical_name]); + } + } + for (const res of resources.values()) { + for (const dep of res.depends_on ?? []) { + const earlier = resources.get(dep); + if (res.phase !== undefined && earlier?.phase !== undefined && earlier.phase > res.phase) { + r.error( + fileRel, + `resources["${res.logical_name}"] (phase ${res.phase}) depends_on "${dep}" in later phase ${earlier.phase}`, + ); + } + } + for (const consumed of res.consumes ?? []) { + if (consumed === res.logical_name || !resources.has(consumed)) { + r.error(fileRel, `resources["${res.logical_name}"].consumes "${consumed}" must name another resource`); + continue; + } + const endpoints = endpointsByTarget.get(consumed) ?? []; + if (endpoints.length > 0 && !endpoints.some((endpoint) => (res.depends_on ?? []).includes(endpoint))) { + r.error( + fileRel, + `resources["${res.logical_name}"] consumes "${consumed}" but does not depends_on its private endpoint (${endpoints.join(", ")}); create the endpoint first`, + ); + } + } + } +} + function detectCycles(graph, fileRel, r) { const WHITE = 0; const GRAY = 1; @@ -260,12 +324,22 @@ function main() { const rawArgs = process.argv.slice(2); if (rawArgs.includes("--help")) { console.log( - "Usage: validate-iac-contract.mjs [artifact-path-or-glob ...] [--readiness]\nNo paths: scan project contracts. Readiness requires capability coverage; it does not grant approval.", + "Usage: validate-iac-contract.mjs [artifact-path-or-glob ...] [--readiness] [--stage deploy|post-deploy|go-live]\nNo paths: scan project contracts. Readiness requires capability coverage; it does not grant approval.\n--stage (default deploy): an unresolved capability fails only when its `blocks` list includes that stage or an earlier one.", ); return; } const readiness = rawArgs.includes("--readiness"); - const args = rawArgs.filter((arg) => arg !== "--readiness"); + const stageIndex = rawArgs.indexOf("--stage"); + const stage = stageIndex >= 0 ? rawArgs[stageIndex + 1] : "deploy"; + if (!STAGES.includes(stage)) { + r.error("--stage", `must be one of ${STAGES.join(", ")}`); + r.summary(); + r.exitOnError("Invalid --stage"); + return; + } + const args = rawArgs.filter( + (arg, index) => arg !== "--readiness" && arg !== "--stage" && (stageIndex < 0 || index !== stageIndex + 1), + ); const patterns = args.length > 0 ? args : defaultGlobs(); let files = []; @@ -305,8 +379,10 @@ function main() { checkIdentity(data, fileRel, r); checkDiagnosticsParam(data, fileRel, r); checkScheduledActions(data, fileRel, r); - checkCapabilityReadiness(data, fileRel, r, readiness); + checkCapabilityReadiness(data, fileRel, r, readiness, stage); + checkPhaseParameter(data, fileRel, r); checkDependsOn(data, fileRel, r); + checkPrivatePaths(data, fileRel, r); checkRefHashes(data, fileRel, r); if (r.errors === errorsBefore) { r.ok( diff --git a/tools/scripts/validate-iac-security-baseline.mjs b/tools/scripts/validate-iac-security-baseline.mjs index b245ffbd5..6758caf03 100644 --- a/tools/scripts/validate-iac-security-baseline.mjs +++ b/tools/scripts/validate-iac-security-baseline.mjs @@ -15,6 +15,8 @@ * 7. App Service HTTP/2 enabled * 8. MySQL/PostgreSQL SSL enforcement * 9. Container Registry admin user disabled + * 10. ALZ Corp Deny floor: Key Vault soft delete, purge protection and RBAC model, + * no NIC IP forwarding, no hybrid-networking or classic resource types * * Enforces Golden Principle #10: Mechanical Enforcement Over Documentation. * @@ -102,6 +104,20 @@ const BICEP_VIOLATIONS = [ [/http20Enabled\s*:\s*false/i, "App Service HTTP/2 should be enabled (http20Enabled must be true)"], // --- MUST-FAIL: Container Registry admin user --- [/adminUserEnabled\s*:\s*true/i, "Container Registry admin user must be disabled — use managed identity"], + // --- MUST-FAIL: ALZ Corp Deny floor --- + [/enableRbacAuthorization\s*:\s*false/i, "ALZ Corp: Key Vault must use the Azure RBAC permission model"], + [/enableSoftDelete\s*:\s*false/i, "ALZ Corp: Key Vault soft delete must be enabled"], + [/enablePurgeProtection\s*:\s*false/i, "ALZ Corp: Key Vault purge protection must be enabled"], + [/enableIPForwarding\s*:\s*true/i, "ALZ Corp: network interface IP forwarding must be disabled"], + [/Microsoft\.Classic(?:Compute|Network|Storage)\//i, "ALZ Corp: classic resource types are denied"], + [ + /Microsoft\.Network\/(?:expressRoute(?:Circuits|Gateways|Ports)|virtualWans|virtualHubs|vpnGateways|p2svpnGateways|vpnSites|virtualNetworkGateways)\b/i, + "ALZ Corp: hybrid networking resource types are denied in the project", + ], + [ + /avm\/res\/network\/(?:express-route-circuit|virtual-network-gateway|vpn-gateway|p2s-vpn-gateway|virtual-wan|virtual-hub)\b/i, + "ALZ Corp: hybrid networking resource types are denied in the project", + ], ]; // --- Bicep WARN-ONLY patterns (flag but don't block) --- @@ -164,6 +180,21 @@ const TERRAFORM_VIOLATIONS = [ [/http2_enabled\s*=\s*false/i, "App Service HTTP/2 should be enabled (http2_enabled must be true)"], // --- MUST-FAIL: Container Registry admin user --- [/admin_enabled\s*=\s*true/i, "Container Registry admin user must be disabled — use managed identity"], + // --- MUST-FAIL: ALZ Corp Deny floor --- + [ + /(?:rbac_authorization_enabled|enable_rbac_authorization)\s*=\s*false/i, + "ALZ Corp: Key Vault must use the Azure RBAC permission model", + ], + [/purge_protection_enabled\s*=\s*false/i, "ALZ Corp: Key Vault purge protection must be enabled"], + [/ip_forwarding_enabled\s*=\s*true/i, "ALZ Corp: network interface IP forwarding must be disabled"], + [ + /resource\s+"azurerm_(?:express_route_circuit|express_route_port|virtual_network_gateway|vpn_gateway|point_to_site_vpn_gateway|virtual_wan|virtual_hub)"/i, + "ALZ Corp: hybrid networking resource types are denied in the project", + ], + [ + /Azure\/avm-res-network-(?:expressroutecircuit|virtualnetworkgateway|vpngateway|virtualwan|virtualhub)\//i, + "ALZ Corp: hybrid networking resource types are denied in the project", + ], ]; // --- Terraform WARN-ONLY patterns --- diff --git a/tools/scripts/validate-mcp-tool-refs.mjs b/tools/scripts/validate-mcp-tool-refs.mjs new file mode 100644 index 000000000..187de92e6 --- /dev/null +++ b/tools/scripts/validate-mcp-tool-refs.mjs @@ -0,0 +1,170 @@ +#!/usr/bin/env node +// Checks every mcp_azure-mcp_* reference in agent and skill markdown against the committed Azure MCP tool snapshot. +// Offline by design; refresh the snapshot with `npm run refresh:mcp-tool-snapshot` when the pin changes. + +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseJsonc } from "./_lib/parse-jsonc.mjs"; +import { Reporter } from "./_lib/reporter.mjs"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const IGNORE_MARKER = "mcp-ref:ignore"; +const WRAPPER_KEYS = new Set(["command", "intent", "parameters", "learn"]); +const WINDOW_LINES = 4; +// Docs write mcp_azure-mcp_; VS Code lists the same tool as mcp_azure_mcp_ser_. +const PREFIX = "mcp_azure[-_]mcp_(?:ser_)?"; + +const words = (text, pattern) => [...text.matchAll(pattern)].map((match) => match[1]); + +function commandsIn(windowText, toolName) { + const found = new Set(); + for (const pattern of [ + /command:\s*["'`]([A-Za-z0-9_-]+)["'`]/g, + /command\s+`([A-Za-z0-9_-]+)`/g, + /\(command\s+([A-Za-z0-9_-]+)\)/g, + new RegExp(`\\b(${toolName}_[a-z0-9_-]+)`, "g"), + ]) { + for (const command of words(windowText, pattern)) found.add(command); + } + for (const match of windowText.matchAll(/`command`\s+to\s+`([^`]+)`(?:\s+or\s+`([^`]+)`)?/g)) { + found.add(match[1]); + if (match[2]) found.add(match[2]); + } + return found; +} + +function keysOf(text) { + return words(text, /["']?([A-Za-z][\w-]*)["']?\s*:/g); +} + +function yamlBlockKeys(lines, headerPattern) { + const keys = []; + lines.forEach((line, index) => { + const header = line.match(headerPattern); + if (!header) return; + const baseIndent = header[1].length; + for (const next of lines.slice(index + 1)) { + const indent = next.match(/^(\s*)/)[1].length; + if (!next.trim() || indent <= baseIndent) break; + const key = next.match(/^\s+["']?([A-Za-z][\w-]*)["']?\s*:/); + if (key) keys.push(key[1]); + } + }); + return keys; +} + +function parametersIn(windowLines, windowText, direct) { + const keys = []; + for (const match of windowText.matchAll(/parameters:\s*\{([^}]*)\}/g)) keys.push(...keysOf(match[1])); + keys.push(...yamlBlockKeys(windowLines, /^(\s*)parameters:\s*$/)); + keys.push(...words(windowText, /\(parameters?:\s*`([^`]+)`\)/g)); + if (direct) { + for (const match of windowText.matchAll(/\(\{([^}]*)\}\)/g)) keys.push(...keysOf(match[1])); + keys.push(...yamlBlockKeys(windowLines, new RegExp(`^()\\S.*${PREFIX}\\w+\\s*$`))); + } + return keys.filter((key) => !WRAPPER_KEYS.has(key)); +} + +// Returns [{ line, message }] for each reference the snapshot cannot back. +export function scanText(text, snapshot) { + const lines = text.split(/\r?\n/); + const problems = []; + lines.forEach((line, index) => { + const matches = [...line.matchAll(new RegExp(`${PREFIX}([a-z][a-z0-9_]*)`, "g"))]; + matches.forEach((match, matchIndex) => { + if (line.includes(IGNORE_MARKER) || (lines[index - 1] ?? "").includes(IGNORE_MARKER)) return; + const toolName = match[1]; + // A prefix followed by a placeholder such as is documentation, not a reference. + if (toolName.endsWith("_") && line[match.index + match[0].length] === "<") return; + const tool = snapshot.tools[toolName]; + if (!tool) { + problems.push({ line: index + 1, message: `unknown tool mcp_azure-mcp_${toolName}` }); + return; + } + const sameLineEnd = matches[matchIndex + 1]?.index ?? line.length; + const following = []; + for (const next of lines.slice(index + 1, index + 1 + WINDOW_LINES)) { + if (!next.trim() || next.trimStart().startsWith("```") || new RegExp(PREFIX).test(next)) break; + following.push(next); + } + const windowLines = [line.slice(match.index, sameLineEnd), ...following]; + const windowText = windowLines.join("\n"); + const direct = !tool.commands; + let commandSchema; + if (!direct) { + const commands = commandsIn(windowText, toolName); + for (const command of commands) { + if (!tool.commands[command]) { + problems.push({ line: index + 1, message: `mcp_azure-mcp_${toolName} has no command "${command}"` }); + } + } + if (commands.size === 1) commandSchema = tool.commands[[...commands][0]]; + if (!commandSchema) return; + } + const known = (direct ? tool : commandSchema).parameters; + for (const key of new Set(parametersIn(windowLines, windowText, direct))) { + if (!Object.hasOwn(known, key)) { + problems.push({ line: index + 1, message: `mcp_azure-mcp_${toolName} has no parameter "${key}"` }); + } + } + }); + }); + return problems; +} + +export function pinProblems(root, snapshot) { + const problems = []; + const config = parseJsonc(readFileSync(join(root, ".vscode/mcp.json"), "utf8")); + const arg = (config.servers?.["azure-mcp"]?.args ?? []).find((value) => String(value).startsWith("@azure/mcp@")); + const workspacePin = arg?.slice("@azure/mcp@".length); + const defaultsPin = readFileSync(join(root, ".devcontainer/configure-mcp.mjs"), "utf8").match( + /@azure\/mcp@(\d+\.\d+\.\d+)/, + )?.[1]; + if (workspacePin !== snapshot.serverVersion) { + problems.push(`.vscode/mcp.json pins ${workspacePin}; snapshot is for ${snapshot.serverVersion}`); + } + if (defaultsPin !== snapshot.serverVersion) { + problems.push(`.devcontainer/configure-mcp.mjs pins ${defaultsPin}; snapshot is for ${snapshot.serverVersion}`); + } + return problems; +} + +function markdownFiles(directory) { + if (!existsSync(directory)) return []; + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const full = join(directory, entry.name); + if (entry.isDirectory()) return markdownFiles(full); + return entry.name.endsWith(".md") ? [full] : []; + }); +} + +function main() { + const r = new Reporter("Azure MCP Tool Reference Validator"); + r.header(); + const snapshotPath = join(repoRoot, "tools/registry/azure-mcp-tools.json"); + if (!existsSync(snapshotPath)) { + r.error("tools/registry/azure-mcp-tools.json", "Missing snapshot; run npm run refresh:mcp-tool-snapshot"); + r.summary(); + r.exitOnError(); + } + const snapshot = JSON.parse(readFileSync(snapshotPath, "utf8")); + r.tick(); + const pins = pinProblems(repoRoot, snapshot); + for (const message of pins) r.error("pin", message); + if (!pins.length) r.ok("pin", `Snapshot matches the @azure/mcp@${snapshot.serverVersion} pin`); + for (const file of [ + ...markdownFiles(join(repoRoot, ".github")), + ...markdownFiles(join(repoRoot, "tools/apex-prompts")), + ]) { + r.tick(); + const location = relative(repoRoot, file); + for (const problem of scanText(readFileSync(file, "utf8"), snapshot)) { + r.error(`${location}:${problem.line}`, problem.message); + } + } + r.summary(); + r.exitOnError("Azure MCP references valid", "Azure MCP reference validation failed"); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(); diff --git a/tools/scripts/validate-policy-property-map.mjs b/tools/scripts/validate-policy-property-map.mjs index 374cb20a3..8d35b6f6c 100644 --- a/tools/scripts/validate-policy-property-map.mjs +++ b/tools/scripts/validate-policy-property-map.mjs @@ -104,6 +104,24 @@ function checkDenyEnrichment(data, fileRel, r) { } } +function checkPolicyStatus(data, fileRel, r) { + for (const p of data.policies ?? []) { + if (p.status === "not-applicable" && !p.not_applicable_reason?.trim()) { + r.error(fileRel, `Policy "${p.policy_id}" is not-applicable but has no not_applicable_reason.`); + } + if (p.status !== "not-applicable" && p.not_applicable_reason !== undefined) { + r.error(fileRel, `Policy "${p.policy_id}" has not_applicable_reason but status is not not-applicable.`); + } + if (p.status === "unsatisfiable") { + r.error( + fileRel, + `Policy "${p.policy_id}" is unsatisfiable: no plan shape satisfies it; return to 04g-Governance via Refresh Governance.`, + ); + } + if (p.status === "pending") r.warn(fileRel, `Policy "${p.policy_id}" is still pending.`); + } +} + function checkConstraintsRefHash(data, fileRel, r) { const ref = data.constraints_ref; if (!ref || !ref.sha256) return; @@ -178,6 +196,7 @@ function main() { checkUniquePolicyIds(data, fileRel, r); checkDenyCoverage(data, fileRel, r); checkDenyEnrichment(data, fileRel, r); + checkPolicyStatus(data, fileRel, r); checkConstraintsRefHash(data, fileRel, r); if (r.errors === errorsBefore) { r.ok(fileRel, `policy-property-map (${data.policies.length} policies, depth=${data.governance_depth})`); diff --git a/tools/scripts/validate-session-state.mjs b/tools/scripts/validate-session-state.mjs index c0797cb26..5716063d6 100644 --- a/tools/scripts/validate-session-state.mjs +++ b/tools/scripts/validate-session-state.mjs @@ -18,7 +18,7 @@ import { AGENT_OUTPUT_DIR } from "./_lib/paths.mjs"; const TEMPLATE_PATH = ".github/skills/apex-azure-artifacts/templates/00-session-state.template.json"; const STATE_FILENAME = "00-session-state.json"; -const VALID_STATUSES = ["pending", "in_progress", "complete", "skipped"]; +const VALID_STATUSES = ["pending", "in_progress", "complete", "skipped", "stale"]; const VALID_IAC_TOOLS = ["", "Bicep", "Terraform"]; const REQUIRED_TOP_LEVEL = [ "schema_version", @@ -192,7 +192,9 @@ function validateStateFile(filePath, isTemplate) { if (!Array.isArray(state.decision_log)) { error(label, "decision_log must be an array"); } else { - const requiredEntryFields = ["id", "step", "agent", "title", "choice", "rationale"]; + // apex-recall writes { decision, rationale?, step?, timestamp }; the risk gate and historical entries add the + // structured fields (id, agent, title, choice), which are validated when present. + const validLogSteps = new Set(Object.keys(EXPECTED_STEP_NAMES)); const idPattern = /^D\d+$/; for (let i = 0; i < state.decision_log.length; i++) { const entry = state.decision_log[i]; @@ -201,16 +203,15 @@ function validateStateFile(filePath, isTemplate) { error(label, `${prefix} must be an object`); continue; } - for (const field of requiredEntryFields) { - if (!(field in entry)) { - error(label, `${prefix}: missing required field "${field}"`); - } + const structured = typeof entry.title === "string" && typeof entry.choice === "string"; + if (typeof entry.decision !== "string" && !structured) { + error(label, `${prefix}: needs a "decision" string, or both "title" and "choice"`); } if (entry.id !== undefined && !idPattern.test(entry.id)) { error(label, `${prefix}: id must match pattern D001, D002, etc. Got: "${entry.id}"`); } - if (entry.step !== undefined && (typeof entry.step !== "number" || entry.step < 1 || entry.step > 7)) { - error(label, `${prefix}: step must be a number between 1 and 7`); + if (entry.step !== undefined && !validLogSteps.has(String(entry.step))) { + error(label, `${prefix}: step must be one of ${[...validLogSteps].join(", ")}`); } if (entry.timestamp !== undefined && entry.timestamp !== null) { const d = new Date(entry.timestamp); diff --git a/tools/tests/azure-sla-section.test.mjs b/tools/tests/azure-sla-section.test.mjs new file mode 100644 index 000000000..1d812b237 --- /dev/null +++ b/tools/tests/azure-sla-section.test.mjs @@ -0,0 +1,140 @@ +// Unit tests for tools/scripts/azure-sla-section.mjs (pure parsing helpers and CLI argument handling). + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(HERE, "../.."); +const mod = await import(path.join(ROOT, "tools/scripts/azure-sla-section.mjs")); + +const PARAGRAPHS = [ + "App Service PAGEREF _Toc1 \\h 24", + "Azure Container Registry PAGEREF _Toc2 \\h 37", + "App Service", + "Additional Definitions:", + "Uptime Percentage", + "< 99.95%", + "Azure Container Registry", + "Managed Registry", + "< 99.9%", + "Table of Contents / Definitions", + "Content Delivery Network (CDN)", +]; + +describe("docxXmlToParagraphs", () => { + it("strips tags, decodes entities and drops empty paragraphs", () => { + const xml = + "< 99.95%A & B"; + assert.deepEqual(mod.docxXmlToParagraphs(xml), ["< 99.95%", "A & B"]); + }); + + it("leaves no tag behind, including an unterminated one", () => { + assert.equal(mod.stripTags("ascript>alert(1)</script>"), "script>alert(1)/script>"); + assert.ok(!mod.stripTags("ipt>x { + it("returns names from PAGEREF lines only", () => { + assert.deepEqual(mod.tocServices(PARAGRAPHS), ["App Service", "Azure Container Registry"]); + }); +}); + +describe("extractSections", () => { + it("stops at the next service heading and skips table of contents lines", () => { + const [section] = mod.extractSections(PARAGRAPHS, "app service"); + assert.equal(section.startLine, 3); + assert.deepEqual(section.lines, ["App Service", "Additional Definitions:", "Uptime Percentage", "< 99.95%"]); + assert.equal(section.truncated, false); + }); + + it("stops at the table of contents marker", () => { + const [section] = mod.extractSections(PARAGRAPHS, "Azure Container Registry"); + assert.equal(section.lines.at(-1), "< 99.9%"); + }); + + it("truncates to the requested line count and reports the full size", () => { + const [section] = mod.extractSections(PARAGRAPHS, "App Service", 2); + assert.equal(section.lines.length, 2); + assert.equal(section.truncated, true); + assert.equal(section.totalLines, 4); + }); + + it("returns no sections for an unknown service", () => { + assert.deepEqual(mod.extractSections(PARAGRAPHS, "Nope"), []); + }); +}); + +describe("candidateMonths", () => { + it("walks backwards across a year boundary", () => { + const months = mod.candidateMonths(new Date(Date.UTC(2026, 0, 15)), 3); + assert.deepEqual(months, [ + { year: 2026, month: 0 }, + { year: 2025, month: 11 }, + { year: 2025, month: 10 }, + ]); + }); +}); + +describe("slaDocUrl", () => { + it("builds the consolidated SLA download URL", () => { + assert.equal( + mod.slaDocUrl(2026, 9), + "https://www.microsoft.com/licensing/docs/documents/download/OnlineSvcsConsolidatedSLA(WW)(English)(October_2026)(CR).docx", + ); + }); +}); + +describe("parseArgs", () => { + it("parses a service with options", () => { + const a = mod.parseArgs(["App Service", "--max-lines", "10", "--month", "2026-10"]); + assert.equal(a.service, "App Service"); + assert.equal(a.maxLines, 10); + assert.equal(a.month, "2026-10"); + }); + + it("rejects bad input", () => { + assert.ok(mod.parseArgs([]).error); + assert.ok(mod.parseArgs(["a", "b"]).error); + assert.ok(mod.parseArgs(["a", "--max-lines", "0"]).error); + assert.ok(mod.parseArgs(["a", "--month", "2026-13"]).error); + assert.ok(mod.parseArgs(["a", "--bogus"]).error); + }); + + it("treats the positional as a filter with --list", () => { + assert.equal(mod.parseArgs(["--list", "storage"]).filter, "storage"); + }); +}); + +describe("run", () => { + it("returns exit 2 when only non-docx responses are available", async () => { + const errors = []; + const fetchImpl = async () => new Response("not a docx", { status: 200 }); + const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), "apex-sla-test-")); + const code = await mod.run(["App Service", "--month", "2026-10"], { + out: () => {}, + err: (m) => errors.push(m), + fetchImpl, + cacheDir, + now: new Date(Date.UTC(2026, 9, 1)), + }); + fs.rmSync(cacheDir, { recursive: true, force: true }); + assert.equal(code, 2); + assert.match(errors.join("\n"), /could not download/); + }); + + it("returns exit 2 for a missing local file", async () => { + const errors = []; + const code = await mod.run(["App Service", "--file", "/nonexistent/sla.docx"], { + out: () => {}, + err: (m) => errors.push(m), + }); + assert.equal(code, 2); + }); +}); diff --git a/tools/tests/exec-plans/active/azure-skills-plugin-alignment.md b/tools/tests/exec-plans/active/azure-skills-plugin-alignment.md index 889f523d8..720a79c0e 100644 --- a/tools/tests/exec-plans/active/azure-skills-plugin-alignment.md +++ b/tools/tests/exec-plans/active/azure-skills-plugin-alignment.md @@ -66,7 +66,7 @@ Checked 2026-09-23: `v1.2.51` is still the latest tag, `main` is unchanged since - **Coexistence**: `apex-` names avoid name collisions, and plugin skills appear as `/azure:`. APEX agents reference `apex-*` skills by explicit name or path, so workflow runs keep using APEX copies; free-form chats can load either set. -- **APEX Azure MCP coupling**: [mcp.json](../../../../.vscode/mcp.json) pins `@azure/mcp@2.0.5` and sets +- **APEX Azure MCP coupling**: [mcp.json](../../../../.vscode/mcp.json) pins `@azure/mcp@3.0.0` and sets `NPM_CONFIG_ALLOW_REMOTE=all` for the npm 12 feed. [configure-mcp.mjs](../../../../.devcontainer/configure-mcp.mjs) holds the defaults and release check, [validate-mcp-config.mjs](../../../../tools/scripts/validate-mcp-config.mjs) requires `servers.azure-mcp`, and @@ -473,7 +473,7 @@ The weekly drift report (#723) flagged seven pinned skills. Reviewed 2026-10-05 - No wholesale replacement, consistent with the recorded refresh rule. - No upstream pull requests; the drift report tracks convergence instead. -- Keep the `apex-azure-cost-optimization` and `apex-azure-rbac` names and the pinned `azure-mcp` 2.0.5 server. +- Keep the `apex-azure-cost-optimization` and `apex-azure-rbac` names and the pinned `azure-mcp` 3.0.0 server. - Left out from upstream: Kubernetes app deployment and AKS Automatic readiness; reliability live-change and IaC-patching guides; upgrade automation and Java; cloud-migrate deployment guides; prepare MCP templates, the root plan file, SQL grant scripts and automatic `azd up`; the cost attribution header and the `temp/` and `output/` diff --git a/tools/tests/fixtures/subagent-file-contract/cost-estimate.findings.json b/tools/tests/fixtures/subagent-file-contract/cost-estimate.findings.json index 2029cc148..68533125f 100644 --- a/tools/tests/fixtures/subagent-file-contract/cost-estimate.findings.json +++ b/tools/tests/fixtures/subagent-file-contract/cost-estimate.findings.json @@ -1,4 +1,5 @@ { + "schema_version": "cost-estimate-v1", "status": "COMPLETE", "project_name": "sample", "region": "swedencentral", @@ -64,7 +65,7 @@ "savings_status": "QUANTIFIED", "savings_reason": "RI eligible compute identified", "eligible_strategies": ["Reserved Instances (3yr)", "Azure Hybrid Benefit (SQL)"], - "data_source": "Azure Resource Manager MCP get_retail_prices", + "data_source": "Azure MCP pricing_get", "queried_at": "2026-05-08T12:00:00Z", "confidence": "High", "unresolved_items": [], diff --git a/tools/tests/scripts/test_azqr_install.mjs b/tools/tests/scripts/test_azqr_install.mjs new file mode 100644 index 000000000..422f18a79 --- /dev/null +++ b/tools/tests/scripts/test_azqr_install.mjs @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +const containerDir = new URL("../../../.devcontainer/", import.meta.url); +const manifest = JSON.parse(readFileSync(new URL("download-checksums.json", containerDir), "utf8")); + +function fixture(context, { tamper = false } = {}) { + const root = mkdtempSync(path.join(tmpdir(), "apex-azqr-test-")); + context.after(() => rmSync(root, { recursive: true, force: true })); + for (const dir of ["bin", "source/bin/linux_amd64", "install"]) mkdirSync(path.join(root, dir), { recursive: true }); + copyFileSync(new URL("install-azqr.sh", containerDir), path.join(root, "install-azqr.sh")); + + writeFileSync(path.join(root, "source/bin/linux_amd64/azqr"), "#!/bin/sh\necho 'azqr version v.4.2.0'\n"); + const archive = path.join(root, "azqr.zip"); + const zipped = spawnSync("python3", ["-m", "zipfile", "-c", archive, "bin"], { cwd: path.join(root, "source") }); + assert.equal(zipped.status, 0, zipped.stderr?.toString()); + + const local = structuredClone(manifest); + const digest = createHash("sha256").update(readFileSync(archive)).digest("hex"); + local.azqr.artifacts.amd64.sha256 = tamper ? "0".repeat(64) : digest; + writeFileSync(path.join(root, "download-checksums.json"), JSON.stringify(local)); + + const stub = (name, body) => { + const file = path.join(root, "bin", name); + writeFileSync(file, `#!/bin/sh\n${body}\n`); + chmodSync(file, 0o755); + }; + stub("dpkg", "echo amd64"); + // Shadows any azqr installed on the host so the pinned-version skip never fires by accident. + stub("azqr", "echo 'azqr version v.0.0.0'"); + stub( + "curl", + `out=""; while [ "$#" -gt 0 ]; do [ "$1" = "--output" ] && out="$2"; shift; done; cp "${archive}" "$out"`, + ); + + return { + root, + run(extraPath = "") { + return spawnSync("bash", [path.join(root, "install-azqr.sh")], { + encoding: "utf8", + env: { + ...process.env, + PATH: `${extraPath}${extraPath ? ":" : ""}${path.join(root, "bin")}:${process.env.PATH}`, + AZQR_INSTALL_DIR: path.join(root, "install"), + TMPDIR: tmpdir(), + }, + }); + }, + }; +} + +test("manifest pins azqr 3.0.0 or later for both architectures with sha256 and github urls", () => { + assert.ok(Number(manifest.azqr.version.split(".")[0]) >= 3); + for (const arch of ["amd64", "arm64"]) { + const artifact = manifest.azqr.artifacts[arch]; + assert.match(artifact.sha256, /^[0-9a-f]{64}$/); + assert.match(artifact.url, /^https:\/\/github\.com\/Azure\/azqr\//); + } +}); + +test("installs the verified binary from the nested archive path", (context) => { + const setup = fixture(context); + const result = setup.run(); + assert.equal(result.status, 0, result.stderr); + assert.ok(existsSync(path.join(setup.root, "install", "azqr"))); +}); + +test("rejects an archive whose checksum does not match the manifest", (context) => { + const setup = fixture(context, { tamper: true }); + const result = setup.run(); + assert.notEqual(result.status, 0); + assert.ok(!existsSync(path.join(setup.root, "install", "azqr"))); +}); + +test("skips the download when the pinned version is already on PATH", (context) => { + const setup = fixture(context); + const present = path.join(setup.root, "present"); + mkdirSync(present); + writeFileSync(path.join(present, "azqr"), "#!/bin/sh\necho 'azqr version v.4.2.0'\n", { mode: 0o755 }); + const result = setup.run(present); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /already installed/); + assert.ok(!existsSync(path.join(setup.root, "install", "azqr"))); +}); diff --git a/tools/tests/scripts/test_contract_lessons.mjs b/tools/tests/scripts/test_contract_lessons.mjs new file mode 100644 index 000000000..8be4d316f --- /dev/null +++ b/tools/tests/scripts/test_contract_lessons.mjs @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); +const contractScript = path.join(repo, "tools/scripts/validate-iac-contract.mjs"); +const consistencyScript = path.join(repo, "tools/scripts/validate-iac-contract-consistency.mjs"); +const sha = (text) => crypto.createHash("sha256").update(text).digest("hex"); + +function fixture(context, { params = [], capabilities, plan = "# Plan\n", resources } = {}) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "iac-contract-")); + context.after(() => fs.rmSync(directory, { recursive: true, force: true })); + fs.writeFileSync(path.join(directory, "04-implementation-plan.md"), plan); + const contract = { + schema_version: "iac-contract-v1", + project: "synthetic", + iac_tool: "Bicep", + generated_at: "2026-10-15T00:00:00Z", + plan_ref: { path: "04-implementation-plan.md", sha256: sha(plan) }, + resources: resources ?? [{ logical_name: "vault-main", type: "Microsoft.KeyVault/vaults", sku: "standard" }], + modules: { bicep: [] }, + params: [{ name: "workspaceId", type: "string" }, ...params], + diagnostics: { mode: "avm", workspace_id_param: "workspaceId", log_categories: [], metric_categories: [] }, + identity: { type: "none" }, + l1m_ref: { path: "04-policy-property-map.json" }, + ...(capabilities ? { capability_checks: capabilities } : {}), + }; + const file = path.join(directory, "04-iac-contract.json"); + fs.writeFileSync(file, JSON.stringify(contract, null, 2)); + return file; +} + +const run = (script, args) => spawnSync(process.execPath, [script, ...args], { cwd: repo, encoding: "utf8" }); +const output = (result) => result.stdout + result.stderr; + +const capability = (blocks) => ({ + id: "devops-access", + owner: "platform", + required_now: true, + security_obligation: false, + status: "unresolved", + consumers: ["operators"], + dependencies: [], + evidence: [], + runtime_status: "unverified", + ...(blocks ? { blocks } : {}), +}); + +test("a phase parameter with a default is rejected", (context) => { + const bad = run(contractScript, [fixture(context, { params: [{ name: "phase", type: "int", default: 5 }] })]); + assert.notEqual(bad.status, 0); + assert.match(output(bad), /must be required with no default/); + const good = run(contractScript, [fixture(context, { params: [{ name: "phase", type: "int" }] })]); + assert.equal(good.status, 0, output(good)); +}); + +test("an unresolved capability blocks only the stages it declares", (context) => { + const file = fixture(context, { capabilities: [capability(["go-live"])] }); + const deploy = run(contractScript, [file, "--readiness"]); + assert.equal(deploy.status, 0, output(deploy)); + assert.match(output(deploy), /not blocking stage deploy/); + const goLive = run(contractScript, [file, "--readiness", "--stage", "go-live"]); + assert.notEqual(goLive.status, 0); + assert.match(output(goLive), /Required design path unresolved/); +}); + +test("an unresolved capability without blocks still blocks every stage", (context) => { + const result = run(contractScript, [fixture(context, { capabilities: [capability()] }), "--readiness"]); + assert.notEqual(result.status, 0); + assert.match(output(result), /Required design path unresolved/); +}); + +test("an unknown stage is rejected", (context) => { + const result = run(contractScript, [fixture(context), "--stage", "someday"]); + assert.notEqual(result.status, 0); + assert.match(output(result), /must be one of deploy, post-deploy, go-live/); +}); + +test("a raw resource needs a row in the Raw Resource Exceptions section", (context) => { + const raw = [{ logical_name: "budget-rg", type: "Microsoft.Consumption/budgets", sku: null }]; + const without = run(consistencyScript, [ + fixture(context, { resources: raw, plan: "# Plan\n\n### Raw Resource Exceptions\n\n_None._\n\n## Next\n" }), + ]); + assert.notEqual(without.status, 0); + assert.match(output(without), /has no row in "### Raw Resource Exceptions"/); + const withRow = run(consistencyScript, [ + fixture(context, { + resources: raw, + plan: "# Plan\n\n### Raw Resource Exceptions\n\n| `Microsoft.Consumption/budgets` | bicep |\n\n## Next\n", + }), + ]); + assert.equal(withRow.status, 0, output(withRow)); +}); + +test("a plan without the exceptions section only warns", (context) => { + const raw = [{ logical_name: "budget-rg", type: "Microsoft.Consumption/budgets", sku: null }]; + const result = run(consistencyScript, [fixture(context, { resources: raw })]); + assert.equal(result.status, 0, output(result)); + assert.match(output(result), /no "### Raw Resource Exceptions" section/); +}); + +const resource = (logical_name, type, extra = {}) => ({ logical_name, type, sku: null, ...extra }); +const privatePath = (api, endpoint) => [ + resource("sql-main", "Microsoft.Sql/servers", { phase: 2 }), + resource("pe-sql", "Microsoft.Network/privateEndpoints", { phase: 2, target: "sql-main", ...endpoint }), + resource("app-api", "Microsoft.Web/sites", { phase: 3, consumes: ["sql-main"], ...api }), +]; + +test("a consumer must depend on the private endpoint of what it consumes", (context) => { + const missing = run(contractScript, [fixture(context, { resources: privatePath({}) })]); + assert.notEqual(missing.status, 0); + assert.match(output(missing), /does not depends_on its private endpoint \(pe-sql\)/); + const wired = run(contractScript, [fixture(context, { resources: privatePath({ depends_on: ["pe-sql"] }) })]); + assert.equal(wired.status, 0, output(wired)); +}); + +test("a resource may not depend on one in a later phase", (context) => { + const late = run(contractScript, [ + fixture(context, { resources: privatePath({ depends_on: ["pe-sql"] }, { phase: 4 }) }), + ]); + assert.notEqual(late.status, 0); + assert.match(output(late), /depends_on "pe-sql" in later phase 4/); +}); + +test("target belongs to private endpoints and consumes names another resource", (context) => { + const wrongType = run(contractScript, [ + fixture(context, { resources: [resource("vault-main", "Microsoft.KeyVault/vaults", { target: "vault-main" })] }), + ]); + assert.match(output(wrongType), /only valid on Microsoft\.Network\/privateEndpoints/); + const unknown = run(contractScript, [ + fixture(context, { resources: [resource("app-api", "Microsoft.Web/sites", { consumes: ["missing"] })] }), + ]); + assert.match(output(unknown), /must name another resource/); +}); + +test("a contract without phase, consumes or target is unchanged", (context) => { + const result = run(contractScript, [ + fixture(context, { resources: privatePath({}).map(({ phase: _phase, ...rest }) => rest) }), + ]); + assert.notEqual(result.status, 0); + assert.match(output(result), /does not depends_on its private endpoint/); + const plain = run(contractScript, [fixture(context)]); + assert.equal(plain.status, 0, output(plain)); +}); diff --git a/tools/tests/scripts/test_cost_estimate.mjs b/tools/tests/scripts/test_cost_estimate.mjs new file mode 100644 index 000000000..13ca8ab9c --- /dev/null +++ b/tools/tests/scripts/test_cost_estimate.mjs @@ -0,0 +1,488 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { createAjv } from "../../scripts/_lib/ajv-validator.mjs"; +import { + calculateLineCost, + normalizeCostRequest, + publishEstimate, + resolveWorkspacePath, + round6, + validateCostRequest, + validateEstimate, +} from "../../scripts/cost-estimate.mjs"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(HERE, "../../.."); +const SCRIPT = path.join(ROOT, "tools/scripts/cost-estimate.mjs"); + +function makeManifest(overrides = {}) { + return { + schema_version: "sku-manifest-v1", + project: "demo", + default_region: "swedencentral", + created_at: "2026-10-15T12:00:00Z", + updated_at: "2026-10-15T12:00:00Z", + current_revision: 2, + environments: ["dev"], + revisions: [ + { + rev: 1, + created_at: "2026-10-15T12:00:00Z", + agent: "02-Requirements", + step: "1", + summary: "init", + changed_ids: ["jump-vm"], + }, + { + rev: 2, + created_at: "2026-10-15T12:05:00Z", + agent: "03-Architect", + step: "2", + summary: "skus", + changed_ids: ["app-plan-web", "sql-db-core"], + }, + ], + services: [ + { + id: "app-plan-web", + service: "Azure App Service", + size: "B2", + iac_logical_names: { bicep: "appPlanWeb", terraform: "azurerm_service_plan.web" }, + capacity: { mode: "fixed", default: 1 }, + zonal: false, + regions: ["swedencentral"], + sla_target: "99.9%", + commitment: { type: "on-demand" }, + source: "architect-derived", + source_step: "2", + last_modified_rev: 2, + }, + { + id: "sql-db-core", + service: "SQL Database", + size: "S2", + iac_logical_names: { bicep: "sqlDbCore", terraform: "azurerm_mssql_database.db" }, + capacity: { mode: "fixed", default: 1 }, + zonal: false, + regions: ["swedencentral"], + sla_target: "99.9%", + commitment: { type: "on-demand" }, + source: "architect-derived", + source_step: "2", + last_modified_rev: 2, + }, + { + id: "jump-vm", + service: "Virtual Machine", + size: "Standard_B2s_v2", + iac_logical_names: { bicep: "jumpVm", terraform: "azurerm_linux_virtual_machine.jump" }, + capacity: { mode: "fixed", default: 1 }, + zonal: false, + regions: ["swedencentral"], + sla_target: "Not specified", + commitment: { type: "on-demand" }, + source: "user-pin", + source_step: "1", + last_modified_rev: 1, + }, + ], + ...overrides, + }; +} + +test("resolveWorkspacePath prevents double-prefixed agent-output paths when shell CWD drifted", () => { + const driftedCwd = path.join(ROOT, "agent-output", "eventdesk"); + const resolved = resolveWorkspacePath("agent-output/eventdesk/02-cost-estimate.json", driftedCwd); + assert.equal(resolved, path.join(ROOT, "agent-output/eventdesk/02-cost-estimate.json")); +}); + +test("preflight enforces single input mode, variable usage, alert frequency, and stale draft checks", (context) => { + const dir = fs.mkdtempSync(path.join(tmpdir(), "apex-cost-preflight-")); + context.after(() => fs.rmSync(dir, { recursive: true, force: true })); + + const manifestPath = path.join(dir, "sku-manifest.json"); + fs.writeFileSync(manifestPath, JSON.stringify(makeManifest())); + const outputPath = path.join(dir, "02-cost-estimate.json"); + + const multiMode = validateCostRequest({ + project_name: "demo", + region: "swedencentral", + output_path: outputPath, + manifest_path: manifestPath, + resource_list: [{ name: "x", service_name: "Storage", sku: "LRS" }], + }); + assert.equal(multiMode.ok, false); + assert.match(multiMode.unresolved_items[0], /Exactly one primary input mode/); + + const missingUsage = validateCostRequest({ + project_name: "demo", + region: "swedencentral", + output_path: outputPath, + manifest_path: manifestPath, + supplemental_resources: [ + { name: "log-analytics", service_name: "Log Analytics", sku: "Analytics Logs" }, + { + name: "alert-rules", + service_name: "Azure Monitor", + sku: "Alerts", + usage: { amount: 3, unit: "monitored-resources", scope: "aggregate" }, + }, + ], + }); + assert.equal(missingUsage.ok, false); + assert.ok(missingUsage.unresolved_items.some((u) => u.includes("log-analytics") && u.includes("explicit usage"))); + assert.ok(missingUsage.unresolved_items.some((u) => u.includes("alert-rules") && u.includes("evaluation_frequency"))); + + // Stale .tmp blocks preflight + fs.writeFileSync(`${outputPath}.tmp`, "{}"); + const staleDraft = validateCostRequest({ + project_name: "demo", + region: "swedencentral", + output_path: outputPath, + manifest_path: manifestPath, + }); + assert.equal(staleDraft.ok, false); + assert.ok(staleDraft.unresolved_items.some((u) => u.includes("Stale temporary draft"))); + fs.rmSync(`${outputPath}.tmp`); + + // Valid full-scope manifest + supplemental request passes + const valid = validateCostRequest({ + project_name: "demo", + region: "swedencentral", + output_path: outputPath, + manifest_path: manifestPath, + supplemental_resources: [ + { + name: "log-analytics", + service_name: "Log Analytics", + sku: "Analytics Logs", + usage: { amount: 5, unit: "GB", scope: "aggregate" }, + }, + { + name: "alert-rules", + service_name: "Azure Monitor", + sku: "Alerts", + usage: { amount: 3, unit: "monitored-resources", scope: "aggregate", evaluation_frequency: "5m" }, + }, + ], + }); + assert.equal(valid.ok, true); + assert.equal(valid.normalized.lines.length, 5); +}); + +test("manifest expansion preserves sparse environment overrides and rejects supplemental ID collisions", (context) => { + const dir = fs.mkdtempSync(path.join(tmpdir(), "apex-cost-expand-")); + context.after(() => fs.rmSync(dir, { recursive: true, force: true })); + + const manifest = makeManifest({ + environments: ["dev", "prod"], + services: [ + { + id: "app-plan-web", + service: "Azure App Service", + size: "P1v3", + iac_logical_names: { bicep: "appPlanWeb", terraform: "azurerm_service_plan.web" }, + capacity: { mode: "autoscale", min: 2, max: 6, default: 2 }, + zonal: true, + regions: ["swedencentral", "germanywestcentral"], + environment_overrides: { + dev: { + size: "B2", + capacity: { mode: "fixed", default: 1 }, + zonal: false, + regions: ["swedencentral"], + }, + }, + sla_target: "99.95%", + commitment: { type: "reserved-1yr", term_years: 1 }, + source: "architect-derived", + source_step: "2", + last_modified_rev: 2, + }, + ], + }); + const manifestPath = path.join(dir, "sku-manifest.json"); + fs.writeFileSync(manifestPath, JSON.stringify(manifest)); + + const normalized = normalizeCostRequest({ + project_name: "demo", + region: "swedencentral", + output_path: path.join(dir, "out.json"), + manifest_path: manifestPath, + }); + // 1 dev region + 2 prod regions = 3 deployment lines + assert.equal(normalized.lines.length, 3); + assert.equal(normalized.lines[0].sku, "B2"); + assert.equal(normalized.lines[0].quantity, 1); + assert.equal(normalized.lines[1].sku, "P1v3"); + assert.equal(normalized.lines[1].quantity, 2); + + assert.throws( + () => + normalizeCostRequest({ + project_name: "demo", + region: "swedencentral", + output_path: path.join(dir, "out.json"), + manifest_path: manifestPath, + supplemental_resources: [{ name: "app-plan-web", service_name: "NAT Gateway", sku: "Standard" }], + }), + /collides with manifest service id/, + ); +}); + +test("calculateLineCost computes deterministic unit and tiered band totals without double-multiplying aggregate usage", () => { + // Hourly meter: 0.036 * 730 = 26.28 + assert.equal(calculateLineCost({ name: "app", quantity: 1, unit_of_measure: "1 Hour", retail_price: 0.036 }), 26.28); + + // Daily meter + storage component: $2.42 * 365/12 + $0.221 * 32 = 80.680333 + assert.equal( + calculateLineCost({ + name: "sql", + quantity: 1, + components: [ + { unit_of_measure: "1/Day", retail_price: 2.42 }, + { unit_of_measure: "1 GB/Month", retail_price: 0.221, usage_amount: 32, usage_scope: "aggregate" }, + ], + }), + 80.680333, + ); + + // Private endpoints: 4 endpoints * 730h * $0.01 + aggregate 20 GB * $0.01 = 29.2 + 0.2 = 29.4 + assert.equal( + calculateLineCost({ + name: "pe", + quantity: 4, + components: [ + { unit_of_measure: "1 Hour", retail_price: 0.01 }, + { unit_of_measure: "1 GB", retail_price: 0.01, usage_amount: 20, usage_scope: "aggregate" }, + ], + }), + 29.4, + ); + + // Tiered meter with free first band (0..5 GB @ $0, >5 GB @ $2.99): 5 GB costs $0, 7 GB costs $5.98 + const bands = [ + { tierMinimumUnits: 0, retailPrice: 0 }, + { tierMinimumUnits: 5, retailPrice: 2.99 }, + ]; + assert.equal( + calculateLineCost({ + name: "law-5gb", + quantity: 1, + unit_of_measure: "1 GB", + usage_amount: 5, + usage_scope: "aggregate", + tier_bands: bands, + }), + 0, + ); + assert.equal( + calculateLineCost({ + name: "law-7gb", + quantity: 1, + unit_of_measure: "1 GB", + usage_amount: 7, + usage_scope: "aggregate", + tier_bands: bands, + }), + round6(2 * 2.99), + ); +}); + +test("publishEstimate normalizes totals, writes back only manifest services, and handles !name notes via CLI", (context) => { + const dir = fs.mkdtempSync(path.join(tmpdir(), "apex-cost-publish-")); + context.after(() => fs.rmSync(dir, { recursive: true, force: true })); + + const manifestPath = path.join(dir, "sku-manifest.json"); + fs.writeFileSync(manifestPath, JSON.stringify(makeManifest(), null, 2)); + + const outputPath = path.join(dir, "02-cost-estimate.json"); + const draftPath = `${outputPath}.tmp`; + + const draft = { + status: "COMPLETE", + project_name: "demo", + region: "swedencentral", + currency: "USD", + monthly_total: 0, + yearly_total: 0, + resources: [ + { + name: "app-plan-web (dev, swedencentral)", + manifest_service_id: "app-plan-web", + source_kind: "manifest", + service_name: "Azure App Service", + sku: "B2", + region: "swedencentral", + quantity: 1, + hourly_rate: 0.036, + monthly_cost: 26.28, + notes: "Linux B2 plan; verified !fs.existsSync hazard does not trigger in file-based publish.", + }, + { + name: "sql-db-core (dev, swedencentral)", + manifest_service_id: "sql-db-core", + source_kind: "manifest", + service_name: "SQL Database", + sku: "S2", + region: "swedencentral", + quantity: 1, + hourly_rate: null, + monthly_cost: 80.68033333333334, + notes: "S2 DTU + 32 GB storage.", + }, + { + name: "jump-vm (dev, swedencentral)", + manifest_service_id: "jump-vm", + source_kind: "manifest", + service_name: "Virtual Machines", + sku: "Standard_B2s_v2", + region: "swedencentral", + quantity: 1, + hourly_rate: 0.0864, + monthly_cost: 63.072, + notes: "Linux jump VM.", + }, + { + name: "bastion-standard", + source_kind: "supplemental", + service_name: "Azure Bastion", + sku: "Standard", + region: "swedencentral", + quantity: 1, + hourly_rate: 0.29, + monthly_cost: 211.7, + notes: "Supplemental Bastion host (must not enter manifest writeback).", + }, + ], + optimization_notes: [], + savings_status: "NOT_APPLICABLE", + savings_reason: "dev on-demand", + eligible_strategies: [], + data_source: "Azure MCP pricing_get", + queried_at: "2026-10-15T14:20:13Z", + confidence: "Medium", + unresolved_items: [], + mcp_calls_used: 4, + budget_exceeded: false, + }; + fs.writeFileSync(draftPath, JSON.stringify(draft, null, 2)); + + const cli = spawnSync( + process.execPath, + [SCRIPT, "publish", "--draft", draftPath, "--output", outputPath, "--manifest", manifestPath], + { encoding: "utf8", timeout: 5000 }, + ); + assert.equal(cli.status, 0, cli.stderr); + assert.match(cli.stdout, /^COST ESTIMATE COMPLETE/m); + assert.equal(fs.existsSync(draftPath), false); + assert.equal(fs.existsSync(outputPath), true); + + const published = JSON.parse(fs.readFileSync(outputPath, "utf8")); + assert.equal(published.monthly_total, 381.732333); + assert.equal(published.yearly_total, 4580.787996); + assert.equal(published.manifest_writeback.length, 3); + assert.ok(!published.manifest_writeback.some((w) => w.id === "bastion-standard")); + + const updatedManifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + const byId = new Map(updatedManifest.services.map((s) => [s.id, s.cost_estimate_monthly_usd])); + assert.equal(byId.get("app-plan-web"), 26.28); + assert.equal(byId.get("sql-db-core"), 80.680333); + assert.equal(byId.get("jump-vm"), 63.072); + + // Refuse on second publish without overwrite + fs.writeFileSync(draftPath, JSON.stringify(published, null, 2)); + assert.throws(() => publishEstimate({ draftPath, outputPath, overwrite: false }), /refuse_on_exists/); +}); + +test("validateEstimate verifies retail_api_evidence SHA-256 and meterIds and flags legacy closest-meter assumptions", (context) => { + const dir = fs.mkdtempSync(path.join(tmpdir(), "apex-cost-evidence-")); + context.after(() => fs.rmSync(dir, { recursive: true, force: true })); + + const rawPage = JSON.stringify({ + BillingCurrency: "USD", + Items: [{ meterId: "m-blob-hot", retailPrice: 0.0184, tierMinimumUnits: 0 }], + NextPageLink: null, + }); + const evidencePath = path.join(dir, "blob.evidence.json"); + fs.writeFileSync( + evidencePath, + JSON.stringify({ + schema_version: "retail-price-evidence-v1", + data_source: "Azure Retail Prices API (direct fallback)", + pages: [ + { + url: "https://prices.azure.com/api/retail/prices", + queried_at: "2026-10-15T14:00:00Z", + response_sha256: createHash("sha256").update(rawPage).digest("hex"), + raw_response: rawPage, + }, + ], + }), + ); + + const estimate = { + status: "COMPLETE", + project_name: "demo", + region: "swedencentral", + currency: "USD", + monthly_total: 1.84, + yearly_total: 22.08, + resources: [ + { + name: "blob", + service_name: "Storage", + sku: "Standard_LRS", + region: "swedencentral", + quantity: 1, + monthly_cost: 1.84, + notes: "Used closest published catalog meter for alerts.", + }, + ], + optimization_notes: [], + savings_status: "NOT_APPLICABLE", + savings_reason: "dev", + eligible_strategies: [], + data_source: "Azure MCP pricing_get + Azure Retail Prices API (direct fallback)", + queried_at: "2026-10-15T14:00:00Z", + confidence: "Medium", + unresolved_items: [], + mcp_calls_used: 1, + direct_api_calls_used: 1, + budget_exceeded: false, + retail_api_evidence: [ + { + evidence_path: evidencePath, + resource_names: ["blob"], + selected_meters: [{ meterId: "m-blob-hot", tierMinimumUnits: 0, retailPrice: 0.0184 }], + calculation: "100 * 0.0184", + }, + ], + }; + + const res = validateEstimate(estimate, { cwd: dir, legacyAudit: true }); + assert.equal(res.ok, true); + assert.equal(res.warnings.length, 1); + assert.match(res.warnings[0], /closest published/); + + estimate.retail_api_evidence[0].selected_meters[0].meterId = "wrong-meter-id"; + const bad = validateEstimate(estimate, { cwd: dir }); + assert.equal(bad.ok, false); + assert.match(bad.errors[0], /not found in/); +}); + +test("cost-estimate.schema.json compiles with Ajv and validates fixture and published estimates", () => { + const ajv = createAjv(); + const schema = JSON.parse(fs.readFileSync(path.join(ROOT, "tools/schemas/cost-estimate.schema.json"), "utf8")); + const validate = ajv.compile(schema); + const fixture = JSON.parse( + fs.readFileSync(path.join(ROOT, "tools/tests/fixtures/subagent-file-contract/cost-estimate.findings.json"), "utf8"), + ); + assert.equal(validate(fixture), true, JSON.stringify(validate.errors)); +}); diff --git a/tools/tests/scripts/test_devcontainer_build.mjs b/tools/tests/scripts/test_devcontainer_build.mjs index edefc5e7e..1c5a77304 100644 --- a/tools/tests/scripts/test_devcontainer_build.mjs +++ b/tools/tests/scripts/test_devcontainer_build.mjs @@ -131,12 +131,18 @@ test("build retains Ubuntu, feature locks, host paths and host-process token for test("release manifest pins both architectures to versioned upstream assets and checksum sources", () => { assert.equal(manifest.uv.version, "0.12.23"); assert.equal(manifest.gitleaks.version, "8.30.1"); + const upstream = { + uv: { prefix: "https://github.com/astral-sh/uv", tag: (version) => version }, + gitleaks: { prefix: "https://github.com/gitleaks/gitleaks", tag: (version) => `v${version}` }, + // The upstream azqr release tags carry a literal "v." prefix. + azqr: { prefix: "https://github.com/Azure/azqr", tag: (version) => `v.${version}` }, + }; for (const [tool, release] of Object.entries(manifest)) { assert.deepEqual(Object.keys(release.artifacts).sort(), ["amd64", "arm64"]); for (const artifact of Object.values(release.artifacts)) { assert.match(artifact.sha256, /^[0-9a-f]{64}$/); - const prefix = tool === "uv" ? "https://github.com/astral-sh/uv" : "https://github.com/gitleaks/gitleaks"; - const tag = tool === "uv" ? release.version : `v${release.version}`; + const { prefix, tag: toTag } = upstream[tool]; + const tag = toTag(release.version); for (const url of [artifact.url, artifact.checksumUrl]) { assert.ok(url.startsWith(`${prefix}/releases/download/${tag}/`)); assert.ok(!url.includes("latest")); diff --git a/tools/tests/scripts/test_draft_hash_sync.mjs b/tools/tests/scripts/test_draft_hash_sync.mjs index 08a6e764b..d84e66bd4 100644 --- a/tools/tests/scripts/test_draft_hash_sync.mjs +++ b/tools/tests/scripts/test_draft_hash_sync.mjs @@ -32,3 +32,34 @@ test("draft hash sync computes full digests without writing or touching approved assert.throws(() => syncDraft(contract, options), /unapproved draft/); assert.deepEqual(fs.readFileSync(contract), before); }); + +test("draft hash sync changes only the digest bytes of a formatted contract", (context) => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "draft-hashes-format-")); + context.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const contract = path.join(directory, "04-iac-contract.json"); + const stale = "0".repeat(64); + fs.writeFileSync(path.join(directory, "04-implementation-plan.md"), "draft"); + fs.writeFileSync(path.join(directory, "04-policy-property-map.json"), "{}"); + const text = [ + "{", + ` "plan_ref": { "path": "04-implementation-plan.md", "sha256": "${stale}" },`, + ' "l1m_ref": {', + ' "path": "04-policy-property-map.json",', + ` "sha256": "${stale}"`, + " },", + ' "tags": ["a", "b"]', + "}", + "", + ].join("\n"); + fs.writeFileSync(contract, text); + const result = syncDraft(contract, { + expectedSha: crypto.createHash("sha256").update(text).digest("hex"), + reason: "owner repair", + state: { steps: { 4: { status: "in_progress" } }, decisions: {} }, + }); + const bare = (value) => value.replace(/[a-f0-9]{64}/g, "#"); + assert.equal(bare(result.text), bare(text)); + assert.ok(result.text.includes('"tags": ["a", "b"]')); + assert.ok(result.text.includes('"plan_ref": { "path"')); + assert.ok(!result.text.includes(stale)); +}); diff --git a/tools/tests/scripts/test_fetch_azure_deprecations.mjs b/tools/tests/scripts/test_fetch_azure_deprecations.mjs new file mode 100644 index 000000000..7f8d5a408 --- /dev/null +++ b/tools/tests/scripts/test_fetch_azure_deprecations.mjs @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { extractSunsetDate, mergeDeprecations, parseFeed } from "../../scripts/fetch-azure-deprecations.mjs"; + +const item = (fields) => + `${Object.entries(fields) + .map(([k, v]) => `<${k}>${v}`) + .join("")}`; + +const FEED = `${[ + item({ + link: "https://azure.microsoft.com/updates?id=1", + category: "Compute,Virtual Machines,Retirements", + title: "Retirement Update: SAP container images removed October 14, 2026", + description: "On September 14, 2026 the old images were retired. Please move before the final removal.", + }), + item({ + link: "https://azure.microsoft.com/updates?id=2", + category: "Developer tools,DevOps,Retirements", + title: "Retirement: Microsoft Dev Box will be retired on September 18, 2028", + description: "Beginning on September 14, 2026, Microsoft Dev Box will begin the closing-down process.", + }), + item({ + link: "https://azure.microsoft.com/updates?id=3", + category: "Retirements", + title: "Retirement: Pod name dimension in AKS pod platform metrics", + description: "Beginning September 30, 2027, support for the pod name dimension is retiring.", + }), + item({ + link: "https://azure.microsoft.com/updates?id=4", + category: "Launched,Compute,Features", + title: "Generally available: something new", + description: "Support for the classic model ends on October 1, 2030.", + }), +].join("")}`; + +test("parseFeed keeps only items tagged Retirements in a comma-separated category list", () => { + const items = parseFeed(FEED); + assert.deepEqual( + items.map((entry) => entry.link), + [ + "https://azure.microsoft.com/updates?id=1", + "https://azure.microsoft.com/updates?id=2", + "https://azure.microsoft.com/updates?id=3", + ], + ); + assert.ok(items.every((entry) => entry.source === "azure-updates-rss")); +}); + +test("a date in the title wins over an earlier date in the description", () => { + const [sap, devBox] = parseFeed(FEED); + assert.equal(sap.sunsetDate, "2026-10-14"); + assert.equal(devBox.sunsetDate, "2028-09-18"); +}); + +test("a milestone date is skipped unless its own sentence announces the retirement", () => { + assert.equal(extractSunsetDate("Retirement: X", "Beginning on September 14, 2026, X will begin closing down."), null); + assert.equal( + extractSunsetDate("Retirement: X", "Beginning September 30, 2027, support for X is retiring."), + "2027-09-30", + ); + assert.equal(extractSunsetDate("Retirement: X", "Microsoft will retire X on 5 March 2028."), "2028-03-05"); + assert.equal(extractSunsetDate("Retirement: X", "No date given."), null); + assert.equal(extractSunsetDate("Retirement: X", "Retires on February 31, 2028."), null); +}); + +test("mergeDeprecations dedupes by link, stamps status and sorts undated entries last", () => { + const known = [{ service: "Azure CDN", sku: "Standard_Microsoft", sunsetDate: "2025-09-30", source: "manual" }]; + const feed = parseFeed(FEED); + const undated = { + service: "Undated", + link: "https://azure.microsoft.com/updates?id=9", + sunsetDate: null, + source: "azure-updates-rss", + }; + const merged = mergeDeprecations(known, [...feed, { ...feed[0] }, undated], new Date("2026-10-13T00:00:00Z")); + + assert.equal(merged.length, 5, "the duplicate link is collapsed but distinct links sharing a service are kept"); + const byDate = Object.fromEntries(merged.map((entry) => [entry.sunsetDate, entry.status])); + assert.equal(byDate["2025-09-30"], "retired"); + assert.equal(byDate["2026-10-14"], "upcoming"); + assert.equal(merged.at(-1).sunsetDate, null); + assert.equal(merged.at(-1).status, undefined); +}); diff --git a/tools/tests/scripts/test_guidance_remediation.mjs b/tools/tests/scripts/test_guidance_remediation.mjs index c8d48fa70..ed815a3cf 100644 --- a/tools/tests/scripts/test_guidance_remediation.mjs +++ b/tools/tests/scripts/test_guidance_remediation.mjs @@ -455,6 +455,15 @@ test("security baseline blocks alternative TLS spellings and scans bicepparam an ["infra/bicep/t/main.bicepparam", "param publicNetworkAccess = 'Enabled'", 1], ["infra/bicep/t/main.bicepparam", "param publicNetworkAccess = 'Disabled'", 0], ["infra/main.bicep", "supportsHttpsTrafficOnly: false", 1], + ["infra/bicep/t/main.bicep", "enablePurgeProtection: false", 1], + ["infra/bicep/t/main.bicep", "enablePurgeProtection: true", 0], + ["infra/bicep/t/main.bicep", "enableRbacAuthorization: false", 1], + ["infra/bicep/t/main.bicep", "enableIPForwarding: true", 1], + ["infra/bicep/t/main.bicep", "resource gw 'Microsoft.Network/vpnGateways@2024-01-01' = {}", 1], + ["infra/bicep/t/main.bicepparam", "param enablePurgeProtection = false", 1], + ["infra/terraform/t/main.tf", "purge_protection_enabled = false", 1], + ["infra/terraform/t/main.tf", "rbac_authorization_enabled = true", 0], + ["infra/terraform/t/main.tf", 'resource "azurerm_virtual_network_gateway" "gw" {}', 1], ]; for (const [relative, content, expected] of cases) { const directory = mkdtempSync(path.join(tmpdir(), "baseline-patterns-")); diff --git a/tools/tests/scripts/test_mcp_tool_refs.mjs b/tools/tests/scripts/test_mcp_tool_refs.mjs new file mode 100644 index 000000000..672fbd61b --- /dev/null +++ b/tools/tests/scripts/test_mcp_tool_refs.mjs @@ -0,0 +1,123 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { pinProblems, scanText } from "../../scripts/validate-mcp-tool-refs.mjs"; +import { buildSnapshot, parseLearnReply, parametersOf, sortKeys } from "../../scripts/refresh-azure-mcp-snapshot.mjs"; + +const snapshot = { + schemaVersion: 1, + serverVersion: "3.0.0", + tools: { + monitor: { + commands: { + monitor_workspace_log_query: { parameters: { query: true, subscription: false, table: true, workspace: true } }, + }, + }, + resourcehealth: { + commands: { "resourcehealth_availability-status_get": { parameters: { resourceId: false, subscription: true } } }, + }, + extension_cli_generate: { parameters: { "cli-type": true, intent: true } }, + group_list: { parameters: { subscription: true } }, + }, +}; + +const problems = (text) => scanText(text, snapshot).map((problem) => problem.message); + +test("accepts valid tool, command and parameter references", () => { + assert.deepEqual( + problems( + 'Use `mcp_azure-mcp_monitor` with `command: "monitor_workspace_log_query"`, `parameters: { workspace: "w", query: "q" }`', + ), + [], + ); + assert.deepEqual(problems("| `mcp_azure-mcp_group_list` | List resource groups |"), []); + assert.deepEqual(problems('mcp_azure-mcp_extension_cli_generate\n intent: "x"\n cli-type: "az"'), []); +}); + +test("accepts the shortened client prefix and still validates against it", () => { + assert.deepEqual( + problems( + 'Use `mcp_azure_mcp_ser_monitor` with `command: "monitor_workspace_log_query"`, `parameters: { workspace: "w" }`', + ), + [], + ); + assert.deepEqual(problems("written `mcp_azure-mcp_` or `mcp_azure_mcp_ser_`"), []); + assert.deepEqual(problems("`mcp_azure_mcp_ser_nope`"), ["unknown tool mcp_azure-mcp_nope"]); + assert.deepEqual(problems('`mcp_azure_mcp_ser_monitor` command: "monitor_nope"'), [ + 'mcp_azure-mcp_monitor has no command "monitor_nope"', + ]); +}); + +test("flags unknown tools, commands and parameters", () => { + assert.deepEqual(problems("`mcp_azure-mcp_nope`"), ["unknown tool mcp_azure-mcp_nope"]); + assert.deepEqual(problems('`mcp_azure-mcp_monitor` command: "monitor_nope"'), [ + 'mcp_azure-mcp_monitor has no command "monitor_nope"', + ]); + assert.deepEqual( + problems('`mcp_azure-mcp_monitor` command: "monitor_workspace_log_query", parameters: { "resource-group": "rg" }'), + ['mcp_azure-mcp_monitor has no parameter "resource-group"'], + ); +}); + +test("parameter names are case sensitive", () => { + assert.deepEqual( + problems( + 'mcp_azure-mcp_resourcehealth\n command: "resourcehealth_availability-status_get"\n parameters:\n resourceid: "x"', + ), + ['mcp_azure-mcp_resourcehealth has no parameter "resourceid"'], + ); +}); + +test("flags direct-tool parameters that do not exist", () => { + assert.deepEqual(problems("mcp_azure-mcp_group_list({ subscription: 's', location: 'l' })"), [ + 'mcp_azure-mcp_group_list has no parameter "location"', + ]); +}); + +test("the ignore marker skips a reference on the same or previous line", () => { + assert.deepEqual(problems("`mcp_azure-mcp_nope` "), []); + assert.deepEqual(problems("\n`mcp_azure-mcp_nope`"), []); +}); + +test("snapshot builder is deterministic and keeps wrapped and direct tools apart", () => { + const listed = [ + { name: "wrapped", inputSchema: { properties: { command: {}, intent: {} } } }, + { name: "direct", inputSchema: { properties: { b: {}, a: {} }, required: ["a"] } }, + ]; + const learned = { wrapped: [{ command: "w_get", inputSchema: { properties: { z: {} }, required: ["z"] } }] }; + const first = JSON.stringify(buildSnapshot("9.9.9", listed, learned)); + assert.equal(first, JSON.stringify(buildSnapshot("9.9.9", [...listed].reverse(), learned))); + assert.deepEqual(JSON.parse(first).tools, { + direct: { parameters: { a: true, b: false } }, + wrapped: { commands: { w_get: { parameters: { z: true } } } }, + }); + assert.deepEqual(parametersOf(undefined), {}); + assert.deepEqual(Object.keys(sortKeys({ b: 1, a: { d: 1, c: 2 } })), ["a", "b"]); +}); + +test("learn replies are parsed from prose plus JSON and fail loudly otherwise", () => { + assert.deepEqual(parseLearnReply('Here are the commands: [{"command":"x"}]'), [{ command: "x" }]); + assert.throws(() => parseLearnReply("no commands here")); +}); + +test("pin check compares both configuration files with the snapshot version", (context) => { + const root = mkdtempSync(join(tmpdir(), "mcp-pin-")); + context.after(() => rmSync(root, { recursive: true, force: true })); + mkdirSync(join(root, ".vscode")); + mkdirSync(join(root, ".devcontainer")); + const write = (workspace, defaults) => { + writeFileSync( + join(root, ".vscode/mcp.json"), + JSON.stringify({ servers: { "azure-mcp": { args: ["-y", `@azure/mcp@${workspace}`] } } }), + ); + writeFileSync(join(root, ".devcontainer/configure-mcp.mjs"), `args: ["-y", "@azure/mcp@${defaults}"]`); + }; + write("3.0.0", "3.0.0"); + assert.deepEqual(pinProblems(root, snapshot), []); + write("3.0.1", "3.0.0"); + assert.equal(pinProblems(root, snapshot).length, 1); + write("3.0.1", "2.0.5"); + assert.equal(pinProblems(root, snapshot).length, 2); +}); diff --git a/tools/tests/scripts/test_mutable_review_inputs.mjs b/tools/tests/scripts/test_mutable_review_inputs.mjs new file mode 100644 index 000000000..24c1fa2f7 --- /dev/null +++ b/tools/tests/scripts/test_mutable_review_inputs.mjs @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { mutableInputProblem } from "../../scripts/validate-challenger-findings.mjs"; + +const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); +const script = path.join(repo, "tools/scripts/validate-challenger-findings.mjs"); + +test("mutable parent-owned files are rejected as review inputs", () => { + for (const name of [ + "sku-manifest.json", + "sku-manifest.md", + "00-handoff.md", + "00-session-state.json", + "README.md", + "challenge-findings-plan-decisions.json", + ]) { + assert.match(mutableInputProblem(`agent-output/p/${name}`), /mutable parent-owned file/, name); + } + assert.equal(mutableInputProblem("agent-output/p/04-iac-contract.json"), null); + assert.equal(mutableInputProblem("agent-output/p/04-governance-constraints.json"), null); +}); + +test("metadata generation refuses a mutable supporting input with a remediation message", () => { + const result = spawnSync( + process.execPath, + [script, "--metadata", "package.json", "--supporting-input", "agent-output/p/sku-manifest.json"], + { cwd: repo, encoding: "utf8" }, + ); + assert.notEqual(result.status, 0); + assert.match(result.stdout + result.stderr, /Remove it from supporting inputs/); +}); diff --git a/tools/tests/scripts/test_policy_map_status.mjs b/tools/tests/scripts/test_policy_map_status.mjs new file mode 100644 index 000000000..2ffa8cfd0 --- /dev/null +++ b/tools/tests/scripts/test_policy_map_status.mjs @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); +const validator = path.join(repo, "tools/scripts/validate-policy-property-map.mjs"); + +function check(context, policy) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "policy-status-")); + context.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const file = path.join(directory, "04-policy-property-map.json"); + fs.writeFileSync( + file, + JSON.stringify({ + schema_version: "policy-property-map-v1", + project: "synthetic", + generated_at: "2026-10-15T00:00:00Z", + governance_depth: "light", + policies: [ + { + policy_id: "block-vm-sku", + display_name: "Block VM SKU sizes", + effect: "Deny", + target_property: { resource_type: "Microsoft.Compute/virtualMachines", property_path: "properties.size" }, + evidence_required: "inline parameter", + ...policy, + }, + ], + }), + ); + const result = spawnSync(process.execPath, [validator, file], { cwd: repo, encoding: "utf8" }); + return { status: result.status, text: result.stdout + result.stderr }; +} + +test("a not-applicable policy row needs a reason", (context) => { + const missing = check(context, { status: "not-applicable" }); + assert.notEqual(missing.status, 0); + assert.match(missing.text, /no not_applicable_reason/); + const reasoned = check(context, { status: "not-applicable", not_applicable_reason: "No VM in the plan" }); + assert.equal(reasoned.status, 0, reasoned.text); +}); + +test("a reason is only valid on a not-applicable row", (context) => { + const result = check(context, { status: "satisfied", not_applicable_reason: "stale text" }); + assert.notEqual(result.status, 0); + assert.match(result.text, /status is not not-applicable/); +}); + +test("an unsatisfiable policy row fails and routes to governance", (context) => { + const result = check(context, { status: "unsatisfiable" }); + assert.notEqual(result.status, 0); + assert.match(result.text, /Refresh Governance/); +}); + +test("a row without a status keeps the earlier behaviour", (context) => { + const result = check(context, {}); + assert.equal(result.status, 0, result.text); +}); diff --git a/tools/tests/scripts/test_post_write_validation.mjs b/tools/tests/scripts/test_post_write_validation.mjs index 81a905af9..ccfbdc250 100644 --- a/tools/tests/scripts/test_post_write_validation.mjs +++ b/tools/tests/scripts/test_post_write_validation.mjs @@ -104,6 +104,20 @@ test("network pricing recipes use verified billing mappings without omitting var assert.match(parent, /do not invent zero usage/); }); +test("cost worker prices only through Azure MCP pricing and no ARM MCP tools", () => { + const worker = path.join(ROOT, ".github/agents/_subagents/cost-estimate-subagent.agent.md"); + const frontmatter = parseFrontmatter(fs.readFileSync(worker, "utf8")); + const tools = [].concat(frontmatter.tools).join(" "); + assert.match(tools, /azure-mcp\/pricing/); + assert.doesNotMatch(tools, /azure-resource-manager-mcp/); + const guidance = fs.readFileSync( + path.join(ROOT, ".github/skills/apex-azure-defaults/references/pricing-guidance.md"), + "utf8", + ); + assert.doesNotMatch(guidance, /get_retail_prices/); + assert.match(guidance, /`pricing_get`/); +}); + test("retail fallback stays worker-owned with shared budgets, source evidence and approval gates", () => { const read = (file) => fs.readFileSync(path.join(ROOT, file), "utf8"); const worker = read(".github/agents/_subagents/cost-estimate-subagent.agent.md"); diff --git a/tools/tests/scripts/test_profile_debug_log.py b/tools/tests/scripts/test_profile_debug_log.py index d34cc7f23..712d9dfbb 100644 --- a/tools/tests/scripts/test_profile_debug_log.py +++ b/tools/tests/scripts/test_profile_debug_log.py @@ -262,3 +262,59 @@ def test_invalid_usage_is_unknown_not_a_measured_zero(profiler, value): def test_empty_profile_is_not_complete_usage(profiler): assert profiler.profile([])["usage_coverage"]["complete"] is False + + +def test_jsonl_debug_log_parsing_and_pricing_hygiene_metrics(profiler, tmp_path): + jsonl = tmp_path / "worker.jsonl" + events = [ + { + "ts": 1000, + "dur": 2000, + "sid": "s1", + "tid": "t1", + "type": "llm_request", + "name": "chat:gpt-5.4-mini", + "status": "ok", + "attrs": {"model": "gpt-5.4-mini", "inputTokens": 12000, "outputTokens": 400}, + }, + { + "ts": 3000, + "dur": 500, + "sid": "s2", + "tid": "t1", + "type": "tool_call", + "name": "mcp_azure_mcp_ser_pricing", + "status": "ok", + "attrs": {"args": {"intent": "learn", "learn": True}, "result": "schema"}, + }, + { + "ts": 3100, + "dur": 600, + "sid": "s3", + "tid": "t1", + "type": "tool_call", + "name": "mcp_azure_mcp_ser_pricing", + "status": "ok", + "attrs": {"args": {"intent": "price", "command": "pricing_get", "learn": False}, "result": "{}"}, + }, + { + "ts": 4000, + "dur": 300000, + "sid": "s4", + "tid": "t1", + "type": "tool_call", + "name": "run_in_terminal", + "status": "ok", + "attrs": {"args": {"command": 'node -e "if (!fs.existsSync())"'}, "result": "event not found"}, + }, + ] + jsonl.write_text("\n".join(json.dumps(e) for e in events), encoding="utf-8") + spans = profiler.load_spans(jsonl) + assert len(spans) == 4 + metrics = profiler.profile(spans) + assert metrics["totals"]["input_tokens"] == 12000 + assert metrics["totals"]["output_tokens"] == 400 + assert metrics["totals"]["chat_calls"] == 1 + assert metrics["totals"]["pricing_learn_calls"] == 1 + assert metrics["totals"]["tool_stalls_over_60s"] == 1 + assert metrics["wall_time_s"]["tool_execution_union"] == 300.7 diff --git a/tools/tests/scripts/test_recipe_remediation.mjs b/tools/tests/scripts/test_recipe_remediation.mjs index e899cdbc0..277831aac 100644 --- a/tools/tests/scripts/test_recipe_remediation.mjs +++ b/tools/tests/scripts/test_recipe_remediation.mjs @@ -670,7 +670,7 @@ test("SK-14 host configuration protects the native endpoint and pins the extensi }); test("SK-14 SDK client fixtures parse even when dependencies are unavailable", () => { - const source = readFileSync(new URL(import.meta.url), "utf8"); + const source = readFileSync(new URL(import.meta.url), "utf8").replace(/\r\n/g, "\n"); const sections = [...source.matchAll(/^test\("SK-14 real (Node|Python) SDK[^\n]*\n([\s\S]*?)(?=^test\()/gm)]; assert.equal(sections.length, 2); for (const [, language, section] of sections) { diff --git a/tools/tests/scripts/test_retail_price_evidence.mjs b/tools/tests/scripts/test_retail_price_evidence.mjs index deed0e2a9..296adc174 100644 --- a/tools/tests/scripts/test_retail_price_evidence.mjs +++ b/tools/tests/scripts/test_retail_price_evidence.mjs @@ -53,6 +53,22 @@ test("direct fallback preserves raw responses and failure provenance without com assert.equal(result.monthly_total, undefined); }); +test("pricing_get empty results authorize fallback but errors and nonempty prices do not", async () => { + const emptyPricingGet = { status: 200, message: "Success", results: { prices: [] } }; + const input = request(); + input.mcp_failure.attempts[0].response = emptyPricingGet; + const result = await fetchRetailEvidence(input, async () => response(page())); + assert.deepEqual(result.mcp_failure.attempts[0].response, emptyPricingGet); + for (const body of [ + { status: 400, message: "must specify sku", results: { prices: [] } }, + { status: 200, message: "Success", results: { prices: [{ meterName: "Private Zone" }] } }, + ]) { + const blocked = request(); + blocked.mcp_failure.attempts[0].response = body; + await assert.rejects(fetchRetailEvidence(blocked, () => assert.fail("must not fetch"))); + } +}); + test("query building escapes OData literals and rejects arbitrary filters", () => { assert.match(retailQueryUrl({ ...query, meterName: "Owner's Zone" }).searchParams.get("$filter"), /Owner''s Zone/); assert.throws(() => retailQueryUrl({ ...query, url: "https://other.test" })); @@ -161,6 +177,99 @@ test("transient fallback requires the exhausted HTTP retry and excludes authoriz ); }); +test("missing_tier_metadata fallback authorizes multi-rate pricing_get rows that omit tierMinimumUnits", async () => { + const blobQuery = { + serviceName: "Storage", + armRegionName: "swedencentral", + productName: "Blob Storage", + skuName: "Hot LRS", + meterName: "Hot LRS Data Stored", + priceType: "Consumption", + currencyCode: "USD", + }; + const input = { + query: { ...blobQuery }, + remaining_requests: 3, + mcp_failure: { + kind: "missing_tier_metadata", + recorded_at: new Date(Date.now() - 1000).toISOString(), + attempts: [ + { + query: { ...blobQuery }, + response: { + status: 200, + message: "Success", + results: { + prices: [ + { + serviceName: "Storage", + productName: "Blob Storage", + skuName: "Hot LRS", + meterName: "Hot LRS Data Stored", + retailPrice: 0.0184, + }, + { + serviceName: "Storage", + productName: "Blob Storage", + skuName: "Hot LRS", + meterName: "Hot LRS Data Stored", + retailPrice: 0.017664, + }, + ], + }, + }, + }, + ], + }, + }; + const blobPage = { + BillingCurrency: "USD", + Items: [ + { + ...row, + serviceName: "Storage", + armRegionName: "swedencentral", + productName: "Blob Storage", + skuName: "Hot LRS", + meterName: "Hot LRS Data Stored", + retailPrice: 0.0184, + tierMinimumUnits: 0, + }, + { + ...row, + serviceName: "Storage", + armRegionName: "swedencentral", + productName: "Blob Storage", + skuName: "Hot LRS", + meterName: "Hot LRS Data Stored", + retailPrice: 0.017664, + tierMinimumUnits: 51200, + }, + ], + NextPageLink: null, + }; + const result = await fetchRetailEvidence(input, async () => response(blobPage)); + assert.equal(result.requests_used, 1); + + // Rejects when tierMinimumUnits is already present in pricing_get response + const alreadyHasTiers = structuredClone(input); + alreadyHasTiers.mcp_failure.attempts[0].response.results.prices[0].tierMinimumUnits = 0; + await assert.rejects( + fetchRetailEvidence(alreadyHasTiers, () => assert.fail("must not fetch")), + /omit tierMinimumUnits/, + ); + + // Rejects when only a single rate row was returned + const singleRate = structuredClone(input); + singleRate.mcp_failure.attempts[0].response.results.prices = [ + singleRate.mcp_failure.attempts[0].response.results.prices[0], + ]; + await assert.rejects( + fetchRetailEvidence(singleRate, () => assert.fail("must not fetch")), + /multiple rate rows/, + ); +}); + test("CLI refuses existing output before reading request or making network calls", (context) => { const directory = mkdtempSync(path.join(tmpdir(), "retail-evidence-")); context.after(() => rmSync(directory, { recursive: true, force: true })); diff --git a/tools/tests/scripts/test_safe_shell.mjs b/tools/tests/scripts/test_safe_shell.mjs index 4ef4be48e..fe160454a 100644 --- a/tools/tests/scripts/test_safe_shell.mjs +++ b/tools/tests/scripts/test_safe_shell.mjs @@ -74,3 +74,18 @@ test("non-compliant heredoc/tee/append writes to agent-output are each flagged", assert.match(snippets, /\| tee /); assert.match(snippets, />> /); }); + +test("node-e-double-quoted-bang flags double-quoted !identifier in node -e but allows single-quoted or script calls", () => { + const rule = RULES.find((r) => r.id === "node-e-double-quoted-bang"); + assert.ok(rule, "node-e-double-quoted-bang rule must be registered"); + assert.equal( + rule.pattern.test("node -e \"const fs = require('fs'); if (!fs.existsSync('a')) process.exit(1);\""), + true, + ); + assert.equal(rule.pattern.test('node --input-type=module --eval "if (!ok) throw new Error();"'), true); + assert.equal( + rule.pattern.test('node -e \'const fs = require("fs"); if (!fs.existsSync("a")) process.exit(1);\''), + false, + ); + assert.equal(rule.pattern.test("node tools/scripts/cost-estimate.mjs publish --draft a.tmp --output a.json"), false); +}); diff --git a/tools/tests/scripts/test_session_handoff.mjs b/tools/tests/scripts/test_session_handoff.mjs index f641afe72..99153e4fd 100644 --- a/tools/tests/scripts/test_session_handoff.mjs +++ b/tools/tests/scripts/test_session_handoff.mjs @@ -43,6 +43,22 @@ test("handoff derives exact headings and preserves operation limits", () => { assert.match(text, /grants no new approval/); assert.match(text, /Step 5: pending/); }); + +test("handoff shows reopened steps as stale with the reason and the superseded plan", () => { + const reopened = view(); + reopened.session.steps = { + 2: { status: "in_progress" }, + "3_5": { status: "stale", stale_because: "Step 2 was reopened" }, + 4: { status: "stale" }, + }; + reopened.session.decisions.plan_status = "SUPERSEDED"; + reopened.session.open_findings = ["plan-devops-access carried"]; + const text = renderHandoff(reopened, options); + assert.match(text, /Step 3_5: stale \(Step 2 was reopened\); redo after its predecessor\./); + assert.match(text, /Step 4: stale \(an upstream step was reopened\)/); + assert.match(text, /plan_status: SUPERSEDED/); + assert.match(text, /plan-devops-access carried/); +}); test("handoff refuses evidence failure and overflow instead of dropping blockers", () => { const missingPath = view(); missingPath.session.open_findings = ["Operator VPN route missing"]; diff --git a/tools/tests/scripts/test_step2_residue_presence.mjs b/tools/tests/scripts/test_step2_residue_presence.mjs new file mode 100644 index 000000000..fd9e8afeb --- /dev/null +++ b/tools/tests/scripts/test_step2_residue_presence.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const script = fileURLToPath(new URL("../../scripts/validate-challenger-presence.mjs", import.meta.url)); + +test("CI flags scratch and failed cost files only once Step 2 is complete", (context) => { + const root = mkdtempSync(path.join(tmpdir(), "apex-step2-residue-")); + context.after(() => rmSync(root, { recursive: true, force: true })); + const project = path.join(root, "agent-output/demo"); + mkdirSync(project, { recursive: true }); + writeFileSync(path.join(project, "02-waf-research.tmp.md"), "scratch"); + writeFileSync(path.join(project, "02-cost-estimate-rev2.json"), '{"status":"FAILED"}'); + const state = (status) => + writeFileSync(path.join(project, "00-session-state.json"), JSON.stringify({ steps: { 2: { status } } })); + const run = () => spawnSync(process.execPath, [script], { cwd: root, encoding: "utf8" }); + + state("in_progress"); + assert.equal(run().status, 0); + + state("complete"); + const flagged = run(); + assert.equal(flagged.status, 1); + assert.match(flagged.stdout + flagged.stderr, /02-waf-research\.tmp\.md: scratch file/); + assert.match(flagged.stdout + flagged.stderr, /02-cost-estimate-rev2\.json: cost estimate with status FAILED/); + + rmSync(path.join(project, "02-waf-research.tmp.md")); + mkdirSync(path.join(project, "_meta")); + renameSync(path.join(project, "02-cost-estimate-rev2.json"), path.join(project, "_meta/02-cost-estimate-rev2.json")); + assert.equal(run().status, 0); +}); diff --git a/tools/tests/scripts/test_upstream_alignment.mjs b/tools/tests/scripts/test_upstream_alignment.mjs index e0ba0d3e5..459253245 100644 --- a/tools/tests/scripts/test_upstream_alignment.mjs +++ b/tools/tests/scripts/test_upstream_alignment.mjs @@ -52,14 +52,14 @@ test("validate recipes run from the per-project IaC folder", () => { } }); -test("cost reports stay in the project folder and prices come from ARM MCP", () => { +test("cost reports stay in the project folder and prices come from Azure MCP pricing", () => { for (const file of ["SKILL.md", ...markdownFiles("apex-azure-cost-optimization/references/")]) { const relative = file.startsWith("apex-") ? file : `apex-azure-cost-optimization/${file}`; assert.doesNotMatch(read(relative), /(? rmSync(root, { recursive: true, force: true })); + mkdirSync(join(root, "tools/scripts"), { recursive: true }); + cpSync(new URL("tools/scripts/_lib", repo), join(root, "tools/scripts/_lib"), { recursive: true }); + cpSync( + new URL("tools/scripts/validate-session-state.mjs", repo), + join(root, "tools/scripts/validate-session-state.mjs"), + ); + mkdirSync(join(root, ".github/skills/apex-azure-artifacts/templates"), { recursive: true }); + cpSync(new URL(template, repo), join(root, template)); + mkdirSync(join(root, "agent-output/demo"), { recursive: true }); + const state = JSON.parse(readFileSync(new URL(template, repo), "utf8")); + state.project = "demo"; + state.decision_log = decisionLog; + writeFileSync(join(root, "agent-output/demo/00-session-state.json"), JSON.stringify(state)); + return spawnSync(process.execPath, [join(root, "tools/scripts/validate-session-state.mjs")], { + cwd: root, + encoding: "utf8", + }); +} + +test("accepts the entries apex-recall and the risk gate actually write", (context) => { + const result = validate(context, [ + { decision: "review_depth=default", timestamp: "2026-10-14T14:23:35Z", rationale: "boot default" }, + { decision: "pin sql", timestamp: "2026-10-14T14:28:33Z", step: "1" }, + { decision: "gate", timestamp: "2026-10-14T14:28:33Z", step: "3_5" }, + { + id: "D003", + step: 4, + agent: "apex-recall", + title: "Explicit lab risk authorization", + choice: "EXCEPTION_AUTHORIZED", + rationale: "r", + decision: "d", + timestamp: "2026-10-14T14:28:33Z", + }, + ]); + assert.equal(result.status, 0, result.stdout + result.stderr); +}); + +test("rejects entries with no decision text, a bad id, an unknown step or a non-object", (context) => { + for (const entry of [ + { timestamp: "2026-10-14T14:28:33Z" }, + { decision: "x", id: "first" }, + { decision: "x", step: "9" }, + { decision: "x", step: 0 }, + "text", + ]) { + const result = validate(context, [entry]); + assert.notEqual(result.status, 0, JSON.stringify(entry)); + } +}); diff --git a/tools/tests/test_apex_recall/test_reopen_and_gates.py b/tools/tests/test_apex_recall/test_reopen_and_gates.py new file mode 100644 index 000000000..42f660bff --- /dev/null +++ b/tools/tests/test_apex_recall/test_reopen_and_gates.py @@ -0,0 +1,243 @@ +"""Tests for the reopen cascade, the Step 2 completion check and path-based review-attempt digests.""" + +from __future__ import annotations + +import hashlib +import io +import json +import os +import sys +from pathlib import Path + +import pytest + +from apex_recall.__main__ import main +from apex_recall.reopen import DEPENDENTS + +REPO = Path(__file__).resolve().parents[3] + + +@pytest.fixture +def workspace(tmp_path: Path) -> Path: + (tmp_path / "agent-output").mkdir() + (tmp_path / "tmp").mkdir() + return tmp_path + + +def _run(workspace: Path, argv: list[str]) -> tuple[int, str]: + old_cwd = os.getcwd() + os.chdir(workspace) + captured = io.StringIO() + old_stdout, sys.stdout = sys.stdout, captured + try: + rc = main(argv) + except SystemExit as error: + rc = error.code or 0 + finally: + sys.stdout = old_stdout + os.chdir(old_cwd) + return rc, captured.getvalue() + + +def _state(workspace: Path, project: str) -> dict: + return json.loads((workspace / "agent-output" / project / "00-session-state.json").read_text()) + + +def _finish(workspace: Path, project: str, steps: list[str]) -> None: + for step in steps: + assert _run(workspace, ["start-step", project, step, "--json"])[0] == 0 + assert _run(workspace, ["complete-step", project, step, "--json"])[0] == 0 + + +@pytest.fixture +def approved(workspace: Path) -> Path: + _run(workspace, ["init", "sp", "--json"]) + _finish(workspace, "sp", ["1", "2", "3_5", "4"]) + _run(workspace, ["decide", "sp", "--key", "plan_status", "--value", "APPROVED", "--step", "4", "--json"]) + return workspace + + +def test_reopening_a_step_marks_dependents_stale_and_supersedes_the_plan(approved: Path): + rc, out = _run(approved, ["start-step", "sp", "2", "--force", "--json"]) + assert rc == 0 + payload = json.loads(out) + assert payload["stale_steps"] == ["3_5", "4"] + assert payload["plan_status"] == "SUPERSEDED" + state = _state(approved, "sp") + for step in ("3_5", "4"): + entry = state["steps"][step] + assert entry["status"] == "stale" + assert entry["started"] is None and entry["completed"] is None + assert entry["stale_because"] == "Step 2 was reopened" + assert entry["previous_completed"] + assert state["steps"]["3"]["status"] == "pending" + assert state["steps"]["1"]["status"] == "complete" + assert state["decisions"]["plan_status"] == "SUPERSEDED" + assert any("Step 2 reopened" in entry["decision"] for entry in state["decision_log"]) + + +def test_a_stale_step_cannot_start_until_its_predecessor_is_complete_again(approved: Path): + _run(approved, ["start-step", "sp", "2", "--force", "--json"]) + rc, _ = _run(approved, ["start-step", "sp", "3_5", "--json"]) + assert rc == 2 + assert _run(approved, ["complete-step", "sp", "2", "--json"])[0] == 0 + rc, _ = _run(approved, ["start-step", "sp", "3_5", "--json"]) + assert rc == 0 + entry = _state(approved, "sp")["steps"]["3_5"] + assert entry["status"] == "in_progress" + assert not {"stale_since", "stale_because", "previous_completed"} & set(entry) + + +def test_transition_into_a_stale_step_clears_the_markers(approved: Path): + _run(approved, ["start-step", "sp", "2", "--force", "--json"]) + _run(approved, ["complete-step", "sp", "2", "--json"]) + rc, _ = _run(approved, ["transition", "sp", "--from-step", "2", "--to-step", "3_5", "--json"]) + assert rc == 0 + entry = _state(approved, "sp")["steps"]["3_5"] + assert entry["status"] == "in_progress" + assert "stale_since" not in entry + + +def test_reopening_the_plan_step_supersedes_its_own_approval(approved: Path): + rc, out = _run(approved, ["start-step", "sp", "4", "--force", "--json"]) + assert rc == 0 + assert json.loads(out)["plan_status"] == "SUPERSEDED" + assert _state(approved, "sp")["steps"]["4"]["status"] == "in_progress" + + +def test_reopening_the_last_step_has_no_dependents(approved: Path): + _finish(approved, "sp", ["5", "6", "7"]) + rc, out = _run(approved, ["start-step", "sp", "7", "--force", "--json"]) + assert rc == 0 + assert "stale_steps" not in json.loads(out) + + +def test_dependents_match_the_workflow_graph(): + nodes = json.loads((REPO / ".github/skills/apex-workflow-engine/templates/workflow-graph.json").read_text())[ + "nodes" + ] + key = {"step-5b": "5", "step-5t": "5", "step-6b": "6", "step-6t": "6"} + + def step_key(node_id: str) -> str | None: + return key.get(node_id) or (node_id.removeprefix("step-") if node_id.startswith("step-") else None) + + def requires_closure(node_id: str) -> set[str]: + found: set[str] = set() + for parent in nodes[node_id].get("requires", []): + found |= {parent} | requires_closure(parent) + return found + + derived: dict[str, set[str]] = {step_key(n): set() for n in nodes if step_key(n)} + for node_id in nodes: + if not step_key(node_id): + continue + for ancestor in requires_closure(node_id): + if step_key(ancestor) and step_key(ancestor) != step_key(node_id): + derived[step_key(ancestor)].add(step_key(node_id)) + assert {step: set(values) for step, values in DEPENDENTS.items()} == derived + + +def test_step_2_refuses_scratch_files_even_with_a_challenger_skip(workspace: Path): + _run(workspace, ["init", "sp", "--json"]) + _finish(workspace, "sp", ["1"]) + _run(workspace, ["start-step", "sp", "2", "--json"]) + project = workspace / "agent-output" / "sp" + (project / "02-waf-research.tmp.md").write_text("scratch") + rc, out = _run(workspace, ["complete-step", "sp", "2", "--json"]) + assert rc == 2 + assert json.loads(out)["error"] == "step2_residue" + assert "_meta" in json.loads(out)["remediation"] + rc, _ = _run( + workspace, ["complete-step", "sp", "2", "--allow-missing-challenger", "--challenger-skip-reason", "x", "--json"] + ) + assert rc == 2 + (project / "02-waf-research.tmp.md").unlink() + assert _run(workspace, ["complete-step", "sp", "2", "--json"])[0] == 0 + + +def test_step_2_refuses_failed_cost_estimates_until_archived(workspace: Path): + _run(workspace, ["init", "sp", "--json"]) + _finish(workspace, "sp", ["1"]) + _run(workspace, ["start-step", "sp", "2", "--json"]) + project = workspace / "agent-output" / "sp" + (project / "02-cost-estimate-rev2.json").write_text(json.dumps({"status": "FAILED"})) + (project / "02-cost-estimate.json").write_text(json.dumps({"status": "COMPLETE"})) + rc, out = _run(workspace, ["transition", "sp", "--from-step", "2", "--to-step", "3_5", "--complete", "--json"]) + assert rc == 2 + assert json.loads(out)["files"] == ["02-cost-estimate-rev2.json: cost estimate with status FAILED"] + (project / "_meta").mkdir() + (project / "02-cost-estimate-rev2.json").rename(project / "_meta" / "02-cost-estimate-rev2.json") + rc, _ = _run(workspace, ["transition", "sp", "--from-step", "2", "--to-step", "3_5", "--complete", "--json"]) + assert rc == 0 + + +def test_attempt_digest_comes_from_input_paths(workspace: Path): + _run(workspace, ["init", "sp", "--json"]) + project = workspace / "agent-output" / "sp" + (project / "04-implementation-plan.md").write_text("plan") + (project / "04-iac-contract.json").write_text("{}") + inputs = ["agent-output/sp/04-iac-contract.json", "agent-output/sp/04-implementation-plan.md"] + arguments = ["review-audit", "sp", "4", "--attempt-id", "plan-1", "--attempt-kind", "invocation", "--json"] + for path in inputs: + arguments += ["--attempt-input", path] + rc, out = _run(workspace, [*arguments, "--attempt-outcome", "started"]) + assert rc == 0 + started = json.loads(out) + expected = hashlib.sha256( + json.dumps( + [ + ["agent-output/sp/04-iac-contract.json", hashlib.sha256(b"{}").hexdigest()], + ["agent-output/sp/04-implementation-plan.md", hashlib.sha256(b"plan").hexdigest()], + ], + separators=(",", ":"), + ).encode() + ).hexdigest() + assert started["input_digest"] == expected + assert started["permission_granted_by_this_record"] is False + assert started["authorization"] == "not_granted" + rc, out = _run(workspace, [*arguments, "--attempt-outcome", "completed"]) + assert rc == 0 + assert json.loads(out)["input_digest"] == expected + + +def test_a_changed_input_cannot_finish_the_started_attempt(workspace: Path): + _run(workspace, ["init", "sp", "--json"]) + project = workspace / "agent-output" / "sp" + (project / "04-implementation-plan.md").write_text("plan") + arguments = [ + "review-audit", + "sp", + "4", + "--attempt-id", + "plan-1", + "--attempt-kind", + "invocation", + "--attempt-input", + "agent-output/sp/04-implementation-plan.md", + "--json", + ] + assert _run(workspace, [*arguments, "--attempt-outcome", "started"])[0] == 0 + (project / "04-implementation-plan.md").write_text("plan, edited during review") + assert _run(workspace, [*arguments, "--attempt-outcome", "completed"])[0] == 1 + + +def test_attempt_inputs_cannot_escape_the_workspace(workspace: Path): + _run(workspace, ["init", "sp", "--json"]) + rc, _ = _run( + workspace, + [ + "review-audit", + "sp", + "4", + "--attempt-id", + "plan-1", + "--attempt-kind", + "invocation", + "--attempt-input", + "../outside.md", + "--attempt-outcome", + "started", + "--json", + ], + ) + assert rc == 1