From 44597184c5d19e399d2d17c190356db01102f171 Mon Sep 17 00:00:00 2001 From: Arnaud Lheureux Date: Mon, 15 Jun 2026 10:34:42 +0800 Subject: [PATCH 1/4] feat(skills): add landing zone discovery skill with cross-shell scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add azure-landing-zone-discovery skill, evals, fixtures, and docs - ship discover-lz/inject-lz in both bash and PowerShell parity ports - document dual-shell helper-script convention in authoring docs - wire landing-zone context into agents and copilot-instructions 🧭 - Generated by Copilot --- .github/agents/azure-policy-advisor.agent.md | 18 +- .../azure-requirements-gatherer.agent.md | 136 +++ .../agents/azure-template-generator.agent.md | 55 + .github/agents/git-ape-onboarding.agent.md | 3 +- .github/agents/git-ape.agent.md | 39 +- .github/copilot-instructions.md | 13 + .../azure-landing-zone-discovery/eval.yaml | 43 + .../tasks/negative-function-deploy.yaml | 15 + .../tasks/negative-naming-research.yaml | 15 + .../tasks/positive-discover-landing-zone.yaml | 49 + .../tasks/positive-manual-inject.yaml | 39 + .github/evals/manifest.yaml | 2 + .../azure-landing-zone-discovery/SKILL.md | 570 +++++++++++ .../scripts/discover-lz.ps1 | 832 +++++++++++++++ .../scripts/discover-lz.sh | 969 ++++++++++++++++++ .../scripts/inject-lz.ps1 | 318 ++++++ .../scripts/inject-lz.sh | 329 ++++++ .github/skills/azure-policy-advisor/SKILL.md | 21 + .github/skills/git-ape-onboarding/SKILL.md | 23 +- .gitignore | 1 + README.md | 3 +- tests/fixtures/landing-zone/README.md | 11 + tests/fixtures/landing-zone/flat-tenant.json | 84 ++ .../landing-zone/hub-spoke-tenant.json | 96 ++ .../landing-zone/skipped-network.json | 74 ++ website/docs/agents/azure-policy-advisor.md | 18 +- .../agents/azure-requirements-gatherer.md | 136 +++ .../docs/agents/azure-template-generator.md | 55 + website/docs/agents/git-ape-onboarding.md | 3 +- website/docs/agents/git-ape.md | 39 +- website/docs/authoring/skills.md | 21 + .../docs/deployment/landing-zone-context.md | 192 ++++ website/docs/getting-started/onboarding.md | 5 + website/docs/intro.md | 1 + .../skills/azure-landing-zone-discovery.md | 587 +++++++++++ website/docs/skills/azure-policy-advisor.md | 21 + website/docs/skills/git-ape-onboarding.md | 23 +- website/docs/skills/overview.md | 1 + .../landing-zone-aware-deployment.md | 181 ++++ website/docs/use-cases/policy-compliance.md | 1 + website/sidebars.ts | 2 + 41 files changed, 5025 insertions(+), 19 deletions(-) create mode 100644 .github/evals/azure-landing-zone-discovery/eval.yaml create mode 100644 .github/evals/azure-landing-zone-discovery/tasks/negative-function-deploy.yaml create mode 100644 .github/evals/azure-landing-zone-discovery/tasks/negative-naming-research.yaml create mode 100644 .github/evals/azure-landing-zone-discovery/tasks/positive-discover-landing-zone.yaml create mode 100644 .github/evals/azure-landing-zone-discovery/tasks/positive-manual-inject.yaml create mode 100644 .github/skills/azure-landing-zone-discovery/SKILL.md create mode 100644 .github/skills/azure-landing-zone-discovery/scripts/discover-lz.ps1 create mode 100755 .github/skills/azure-landing-zone-discovery/scripts/discover-lz.sh create mode 100644 .github/skills/azure-landing-zone-discovery/scripts/inject-lz.ps1 create mode 100755 .github/skills/azure-landing-zone-discovery/scripts/inject-lz.sh create mode 100644 tests/fixtures/landing-zone/README.md create mode 100644 tests/fixtures/landing-zone/flat-tenant.json create mode 100644 tests/fixtures/landing-zone/hub-spoke-tenant.json create mode 100644 tests/fixtures/landing-zone/skipped-network.json create mode 100644 website/docs/deployment/landing-zone-context.md create mode 100644 website/docs/skills/azure-landing-zone-discovery.md create mode 100644 website/docs/use-cases/landing-zone-aware-deployment.md diff --git a/.github/agents/azure-policy-advisor.agent.md b/.github/agents/azure-policy-advisor.agent.md index 61c0a99..b7d2dd0 100644 --- a/.github/agents/azure-policy-advisor.agent.md +++ b/.github/agents/azure-policy-advisor.agent.md @@ -27,18 +27,24 @@ Always use the `/azure-policy-advisor` skill for procedure, classification tiers - A general subscription audit - Compliance with a specific framework (CIS, NIST, etc.) 2. Read compliance preferences from `copilot-instructions.md` (the `## Compliance & Azure Policy` section). -3. If an ARM template is provided, parse resource types. Otherwise, ask what resource types to assess. -4. Execute the `/azure-policy-advisor` skill procedure: +3. **Load landing zone context** if `.azure/landing-zone-context.json` exists (produced by `/azure-landing-zone-discovery`). Use it to: + - **Dedupe** recommendations against `policies.denyEffects[]`, `policies.auditEffects[]`, and `policies.alzCanonicalAssignments[]` — do not recommend policies the tenant is already enforcing + - **Align region recommendations** with `policies.allowedLocations[]` instead of guessing + - **Align tag recommendations** with `policies.requiredTags[]` + - **Note inherited posture** in Part 2 (subscription-level actions) — say "✓ inherited from management group" for canonical ALZ assignments rather than re-recommending them + - Respect `landingZoneDetection.confidence` — when `low`/`none`, treat the policy lists as informational only (the tenant may not actually be ALZ-managed) +4. If an ARM template is provided, parse resource types. Otherwise, ask what resource types to assess. +5. Execute the `/azure-policy-advisor` skill procedure: - **Step 2:** Query existing policy assignments in the Azure subscription (via `az policy assignment list`) - **Step 3:** Discover unassigned custom/built-in policy definitions (via `az policy definition list`) - **Step 4:** Query Microsoft Learn for current built-in policy definitions per resource type - - **Step 5:** Classify and prioritize — cross-reference template config, existing assignments, and custom definitions + - **Step 5:** Classify and prioritize — cross-reference template config, existing assignments, custom definitions, **and the LZ context's tenant policy state** - **Step 6:** Generate split report: - **Part 1: Template Improvements** — gaps fixable by modifying the ARM template (developer action) - - **Part 2: Subscription-Level Actions** — policy/initiative assignments (platform team action) + - **Part 2: Subscription-Level Actions** — policy/initiative assignments (platform team action), with canonical ALZ assignments already enforced marked as "✓ already inherited" - **Step 7:** Provide implementation options for both tracks -5. Present the policy assessment report with the split Part 1 / Part 2 format. -6. Save `policy-assessment.md` and `policy-recommendations.json` to the deployment directory if one exists. +6. Present the policy assessment report with the split Part 1 / Part 2 format. +7. Save `policy-assessment.md` and `policy-recommendations.json` to the deployment directory if one exists. ## Output Requirements diff --git a/.github/agents/azure-requirements-gatherer.agent.md b/.github/agents/azure-requirements-gatherer.agent.md index c7e4961..ea30e23 100644 --- a/.github/agents/azure-requirements-gatherer.agent.md +++ b/.github/agents/azure-requirements-gatherer.agent.md @@ -117,6 +117,119 @@ User intent: Deploy Azure Function App - Ensure globally unique names for resources that require it - Follow organizational naming conventions +### 0.7. Detect Landing Zone Context + +Check whether a landing zone context has been discovered for this workspace. The file is produced by the **azure-landing-zone-discovery** skill and lets this agent route workloads to the right subscription, warn on policy conflicts, and surface shared services. + +**Step 1 — read the context:** + +```bash +LZ_CONTEXT_FILE=".azure/landing-zone-context.json" + +if [[ -f "$LZ_CONTEXT_FILE" ]]; then + DISCOVERED_AT=$(jq -r '.discoveredAt' "$LZ_CONTEXT_FILE") + # Stale-check (warn if > 7 days old) + AGE_DAYS=$(( ( $(date -u +%s) - $(date -u -d "$DISCOVERED_AT" +%s 2>/dev/null || date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$DISCOVERED_AT" +%s) ) / 86400 )) + [[ $AGE_DAYS -gt 7 ]] && echo "âš ī¸ Landing zone context is $AGE_DAYS days old — consider refreshing" + + # Detection confidence (see azure-landing-zone-discovery skill, "Landing Zone Detection Confidence") + LZ_CONFIDENCE=$(jq -r '.landingZoneDetection.confidence // "unknown"' "$LZ_CONTEXT_FILE") + LZ_SCORE=$(jq -r '.landingZoneDetection.confidenceScore // 0' "$LZ_CONTEXT_FILE") + LZ_IS_LZ=$(jq -r '.landingZoneDetection.isLandingZone // false' "$LZ_CONTEXT_FILE") +else + echo "â„šī¸ No landing zone context found at $LZ_CONTEXT_FILE" + echo " Run /azure-landing-zone-discovery to enable landing-zone-aware deployments," + echo " or proceed with subscription-only context." + # Continue without LZ context — do not block the user +fi +``` + +**How to treat `landingZoneDetection.confidence`:** + +| Confidence | Treatment | +|---|---| +| `high` (score â‰Ĩ 70) | Trust auto-classified subscription roles, hub-spoke topology, and shared services without prompting. | +| `medium` (40–69) | Surface matched + missing signals to the user (`.landingZoneDetection.matchedSignals[]`, `.landingZoneDetection.missingSignals[]`) and confirm before assuming ALZ-managed behavior. | +| `low` (10–39) | Note partial ALZ signals but default to standalone-tenant treatment. Do not auto-attach to hub or shared services. | +| `none` (< 10) | Treat as a flat/standalone tenant. If the user *knows* the tenant is ALZ-managed, suggest manual injection via the `inject-lz.sh` script. | + + +**Step 2 — classify the current subscription:** + +If the context exists, look up the active subscription in `subscriptions.platform[]` and `subscriptions.landingZones[]`: + +```bash +CURRENT_SUB_ID=$(az account show --query id -o tsv) +SUB_ROLE=$(jq -r --arg id "$CURRENT_SUB_ID" ' + (.subscriptions.platform[] | select(.id == $id) | .role) // + (.subscriptions.landingZones[] | select(.id == $id) | .role) // + "unclassified" +' "$LZ_CONTEXT_FILE") +``` + +**Warn if the user is targeting a platform subscription:** + +| Detected `role` | Action | +|------------------|--------| +| `connectivity`, `identity`, `management` | âš ī¸ **Block by default.** Display "This is a platform subscription. Workloads should land in an application landing zone." Offer to switch. | +| `landing-zone`, `landing-zone-dev`, `landing-zone-staging`, `landing-zone-prod` | ✅ Proceed. | +| `unclassified` (sub not in context) | Note that the subscription isn't part of the discovered topology. Ask user to confirm intent. | + +**Step 3 — surface the policy gates that may block this deployment:** + +```bash +DENY_COUNT=$(jq '.policies.denyEffects | length' "$LZ_CONTEXT_FILE") +ALLOWED_LOCATIONS=$(jq -r '.policies.allowedLocations | join(", ")' "$LZ_CONTEXT_FILE") +REQUIRED_TAGS=$(jq -r '.policies.requiredTags | join(", ")' "$LZ_CONTEXT_FILE") + +if [[ "$DENY_COUNT" -gt 0 ]]; then + echo "🛑 $DENY_COUNT Deny-effect policies apply to this scope:" + jq -r '.policies.denyEffects[] | " â€ĸ \(.name) — \(.impact)"' "$LZ_CONTEXT_FILE" +fi +[[ -n "$ALLOWED_LOCATIONS" && "$ALLOWED_LOCATIONS" != "" ]] && echo "📍 Allowed locations: $ALLOWED_LOCATIONS" +[[ -n "$REQUIRED_TAGS" && "$REQUIRED_TAGS" != "" ]] && echo "đŸˇī¸ Required tags: $REQUIRED_TAGS" +``` + +Treat `denyEffects` as gating: a user-requested region outside `allowedLocations`, a missing required tag, or a configuration that matches a deny impact MUST be raised before template generation. + +**Do NOT** surface entries from `auditEffects` as blockers — those are informational only. + +**Step 4 — note available shared services:** + +If `sharedServices.logAnalytics.id` / `containerRegistry.id` / `keyVault.id` are present, record them so Stage 2 (template generation) wires diagnostics, container images, and secrets to the shared platform resources instead of creating new ones. + +If `networking.topology == "hub-spoke"` and `networking.hubs[]` is non-empty, record the hub VNet ID(s) for VNet peering in Stage 2. + +Skip this step gracefully when: +- `discoveryMethod == "manual"` and fields are absent (user has not provided them) +- `topology == "flat"` (no hub-spoke to integrate with) +- `topology == "unknown"` (treat conservatively — do not assume any shared infra) + +**Display the landing zone summary to the user:** + +```markdown +## Landing Zone Context + +| Property | Value | +|----------|-------| +| **Discovered** | {discoveredAt} ({age} ago) | +| **Method** | {auto / manual} | +| **Detection** | {confidence} ({confidenceScore}/100) — isLandingZone: {true/false} | +| **Current subscription role** | {connectivity / landing-zone-prod / unclassified ...} | +| **Network topology** | {hub-spoke / flat / unknown} | +| **Deny policies** | {N} ({list if N ≤ 5}) | +| **Allowed locations** | {list or "any"} | +| **Required tags** | {list or "none"} | +| **Shared Log Analytics** | {name / "none"} | +| **Shared ACR** | {name / "none"} | +| **Hub VNet** | {name / "none"} | + +{If confidence is "medium" or "low":} Detected ALZ signals: {matchedSignals[].signal}. Missing: {missingSignals[]}. +{If platform subscription:} âš ī¸ Target subscription is a platform subscription — workloads should typically deploy elsewhere. +``` + +**Pass landing zone context to downstream stages** by including a `landingZone` block in the requirements output (see Section 4). + ### 1. Identify Resource Type(s) **Support Multi-Resource Deployments** - Ask if user wants single or multiple resources: @@ -386,6 +499,29 @@ Resource 3 (App Insights) → Resource 2 (Function App) "displayName": "{tenantDisplayName}", "domain": "{tenantDomain}" }, + "landingZone": { + "contextFile": ".azure/landing-zone-context.json", + "discoveredAt": "{ISO 8601 or null if no context}", + "discoveryMethod": "auto|manual|none", + "detection": { + "isLandingZone": false, + "confidence": "high|medium|low|none", + "confidenceScore": 0 + }, + "currentSubscriptionRole": "landing-zone|connectivity|identity|management|unclassified", + "topology": "hub-spoke|flat|unknown", + "policyGates": { + "denyEffectCount": 0, + "allowedLocations": [], + "requiredTags": [] + }, + "sharedServices": { + "logAnalyticsId": "{id or null}", + "containerRegistryId": "{id or null}", + "keyVaultId": "{id or null}", + "hubVnetIds": [] + } + }, "resources": [ { "type": "Microsoft.Web/sites", diff --git a/.github/agents/azure-template-generator.agent.md b/.github/agents/azure-template-generator.agent.md index 0661368..e95baa0 100644 --- a/.github/agents/azure-template-generator.agent.md +++ b/.github/agents/azure-template-generator.agent.md @@ -267,6 +267,61 @@ For **ALL resources**: All other per-resource hardening (TLS versions, blob soft delete, threat detection, health probes, auto-scaling, etc.) is owned by the security analyzer in Step 3 and the policy advisor in Step 4 — they will flag anything missing with severity tags, and Critical / High findings are auto-applied or BLOCK the security gate. +### 2.5. Apply Landing Zone Context (When Available) + +Before invoking the security/policy/preflight skills, check whether the workspace has discovered landing zone context at `.azure/landing-zone-context.json` (produced by `/azure-landing-zone-discovery`). When present, the template MUST respect the discovered tenant configuration. + +**Load the context once:** + +```bash +LZ_CONTEXT_FILE=".azure/landing-zone-context.json" +if [[ -f "$LZ_CONTEXT_FILE" ]]; then + LZ_CONFIDENCE=$(jq -r '.landingZoneDetection.confidence // "unknown"' "$LZ_CONTEXT_FILE") + LZ_ALLOWED_LOCATIONS=$(jq -r '.policies.allowedLocations[]? // empty' "$LZ_CONTEXT_FILE") + LZ_REQUIRED_TAGS=$(jq -r '.policies.requiredTags[]? // empty' "$LZ_CONTEXT_FILE") + LZ_LAW_ID=$(jq -r '.sharedServices.logAnalytics.id // empty' "$LZ_CONTEXT_FILE") + LZ_ACR_ID=$(jq -r '.sharedServices.containerRegistry.id // empty' "$LZ_CONTEXT_FILE") + LZ_HUB_VNET_ID=$(jq -r '.networking.hubs[0].id // empty' "$LZ_CONTEXT_FILE") + LZ_TOPOLOGY=$(jq -r '.networking.topology // "unknown"' "$LZ_CONTEXT_FILE") +fi +``` + +**How to act on each field (gated by `landingZoneDetection.confidence`):** + +| Field | Action when `confidence` â‰Ĩ `medium` | +|-------|--------------------------------------| +| `policies.allowedLocations[]` | **Reject** the template if the target region is not in the list. Surface the allowed list to the user and ask them to pick one. | +| `policies.requiredTags[]` | Inject each required tag as a parameter in the template; if the user didn't provide a value, ask before generating. Apply to all resources via `tags` block. | +| `policies.denyEffects[]` | Cross-check template properties against the deny rules. If the template would be denied (e.g., public IP when `Deny-PublicIP` is enforced), flag it as a security-gate blocker before invoking the security analyzer. | +| `policies.alzCanonicalAssignments[]` | Document the matched canonical ALZ policies in the deployment plan so the user understands the tenant baseline. | +| `sharedServices.logAnalytics.id` | Wire `diagnosticSettings` for every resource that supports it to this workspace instead of creating a new one. | +| `sharedServices.containerRegistry.id` | If deploying Container Apps / AKS, reference this ACR (with pull RBAC on the workload identity). Skip creating a new ACR unless the user explicitly asks. | +| `networking.hubs[0].id` (when `topology` = `hub-spoke`) | Generate VNet peering from the workload spoke to the hub. Use the hub's resource group / subscription from the discovered ID. | +| `networking.privateDnsZones[]` | When generating private endpoints, link them to the discovered private DNS zones for end-to-end name resolution. | + +**Confidence handling:** + +- `high` (â‰Ĩ70) — Apply all the above automatically. Note each LZ-driven decision in the deployment plan. +- `medium` (40–69) — Surface the proposed LZ-driven choices to the user and ask to confirm before applying. +- `low` (10–39) / `none` (<10) — Use **only** the policy fields (`allowedLocations`, `requiredTags`, `denyEffects`) when they are explicitly populated. Do **not** auto-wire shared services or hub peering — the topology may be misclassified. +- Context missing — Skip this entire step; proceed with the user-provided values. + +**Surface in the deployment plan:** + +Add a "Landing Zone Compliance" subsection between "Security Configuration" and "Security Best Practices Analysis": + +```markdown +### Landing Zone Compliance + +- **Confidence:** {high|medium|low|none} ({score}/100) +- **Target subscription role:** {landing-zone|sandbox|standalone} +- **Region check:** ✓ {region} is in `allowedLocations` +- **Required tags applied:** Environment, Project, CostCenter +- **Diagnostics:** routed to `{logAnalyticsId}` (shared) +- **Hub peering:** {generated to hub-vnet-id | skipped — topology=flat} +- **Policy gate check:** ✓ no template properties conflict with tenant `denyEffects` +``` + ### 3. Analyze Security Best Practices (Per Resource) **Invoke skill:** `/azure-security-analyzer` diff --git a/.github/agents/git-ape-onboarding.agent.md b/.github/agents/git-ape-onboarding.agent.md index 709b98f..e2d725b 100644 --- a/.github/agents/git-ape-onboarding.agent.md +++ b/.github/agents/git-ape-onboarding.agent.md @@ -73,7 +73,8 @@ Treat this as a **non-negotiable contract** for the gated first reply: regardles Both scripts produce byte-identical output. Report which files were created vs skipped. 9. Ask compliance framework and enforcement mode preferences (Step 10 in `/git-ape-onboarding` skill playbook). 10. Update the `## Compliance & Azure Policy` section in `.github/copilot-instructions.md` with the user's choices. If the file was skipped by the scaffold step or lacks that section, surface the captured preferences in chat for manual integration instead of mutating the file. -11. Summarize created/updated artifacts and next checks. +11. **Run landing zone discovery** against each onboarded subscription using `/azure-landing-zone-discovery`. This populates `.azure/landing-zone-context.json` with the tenant's management group hierarchy, platform subscriptions, hub-spoke networking, and policy gates so the requirements gatherer, template generator, and policy advisor can be landing-zone-aware on the very first deployment. If discovery reports `confidence` = `low`/`none`, tell the user the workspace will deploy in standalone mode and document how to fall back to manual injection. +12. Summarize created/updated artifacts and next checks. ## Output Requirements diff --git a/.github/agents/git-ape.agent.md b/.github/agents/git-ape.agent.md index f4b3077..b8fbd04 100644 --- a/.github/agents/git-ape.agent.md +++ b/.github/agents/git-ape.agent.md @@ -119,6 +119,7 @@ Coordinate the deployment of Azure resources by delegating to specialized subage **Skills (invoked during workflow):** - `/azure-rest-api-reference` — ARM template property schemas, required fields, valid values, and latest stable API versions. **Must be invoked before generating or modifying any ARM template resource.** - `/azure-naming-research` — CAF abbreviation lookup and naming validation +- `/azure-landing-zone-discovery` — Discover Azure Landing Zone topology (management groups, platform subscriptions, hub-spoke networking, policy gates) and emit `.azure/landing-zone-context.json` with a confidence score (high/medium/low/none). Consumed by requirements gatherer, template generator, and policy advisor. - `/azure-security-analyzer` — Per-resource security best practices assessment - `/azure-policy-advisor` - assess the template against Azure Policy compliance - `/azure-deployment-preflight` — What-if analysis and preflight validation @@ -128,6 +129,38 @@ Coordinate the deployment of Azure resources by delegating to specialized subage - `/azure-role-selector` — Least-privilege RBAC role recommendations - `/azure-cost-estimator` — Real-time cost estimation via Azure Retail Prices API +## Stage 0: Landing Zone Context (Pre-Flight) + +**Before starting any deployment**, check whether the workspace has a discovered landing zone topology at `.azure/landing-zone-context.json`. This file is produced by the `/azure-landing-zone-discovery` skill and lets the requirements gatherer, template generator, and policy advisor route workloads correctly, respect tenant policy gates, and reuse shared services. + +**Flow:** + +```bash +LZ_CONTEXT_FILE=".azure/landing-zone-context.json" +if [[ -f "$LZ_CONTEXT_FILE" ]]; then + LZ_CONFIDENCE=$(jq -r '.landingZoneDetection.confidence // "unknown"' "$LZ_CONTEXT_FILE") + LZ_SCORE=$(jq -r '.landingZoneDetection.confidenceScore // 0' "$LZ_CONTEXT_FILE") + LZ_AGE_DAYS=$(( ($(date +%s) - $(date -r "$LZ_CONTEXT_FILE" +%s)) / 86400 )) + echo "✓ Landing zone context: confidence=$LZ_CONFIDENCE (score $LZ_SCORE/100), age $LZ_AGE_DAYS days" + [[ $LZ_AGE_DAYS -gt 7 ]] && echo "âš ī¸ Context is stale — consider re-running /azure-landing-zone-discovery" +else + echo "â„šī¸ No landing zone context — workspace will deploy in standalone mode." + echo " Run /azure-landing-zone-discovery to enable LZ-aware deployments." +fi +``` + +**How to act on the result:** + +| Situation | Action | +|-----------|--------| +| Context missing | Proceed standalone. Show one-line hint suggesting `/azure-landing-zone-discovery` for ALZ-managed tenants. Do **not** force discovery — many users deploy into solo subscriptions. | +| Context present, `confidence` = `high` | Trust auto-classification. Pass the path to every subagent (requirements gatherer, template generator, policy advisor). | +| Context present, `confidence` = `medium` | Pass it through, but tell subagents to confirm matched/missing signals with the user before applying ALZ-specific behavior (hub peering, shared diagnostics). | +| Context present, `confidence` = `low`/`none` | Pass it through for the policy/region data only. Subagents must treat tenant as standalone unless the user explicitly opts in. | +| Context older than 7 days | Surface the staleness warning; offer to re-run discovery. | + +**Propagation:** Every downstream subagent receives the path `.azure/landing-zone-context.json`. They are responsible for parsing the parts they need (subscriptions, policies, shared services, hubs) and respecting the confidence bucket. + ## Pre-Deployment Drift Check (Optional) **Before starting new deployments**, check if there are existing deployments with configuration drift. @@ -172,10 +205,14 @@ Coordinate the deployment of Azure resources by delegating to specialized subage ## Workflow Stages +> **Pre-flight:** Always run the **Stage 0: Landing Zone Context** check above before Stage 1. If the context exists, every downstream subagent must read it. + ### Stage 1: Requirements Gathering **Delegate to:** `azure-requirements-gatherer` -The gatherer will interview the user to collect: +The gatherer will: +- **Read `.azure/landing-zone-context.json`** if present (Stage 0). Use the LZ context to auto-route the deployment to the right subscription, surface tenant policy gates, and pre-fill shared service references. Respect the `landingZoneDetection.confidence` bucket: `high` = trust; `medium` = confirm with user; `low`/`none` = treat as standalone. +- Interview the user to collect: - Resource type (Function App, Storage Account, SQL Database, etc.) - SKU/tier and sizing requirements - Region and resource group details diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index a6fe66a..a6b6374 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -353,6 +353,19 @@ Always assess and recommend policies for: identity, networking, storage, compute - Policy gate is **advisory** (not blocking) — surfaces findings without halting deployment - During onboarding, ask the user about compliance framework and enforcement mode preferences and update this section accordingly +## Landing Zone Context + +If `.azure/landing-zone-context.json` is present in the workspace (produced by `/azure-landing-zone-discovery`), Git-Ape agents MUST read it before generating templates and respect its constraints: + +- **`policies.allowedLocations[]`** — Reject any region not in this list (or, when empty/missing, fall back to the default-region rules above). +- **`policies.requiredTags[]`** — Inject these tag keys into every resource's `tags` block; surface missing values to the user. +- **`policies.denyEffects[]`** / **`policies.alzCanonicalAssignments[]`** — Cross-check the template against these before the security gate. Findings already enforced at the tenant level are marked `✓ inherited` instead of re-recommended. +- **`sharedServices.logAnalytics`**, **`sharedServices.acr`**, **`sharedServices.keyVault`** — Prefer these over creating new platform resources; wire diagnostic settings to the shared Log Analytics workspace. +- **`networking.topology` = `hub-spoke`** — Generate hub-VNet peering and link private endpoints to `networking.privateDnsZones[]`. +- **`landingZoneDetection.confidence`** — `high` = auto-apply; `medium` = confirm with user before applying ALZ-specific behavior; `low`/`none` = use explicit policy fields only and treat the tenant as standalone; missing = skip LZ-aware logic. + +If the context file is missing, deploy in standalone mode. Do **not** force `/azure-landing-zone-discovery` — many users deploy into solo subscriptions. + ### Rules 1. **Cite evidence**: Every "✅ Applied" finding must reference the exact ARM property path and value from the template. No exceptions. diff --git a/.github/evals/azure-landing-zone-discovery/eval.yaml b/.github/evals/azure-landing-zone-discovery/eval.yaml new file mode 100644 index 0000000..9e577c4 --- /dev/null +++ b/.github/evals/azure-landing-zone-discovery/eval.yaml @@ -0,0 +1,43 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/waza/main/schemas/eval.schema.json + +# Expanded-tier evaluation suite for the azure-landing-zone-discovery skill. +# Validates trigger precision via the heuristic `trigger` grader plus +# per-task answer_quality on positives. +# +# Run: waza run .github/evals/azure-landing-zone-discovery/eval.yaml + +name: azure-landing-zone-discovery-eval +description: Trigger precision suite for azure-landing-zone-discovery. +skill: azure-landing-zone-discovery +version: "0.2" + +config: + # Expanded tier: 1 trial per task (cheap baseline). Promote to pilot + # (trials_per_task: 3) once the skill stabilises. + trials_per_task: 1 + timeout_seconds: 120 + parallel: false + executor: copilot-sdk + model: claude-sonnet-4.6 + +metrics: + - name: trigger_precision + weight: 1.0 + threshold: 0.6 + description: Skill should activate on landing-zone / ALZ prompts and stay quiet on unrelated Azure asks. + +graders: + # Budget grader: discovery wraps az + Resource Graph calls; flag runs + # that explode in tool calls or blow past reasonable wall time. + - type: behavior + name: budget + config: + max_tool_calls: 30 + max_duration_ms: 240000 + + # answer_quality (LLM-as-judge) is scoped per-task on positive tasks only. + # Keeps judge-model errors from zeroing out the negative-task trigger check + # in the same leg. + +tasks: + - "tasks/*.yaml" diff --git a/.github/evals/azure-landing-zone-discovery/tasks/negative-function-deploy.yaml b/.github/evals/azure-landing-zone-discovery/tasks/negative-function-deploy.yaml new file mode 100644 index 0000000..50ead05 --- /dev/null +++ b/.github/evals/azure-landing-zone-discovery/tasks/negative-function-deploy.yaml @@ -0,0 +1,15 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/waza/main/schemas/task.schema.json + +id: negative-function-deploy +name: Negative — Plain function-app deployment (off-topic) +description: A solo-subscription Function App deployment does not require landing-zone discovery. +tags: [trigger, negative] +inputs: + prompt: "Deploy a Python 3.12 Azure Function App on the Consumption plan in eastus, with an associated Storage Account." +graders: + - name: trigger_relevance_negative + type: trigger + config: + skill_path: .github/skills/azure-landing-zone-discovery/SKILL.md + mode: negative + threshold: 0.5 diff --git a/.github/evals/azure-landing-zone-discovery/tasks/negative-naming-research.yaml b/.github/evals/azure-landing-zone-discovery/tasks/negative-naming-research.yaml new file mode 100644 index 0000000..cf29b9a --- /dev/null +++ b/.github/evals/azure-landing-zone-discovery/tasks/negative-naming-research.yaml @@ -0,0 +1,15 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/waza/main/schemas/task.schema.json + +id: negative-naming-research +name: Negative — CAF naming lookup (off-topic) +description: A single-resource CAF abbreviation lookup belongs to `/azure-naming-research`, not landing-zone discovery. +tags: [trigger, negative] +inputs: + prompt: "What's the CAF abbreviation for an Azure Storage Account and what are the naming rules?" +graders: + - name: trigger_relevance_negative + type: trigger + config: + skill_path: .github/skills/azure-landing-zone-discovery/SKILL.md + mode: negative + threshold: 0.5 diff --git a/.github/evals/azure-landing-zone-discovery/tasks/positive-discover-landing-zone.yaml b/.github/evals/azure-landing-zone-discovery/tasks/positive-discover-landing-zone.yaml new file mode 100644 index 0000000..cc0e24b --- /dev/null +++ b/.github/evals/azure-landing-zone-discovery/tasks/positive-discover-landing-zone.yaml @@ -0,0 +1,49 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/waza/main/schemas/task.schema.json + +id: positive-discover-landing-zone +name: Positive — Discover the landing zone +description: Direct ask to discover the enterprise Azure landing zone topology. +tags: [trigger, positive] +inputs: + prompt: "Before I deploy anything to Azure, can you discover the landing zone topology for my current subscription? I want to know the management group hierarchy, which subscriptions are platform vs. application landing zones, and whether there's a hub VNet I should peer to." +graders: + - name: trigger_relevance_positive + type: trigger + config: + skill_path: .github/skills/azure-landing-zone-discovery/SKILL.md + mode: positive + threshold: 0.5 + + # answer_quality (LLM-as-judge): scoped per-task on positives so a flaky + # judge call only zeroes out this task, not the whole leg. See eval.yaml. + # + # IMPORTANT: waza prompt graders are binary (set_waza_grade_pass = 1.0, + # set_waza_grade_fail = 0.0). They are NOT 1–5 rubrics. The judge has NO + # access to the agent's response unless continue_session: true is set — it + # resumes the agent's own session so it can read the response. + - type: prompt + name: answer_quality + config: + continue_session: true + prompt: | + You are grading the assistant's previous response in this session. + The user asked to discover their Azure landing zone topology + (management groups, platform vs. application subscriptions, hub VNet). + + PASS criteria — the response must contain ALL of: + 1. References the `azure-landing-zone-discovery` skill or its + `discover-lz.sh` script (or equivalent: `az account management-group` + plus Resource Graph queries for hub VNets). + 2. Mentions writing or producing `.azure/landing-zone-context.json` + as the output artifact. + 3. Mentions at least TWO of: management group hierarchy, + subscription classification (platform / landing-zone), + hub-spoke networking detection, policy/deny-effect detection, + shared services (Log Analytics / ACR / Key Vault). + 4. Acknowledges that discovery may have limited results when the + user lacks management-group read permissions OR mentions the + `inject-lz.sh` manual fallback for air-gapped / cross-tenant + scenarios. + + If ALL four PASS criteria are met, call `set_waza_grade_pass`. + Otherwise, call `set_waza_grade_fail` and list which criteria are missing. diff --git a/.github/evals/azure-landing-zone-discovery/tasks/positive-manual-inject.yaml b/.github/evals/azure-landing-zone-discovery/tasks/positive-manual-inject.yaml new file mode 100644 index 0000000..726e66c --- /dev/null +++ b/.github/evals/azure-landing-zone-discovery/tasks/positive-manual-inject.yaml @@ -0,0 +1,39 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/waza/main/schemas/task.schema.json + +id: positive-manual-inject +name: Positive — Manual landing-zone context injection +description: User in a cross-tenant / air-gapped scenario knows the hub VNet, allowed regions, and shared services but cannot run discovery. +tags: [trigger, positive] +inputs: + prompt: "I can't query management groups from this workspace (cross-tenant). Can you populate landing-zone-context.json manually? Our hub VNet is /subscriptions/aaaa.../resourceGroups/rg-connectivity/providers/Microsoft.Network/virtualNetworks/vnet-hub-eastus, allowed regions are eastus and westus2, and we require tags `costcenter` and `owner` on every resource." +graders: + - name: trigger_relevance_positive + type: trigger + config: + skill_path: .github/skills/azure-landing-zone-discovery/SKILL.md + mode: positive + threshold: 0.5 + + - type: prompt + name: answer_quality + config: + continue_session: true + prompt: | + You are grading the assistant's previous response in this session. + The user asked to manually inject landing-zone context (hub VNet, + allowed locations, required tags) because they cannot run discovery. + + PASS criteria — the response must contain ALL of: + 1. References the `inject-lz.sh` script (or the manual-injection + procedure in the `azure-landing-zone-discovery` skill). + 2. Maps the user's inputs to the correct flags / fields: + hub VNet ID → `--hub-vnet-id` (or `networking.hubVnetId`), + allowed regions → `--allowed-locations` (or `policies.allowedLocations`), + required tags → `--required-tags` (or `policies.requiredTags`). + 3. Mentions writing `.azure/landing-zone-context.json` as output. + 4. Does NOT silently try to run discovery (e.g., `az account + management-group list`) instead of the manual injection path — + the user explicitly said they can't. + + If ALL four PASS criteria are met, call `set_waza_grade_pass`. + Otherwise, call `set_waza_grade_fail` and list which criteria are missing. diff --git a/.github/evals/manifest.yaml b/.github/evals/manifest.yaml index 86f5b36..cb9b05b 100644 --- a/.github/evals/manifest.yaml +++ b/.github/evals/manifest.yaml @@ -34,6 +34,8 @@ skills: tier: expanded - name: azure-stack-destroy tier: expanded + - name: azure-landing-zone-discovery + tier: expanded # Per-tier model fan-out. The matrix runs each selected skill against every # model in its tier. To compare additional models, add them here. # diff --git a/.github/skills/azure-landing-zone-discovery/SKILL.md b/.github/skills/azure-landing-zone-discovery/SKILL.md new file mode 100644 index 0000000..004a9e7 --- /dev/null +++ b/.github/skills/azure-landing-zone-discovery/SKILL.md @@ -0,0 +1,570 @@ +--- +name: azure-landing-zone-discovery +description: "Auto-discover Azure landing zone topology including management groups, platform vs. application subscriptions, policy assignments, hub-spoke networking, and shared services. Use when deploying into enterprise environments, checking policy conflicts, or connecting to shared infrastructure." +argument-hint: "Discovery scope or manual injection mode (e.g. 'full discovery', 'inject context', 'check policies for eastus')" +user-invocable: true +last_updated: "2026-06-15" +--- + +# Azure Landing Zone Discovery + +## Overview + +Discover the enterprise Azure landing zone topology from the current Azure context, enabling Git-Ape to make landing zone-aware deployment decisions — routing workloads to the correct subscription, connecting to shared services, and avoiding policy conflicts. + +Enterprise Azure environments follow the [Cloud Adoption Framework landing zone architecture](https://learn.microsoft.com/azure/cloud-adoption-framework/ready/landing-zone/) with management groups, platform subscriptions, application landing zones, and hub-spoke networking. + +**Triggers:** + +- User asks: "discover landing zone", "show management groups", "what policies apply?" +- Before first deployment in a new subscription (auto-detect enterprise topology) +- User asks: "connect to hub VNet", "use shared Log Analytics", "which subscription for prod?" +- User provides manual landing zone context for air-gapped or cross-tenant environments + +**Output:** + +- `.azure/landing-zone-context.json` — Machine-readable landing zone topology +- Landing zone summary displayed to user with management group hierarchy visualization + +## Procedure + +### 1. Check for Existing Context + +Before running discovery, check if a landing zone context already exists: + +```bash +LZ_CONTEXT_FILE=".azure/landing-zone-context.json" + +if [[ -f "$LZ_CONTEXT_FILE" ]]; then + DISCOVERED_AT=$(jq -r '.discoveredAt' "$LZ_CONTEXT_FILE") + echo "Existing landing zone context found (discovered: $DISCOVERED_AT)" + echo "" + echo "Options:" + echo " A. Use existing context" + echo " B. Re-run discovery (refresh)" + echo " C. Manually update context" +fi +``` + +### 2. Run Full Discovery + +Run the discovery script to auto-detect the landing zone topology. The skill ships parity implementations for both shells — use the bash script on Linux/macOS and the PowerShell script on Windows (both produce an identical `landing-zone-context.json`): + +```bash +# Bash (Linux/macOS, git-bash on Windows) +.github/skills/azure-landing-zone-discovery/scripts/discover-lz.sh \ + --output-format json \ + --output-file .azure/landing-zone-context.json +``` + +```powershell +# PowerShell (Windows, or pwsh on any platform) +.github/skills/azure-landing-zone-discovery/scripts/discover-lz.ps1 ` + -OutputFormat json ` + -OutputFile .azure/landing-zone-context.json +``` + +**Discovery targets:** + +| Target | Azure CLI Command | Fallback | +|--------|------------------|----------| +| Management group hierarchy | `az account management-group list` | Subscription-only mode | +| Subscription classification | Tags, naming convention, management group placement | Manual classification | +| Policy assignments | `az policy assignment list --scope ` | Skip policy check | +| Network topology | `az network vnet list`, peerings, DNS zones | Manual VNet ID input | +| Shared services | Resource Graph query for Log Analytics, ACR, Key Vault | Manual resource IDs | +| RBAC | `az role assignment list` | Note limited permissions | + +**The script handles these scenarios gracefully:** + +- **No management groups (flat subscription):** Skips hierarchy discovery, uses subscription-level context only +- **Limited RBAC (no management group read):** Falls back to subscription-level discovery, prompts for manual hierarchy input +- **Cross-tenant landing zone:** Manual injection required — discovery limited to current tenant +- **No network resources:** Skips networking discovery, notes that hub connectivity is not configured + +### 3. Management Group Hierarchy Discovery + +```bash +# Discover management group tree +MG_LIST=$(az account management-group list --output json 2>/dev/null) + +if [[ $? -ne 0 ]] || [[ -z "$MG_LIST" ]] || [[ "$MG_LIST" == "[]" ]]; then + echo "âš ī¸ Cannot read management groups (insufficient RBAC or flat subscription)" + echo "Falling back to subscription-level discovery" + # Continue with subscription-only mode +fi +``` + +**Classification heuristics:** + +- Management groups named `*platform*`, `*connectivity*`, `*identity*`, `*management*` → Platform +- Management groups named `*landing*zone*`, `*workload*`, `*application*`, `*corp*`, `*online*` → Landing Zones +- Management groups named `*sandbox*`, `*decommission*` → Non-production +- Tags `mg-type`, `lz-type` override naming heuristics + +### 4. Subscription Classification + +Classify subscriptions as platform or application landing zones: + +```bash +# List all accessible subscriptions +SUBSCRIPTIONS=$(az account list --query "[?state=='Enabled']" --output json) + +# For each subscription, determine its role +for SUB in $(echo "$SUBSCRIPTIONS" | jq -r '.[].id'); do + SUB_NAME=$(echo "$SUBSCRIPTIONS" | jq -r --arg id "$SUB" '.[] | select(.id == $id) | .name') + + # Check management group placement + MG_PATH=$(az account management-group subscription show \ + --subscription-id "$SUB" \ + --query "managementGroupAncestorsChain[].displayName" \ + --output tsv 2>/dev/null || echo "unknown") + + # Classify by naming convention and MG placement + case "$SUB_NAME" in + *connectivity*|*network*|*hub*) + ROLE="connectivity" ;; + *identity*|*aad*) + ROLE="identity" ;; + *management*|*logging*|*monitor*) + ROLE="management" ;; + *sandbox*|*dev*|*test*) + ROLE="landing-zone-dev" ;; + *staging*|*uat*|*qa*) + ROLE="landing-zone-staging" ;; + *prod*|*production*) + ROLE="landing-zone-prod" ;; + *) + ROLE="landing-zone" ;; + esac +done +``` + +### 5. Policy Conflict Detection + +Discover policy assignments that may affect deployments: + +```bash +# Get policy assignments at management group and subscription level +POLICY_ASSIGNMENTS=$(az policy assignment list \ + --scope "/subscriptions/$SUBSCRIPTION_ID" \ + --query "[?enforcementMode=='Default']" \ + --output json) + +# Identify high-risk policies (Deny effect) +DENY_POLICIES=$(echo "$POLICY_ASSIGNMENTS" | jq '[ + .[] | select(.parameters != null) | + { + name: .displayName, + scope: .scope, + effect: ( + .parameters.effect.value // + .parameters.Effect.value // + "unknown" + ), + policyDefinitionId: .policyDefinitionId + } | select(.effect == "Deny" or .effect == "deny") +]') + +# Check for common deployment-blocking policies +# - Deny-Public-IP +# - Allowed-Locations +# - Deny-Storage-Public-Access +# - Require-Tag +``` + +**Policy conflict categories:** + +| Policy | Impact | Deployment Concern | +|--------|--------|--------------------| +| Deny-Public-IP | Blocks public IP creation | Use private endpoints or internal load balancers | +| Allowed-Locations | Restricts regions | Template must use allowed regions only | +| Deny-Storage-Public-Access | Blocks public storage | Require private endpoints for storage | +| Require-Tag | Blocks untagged resources | Ensure all resources have required tags | +| Deny-Subnet-Without-NSG | Blocks subnets without NSGs | Include NSG in template | + +### 6. Network Topology Discovery + +Discover hub-spoke networking configuration: + +```bash +# Find hub VNets (typically in connectivity subscription) +HUB_VNETS=$(az graph query -q " + Resources + | where type == 'microsoft.network/virtualnetworks' + | where name contains 'hub' or tags['network-role'] == 'hub' + | project id, name, location, subscriptionId, + addressPrefixes=properties.addressSpace.addressPrefixes +" --output json 2>/dev/null) + +# Find VNet peerings +PEERINGS=$(az graph query -q " + Resources + | where type == 'microsoft.network/virtualnetworks' + | mv-expand peering=properties.virtualNetworkPeerings + | project vnetName=name, peerName=peering.name, + remoteVnet=peering.properties.remoteVirtualNetwork.id, + peeringState=peering.properties.peeringState +" --output json 2>/dev/null) + +# Find private DNS zones +DNS_ZONES=$(az graph query -q " + Resources + | where type == 'microsoft.network/privatednszones' + | project id, name, subscriptionId +" --output json 2>/dev/null) +``` + +### 7. Shared Services Discovery + +Discover shared infrastructure for workload integration: + +```bash +# Find shared Log Analytics workspaces +LOG_ANALYTICS=$(az graph query -q " + Resources + | where type == 'microsoft.operationalinsights/workspaces' + | where tags['shared'] == 'true' or name contains 'platform' or name contains 'central' + | project id, name, subscriptionId, location, + sku=properties.sku.name, retentionDays=properties.retentionInDays +" --output json 2>/dev/null) + +# Find shared Container Registries +ACR=$(az graph query -q " + Resources + | where type == 'microsoft.containerregistry/registries' + | where tags['shared'] == 'true' or sku.name == 'Premium' + | project id, name, subscriptionId, location, sku=sku.name, + loginServer=properties.loginServer +" --output json 2>/dev/null) + +# Find shared Key Vaults +KEY_VAULTS=$(az graph query -q " + Resources + | where type == 'microsoft.keyvault/vaults' + | where tags['shared'] == 'true' or name contains 'platform' + | project id, name, subscriptionId, location +" --output json 2>/dev/null) +``` + +### 8. Generate Landing Zone Context File + +Assemble all discovery results into the context file: + +```bash +# The discover-lz.sh script outputs the full context +# See .azure/landing-zone-context.json for the output format + +cat .azure/landing-zone-context.json | jq '.' +``` + +**Output format (`landing-zone-context.json`):** + +Notable field semantics: + +- `landingZoneDetection` rates how confidently the discovered topology matches the canonical [Azure Landing Zone accelerator](https://azure.github.io/Azure-Landing-Zones/accelerator/) reference. Treat `confidence` as the primary signal — see the confidence model below. +- `networking.topology` is one of `"hub-spoke"` (hub VNet discovered), `"flat"` (discovery ran, no hub found), or `"unknown"` (discovery skipped or failed). See the Edge Cases table below. +- `policies.denyEffects[]` contains only assignments whose `effect` parameter resolves to `Deny`. `DeployIfNotExists`, `Modify`, and initiatives are excluded — see the Policy effect classification table below. +- `policies.alzCanonicalAssignments[]` lists policy assignments whose name matches a known ALZ accelerator policy (e.g. `Deploy-MDFC-Config`, `Deny-PublicIP`). High-precision ALZ signature regardless of `effect`. + +```json +{ + "discoveredAt": "2026-04-30T10:00:00Z", + "discoveryMethod": "auto", + "landingZoneDetection": { + "isLandingZone": true, + "confidence": "high", + "confidenceScore": 85, + "reference": "https://azure.github.io/Azure-Landing-Zones/accelerator/", + "matchedSignals": [ + { "signal": "alz-top-level-mgs", "points": 30, "evidence": "4/4 canonical top-level MGs (Platform, Landing zones, Sandbox, Decommissioned)" }, + { "signal": "platform-children", "points": 20, "evidence": "3/3 platform children (Connectivity, Identity, Management)" }, + { "signal": "alz-lz-archetypes", "points": 10, "evidence": "Corp and Online MGs present under Landing zones" }, + { "signal": "platform-subscriptions", "points": 10, "evidence": "3 platform subscription(s) classified" }, + { "signal": "hub-spoke-topology", "points": 5, "evidence": "Hub VNet(s): vnet-hub-eastus" }, + { "signal": "hub-in-connectivity-sub", "points": 5, "evidence": "Hub VNet sits in a connectivity-classified subscription" }, + { "signal": "alz-canonical-policies", "points": 5, "evidence": "1 canonical ALZ policy assignment(s): Deploy-MDFC-Config" } + ], + "missingSignals": [], + "checks": { + "topLevelMgs": { "platform": true, "landingZones": true, "sandbox": true, "decommissioned": true }, + "platformChildren": { "connectivity": true, "identity": true, "management": true }, + "lzChildren": { "corp": true, "online": true }, + "platformSubscriptionCount": 3, + "hubSpoke": true, + "hubInConnectivitySubscription": true, + "knownAlzPolicies": ["Deploy-MDFC-Config"] + } + }, + "managementGroups": { + "root": "Tenant Root Group", + "hierarchy": [ + { + "id": "/providers/Microsoft.Management/managementGroups/mg-platform", + "displayName": "Platform", + "role": "platform", + "children": ["mg-connectivity", "mg-identity", "mg-management"] + }, + { + "id": "/providers/Microsoft.Management/managementGroups/mg-landing-zones", + "displayName": "Landing Zones", + "role": "landing-zones", + "children": ["mg-corp", "mg-online"] + } + ] + }, + "subscriptions": { + "platform": [ + { "id": "...", "name": "sub-connectivity-prod", "role": "connectivity", "mgPath": "mg-platform/mg-connectivity" }, + { "id": "...", "name": "sub-identity-prod", "role": "identity", "mgPath": "mg-platform/mg-identity" }, + { "id": "...", "name": "sub-management-prod", "role": "management", "mgPath": "mg-platform/mg-management" } + ], + "landingZones": [ + { "id": "...", "name": "sub-app-dev", "environment": "dev", "mgPath": "mg-landing-zones/mg-corp" }, + { "id": "...", "name": "sub-app-prod", "environment": "prod", "mgPath": "mg-landing-zones/mg-corp" } + ] + }, + "sharedServices": { + "logAnalytics": { "id": "...", "name": "log-platform-prod-eastus", "subscription": "sub-management-prod", "location": "eastus" }, + "containerRegistry": { "id": "...", "name": "crplatformprod", "subscription": "sub-management-prod", "location": "eastus" }, + "keyVault": { "id": "...", "name": "kv-platform-prod-eus", "subscription": "sub-management-prod", "location": "eastus" } + }, + "networking": { + "topology": "hub-spoke", + "hubs": [ + { "id": "...", "name": "vnet-hub-eastus", "subscription": "sub-connectivity-prod", "location": "eastus", "addressPrefixes": ["10.0.0.0/16"] } + ], + "privateDnsZones": [ + "privatelink.blob.core.windows.net", + "privatelink.vaultcore.azure.net", + "privatelink.azurewebsites.net" + ], + "peerings": [] + }, + "policies": { + "denyEffects": [ + { "name": "Deny-Public-IP", "scope": "/providers/Microsoft.Management/managementGroups/mg-landing-zones", "impact": "Blocks public IP creation in landing zone subscriptions" } + ], + "auditEffects": [], + "allowedLocations": ["eastus", "westus2", "westeurope"], + "requiredTags": ["Environment", "Project", "CostCenter"], + "alzCanonicalAssignments": ["Deploy-MDFC-Config", "Deploy-Diag-LogsCat-LAW"] + }, + "currentIdentity": { + "user": "user@contoso.com", + "tenantId": "...", + "roles": [] + } +} +``` + +### 9. Landing Zone Visualization + +Generate a Mermaid diagram of the management group hierarchy: + +````markdown +## Landing Zone Topology + +```mermaid +graph TD + TRG["đŸĸ Tenant Root Group"] + TRG --> MG_PLATFORM["📋 Platform"] + TRG --> MG_LZ["📋 Landing Zones"] + TRG --> MG_SANDBOX["📋 Sandbox"] + TRG --> MG_DECOM["📋 Decommissioned"] + + MG_PLATFORM --> MG_CONN["🔌 Connectivity"] + MG_PLATFORM --> MG_IDENTITY["🔐 Identity"] + MG_PLATFORM --> MG_MGMT["📊 Management"] + + MG_CONN --> SUB_CONN["đŸ’ŗ sub-connectivity-prod"] + MG_IDENTITY --> SUB_ID["đŸ’ŗ sub-identity-prod"] + MG_MGMT --> SUB_MGMT["đŸ’ŗ sub-management-prod"] + + MG_LZ --> MG_CORP["đŸ—ī¸ Corp"] + MG_LZ --> MG_ONLINE["🌐 Online"] + + MG_CORP --> SUB_DEV["đŸ’ŗ sub-app-dev"] + MG_CORP --> SUB_PROD["đŸ’ŗ sub-app-prod"] + + SUB_CONN -.->|"hub VNet"| VNET_HUB["🔗 vnet-hub-eastus"] + SUB_MGMT -.->|"shared"| LOG["📊 log-platform-prod-eastus"] + SUB_MGMT -.->|"shared"| ACR["đŸ“Ļ crplatformprod"] + + style SUB_DEV fill:#e1f5fe + style SUB_PROD fill:#fff3e0 + style VNET_HUB fill:#e8f5e9 + style LOG fill:#f3e5f5 +``` +```` + +## Landing Zone Detection Confidence + +Discovery rates every tenant against the canonical [Azure Landing Zone (ALZ) accelerator](https://azure.github.io/Azure-Landing-Zones/accelerator/) reference and emits a `landingZoneDetection` block in the context file. The score (0–100) drives downstream tooling decisions: trust the auto-classified MGs/subscriptions vs. fall back to manual injection or user confirmation. + +### Weighted Signals + +| Signal | Max points | Source | +|---|---:|---| +| `alz-top-level-mgs` — `Platform`, `Landing zones`, `Sandbox`, `Decommissioned` | 30 | Management group hierarchy | +| `platform-children` — `Connectivity`, `Identity`, `Management` under Platform | 20 | Management group hierarchy | +| `alz-lz-archetypes` — both `Corp` and `Online` under Landing zones | 10 | Management group hierarchy | +| `platform-subscriptions` — subs classified as connectivity/identity/management/platform-other | 10 | Subscription role classification | +| `hub-spoke-topology` — at least one hub VNet discovered | 5 | Network topology | +| `hub-in-connectivity-sub` — hub VNet lives in a connectivity subscription | 5 | Network + subscription cross-check | +| `alz-canonical-policies` — assignments matching ALZ accelerator policy names (e.g. `Deploy-MDFC-Config`, `Deny-PublicIP`) | 15 (3 × 5) | Policy assignments | + +Top-level and platform-children signals scale: 4/4 top-level MGs = 30, 3/4 = 20, 2/4 = 10; 3/3 platform children = 20, 2/3 = 10; â‰Ĩ3 platform subs = 10, â‰Ĩ1 = 5; ALZ canonical policy points = `min(matches × 5, 15)`. + +### Confidence Buckets + +| `confidence` | Score range | `isLandingZone` | Suggested treatment | +|---|---:|---:|---| +| `high` | â‰Ĩ 70 | `true` | Trust auto-classification. Proceed with hub-attach + shared services without prompting. | +| `medium` | 40–69 | `true` | Surface matched + missing signals to the user. Ask to confirm before assuming the tenant is ALZ-managed. | +| `low` | 10–39 | `false` | Treat as standalone tenant. Mention partial signals so the user can decide whether to manually inject. | +| `none` | < 10 | `false` | No ALZ signature. Default to flat-tenant assumptions. Recommend manual injection only if the user knows the tenant *is* ALZ-managed. | + +Inspect the full breakdown: + +```bash +jq '.landingZoneDetection' .azure/landing-zone-context.json +``` + +`matchedSignals[]` records every signal that scored, including the points awarded and a human-readable evidence string. `missingSignals[]` lists the signals that scored zero — useful for telling the user *why* confidence is low. `checks` exposes the raw booleans/counts the scorer evaluated. + +## Manual Injection + +When discovery cannot reach the landing zone (cross-tenant, limited RBAC, air-gapped environments), users can inject context manually. + +### Option A: Provide the Context File Directly + +Create or edit `.azure/landing-zone-context.json` with your landing zone topology. Set `"discoveryMethod": "manual"`. + +### Option B: Use the Injection Script + +The injection script ships in both shells (bash and PowerShell parity ports). Both write an identical `landing-zone-context.json`: + +```bash +# Bash (Linux/macOS, git-bash on Windows) +.github/skills/azure-landing-zone-discovery/scripts/inject-lz.sh \ + --hub-vnet-id "/subscriptions/.../resourceGroups/.../providers/Microsoft.Network/virtualNetworks/vnet-hub" \ + --log-analytics-id "/subscriptions/.../resourceGroups/.../providers/Microsoft.OperationalInsights/workspaces/log-central" \ + --acr-id "/subscriptions/.../resourceGroups/.../providers/Microsoft.ContainerRegistry/registries/crshared" \ + --allowed-locations "eastus,westus2" \ + --required-tags "Environment,Project,CostCenter" +``` + +```powershell +# PowerShell (Windows, or pwsh on any platform) +.github/skills/azure-landing-zone-discovery/scripts/inject-lz.ps1 ` + -HubVnetId "/subscriptions/.../resourceGroups/.../providers/Microsoft.Network/virtualNetworks/vnet-hub" ` + -LogAnalyticsId "/subscriptions/.../resourceGroups/.../providers/Microsoft.OperationalInsights/workspaces/log-central" ` + -AcrId "/subscriptions/.../resourceGroups/.../providers/Microsoft.ContainerRegistry/registries/crshared" ` + -AllowedLocations "eastus,westus2" ` + -RequiredTags "Environment,Project,CostCenter" +``` + +### Option C: Interactive Questionnaire + +When invoked without arguments, the agent asks targeted questions: + +```markdown +I'll help you set up your landing zone context. Please answer what you can: + +1. **Management Groups:** What management group should workloads land in? + - Provide the management group name or ID, or type "none" for flat subscription + +2. **Hub Networking:** Do you have a hub VNet to peer with? + - Provide the hub VNet resource ID, or type "none" + +3. **Shared Log Analytics:** Which Log Analytics workspace should resources send diagnostics to? + - Provide the workspace resource ID, or type "none" + +4. **Shared Container Registry:** Do you have a shared ACR for container workloads? + - Provide the ACR resource ID, or type "none" + +5. **Azure Policies:** Are there Azure Policies at the management group level that restrict: + - Public IPs? (yes/no) + - Allowed regions? (list regions, or "any") + - Required tags? (list tag names, or "none") + - Public storage access? (yes/no) + +6. **Landing Zone Subscriptions:** List your subscriptions by environment: + - Dev: subscription ID or name + - Staging: subscription ID or name + - Prod: subscription ID or name +``` + +## Integration with Deployment Workflow + +### Stage 1: Requirements Gathering (Landing Zone-Aware) + +When landing zone context is available: + +- **Auto-select target subscription:** Route `dev` deployments to dev landing zone, `prod` to prod landing zone +- **Warn on platform subscriptions:** If user targets a platform subscription (connectivity/identity/management), warn that it's not for workloads +- **Show policy constraints:** Display Deny-effect policies that may affect the deployment before template generation + +### Stage 2: Template Generation (Landing Zone-Aware) + +When landing zone context is available: + +- **Auto-connect diagnostics:** Route `diagnosticSettings` to the shared Log Analytics workspace +- **Hub VNet peering:** Generate VNet peering to the hub VNet for workloads that need network connectivity +- **Private endpoints:** Use discovered private DNS zones for private endpoint DNS integration +- **Container Registry:** Reference shared ACR for container workloads instead of creating a new one +- **Policy-compliant defaults:** Use allowed locations, apply required tags, avoid public IPs if denied + +### Stage 2.5: Security Gate (Landing Zone-Aware) + +- **Policy validation:** Check if the deployment template would be denied by management group policies +- **Flag conflicts:** Warn if template uses resources/configurations blocked by landing zone policies +- **Suggest alternatives:** Recommend policy-compliant configurations (e.g., private endpoints instead of public IPs) + +## Edge Cases + +| Scenario | Handling | `networking.topology` | +|----------|----------|------------------------| +| Hub VNet found (named/tagged `hub`) | Record hubs and peerings | `hub-spoke` | +| Network discovery ran, no hub found | Treat as single-VNet / non-enterprise environment | `flat` | +| Network discovery skipped (`--skip-network`) or failed | Downstream must not assume any topology | `unknown` | +| No management groups (flat subscription) | Skip hierarchy discovery, use subscription-level context only | (independent of topology field) | +| Cross-tenant landing zone (CSP, MCA) | Manual injection required — discovery limited to current tenant | (set by `inject-lz.sh`) | +| Limited RBAC (no management group read) | Fall back to subscription-level discovery + manual injection for hierarchy | (network may still run) | +| Multiple landing zones for same environment | Present options, let user choose | (independent) | +| Landing zone not yet deployed | Guide user to ALZ accelerator or suggest manual setup | `flat` | +| `landingZoneDetection.confidence` is `low` or `none` | Treat tenant as standalone; do not auto-attach to hub/shared services. If user knows it *is* ALZ-managed, fall back to manual injection | (preserved from discovery) | +| `landingZoneDetection.confidence` is `medium` | Surface `matchedSignals` and `missingSignals` to the user; ask to confirm ALZ-managed behavior before relying on auto-classification | (preserved from discovery) | +| Stale context (old discovery) | Warn if context is older than 7 days, offer refresh | (preserved from prior run) | +| No Azure Resource Graph access | Fall back to individual `az` CLI queries (slower, current-subscription only) | `hub-spoke` or `flat` | + +### Policy effect classification + +`discover-lz.sh` reads `parameters.effect.value` (or `Effect.value`) from each assignment and partitions the result: + +| Effect parameter (case-insensitive) | Goes into | +|-------------------------------------|-----------| +| `Deny` | `policies.denyEffects[]` | +| `Audit`, `AuditIfNotExists` | `policies.auditEffects[]` | +| `DeployIfNotExists`, `Modify`, `Append`, `Disabled` | excluded from both arrays | +| Initiatives (`policySetDefinitions/*`) with no top-level effect param | excluded — effect varies by inner definition | +| Missing or unrecognized effect param | excluded | + +Downstream consumers (Stage 2.5 Security Gate, Stage 1 warnings) should treat `denyEffects` as deployment-blocking and `auditEffects` as informational. + +## Best Practices + +1. **Run discovery at onboarding time** — Include in the `/git-ape-onboarding` flow +2. **Refresh periodically** — Re-run discovery if the context is older than 7 days +3. **Commit context to repo** — `.azure/landing-zone-context.json` should be version-controlled for team consistency +4. **Use tags for classification** — Tag management groups and subscriptions with `lz-role`, `environment`, `shared=true` for reliable discovery +5. **Review policy conflicts early** — Check policies before template generation, not at deploy time + +## Related Skills + +- `/azure-policy-advisor` — Detailed policy compliance assessment for ARM templates +- `/azure-security-analyzer` — Security best practices analysis +- `/azure-resource-visualizer` — Live resource group visualization +- `/azure-drift-detector` — Configuration drift detection +- `/prereq-check` — Verify Azure CLI and authentication prerequisites diff --git a/.github/skills/azure-landing-zone-discovery/scripts/discover-lz.ps1 b/.github/skills/azure-landing-zone-discovery/scripts/discover-lz.ps1 new file mode 100644 index 0000000..33843a2 --- /dev/null +++ b/.github/skills/azure-landing-zone-discovery/scripts/discover-lz.ps1 @@ -0,0 +1,832 @@ +#!/usr/bin/env pwsh +# Azure Landing Zone Discovery Script (PowerShell) +# Auto-discovers management groups, subscriptions, policies, networking, and shared services +# +# PowerShell parity port of discover-lz.sh. Produces an identical +# landing-zone-context.json schema and the same exit codes: +# 0 Discovery completed successfully +# 1 Partial discovery (some targets failed, results still usable) +# 2 Discovery failed (no Azure access or critical error) + +param( + [ValidateSet("json", "markdown")] + [string]$OutputFormat = "json", + [string]$OutputFile = "", + [switch]$SkipNetwork, + [switch]$SkipPolicies, + [switch]$SkipSharedServices, + [switch]$Verbose, + [switch]$Help +) + +$ErrorActionPreference = 'Stop' + +function Show-Usage { + @" +Azure Landing Zone Discovery Script (PowerShell) + +Discovers management group hierarchy, subscription classification, policy +assignments, network topology, and shared services from the current Azure context. + +Usage: ./discover-lz.ps1 [OPTIONS] + +Options: + -OutputFormat Output format: json, markdown (default: json) + -OutputFile Output file path (default: stdout) + -SkipNetwork Skip network topology discovery + -SkipPolicies Skip policy assignment discovery + -SkipSharedServices Skip shared services discovery + -Verbose Show detailed discovery progress + -Help Show this help message + +Examples: + ./discover-lz.ps1 -OutputFile .azure/landing-zone-context.json + ./discover-lz.ps1 -OutputFormat markdown + ./discover-lz.ps1 -OutputFile .azure/landing-zone-context.json -SkipNetwork + ./discover-lz.ps1 -Verbose + +Exit Codes: + 0 Discovery completed successfully + 1 Partial discovery (some targets failed, results still usable) + 2 Discovery failed (no Azure access or critical error) +"@ | Write-Host + exit 1 +} + +if ($Help) { Show-Usage } + +# ───────────────────────────────────────────────────────────────────────────── +# Helpers +# ───────────────────────────────────────────────────────────────────────────── +function Invoke-AzJson { + # Run an az command and parse JSON output, returning $null on failure. + param([string[]]$AzArgs) + try { + $raw = & az @AzArgs -o json 2>$null + if ($LASTEXITCODE -ne 0 -or -not $raw) { return $null } + return ($raw | ConvertFrom-Json -ErrorAction Stop) + } + catch { + return $null + } +} + +function Get-Prop { + param($Object, [string]$Name, $Default = $null) + if ($null -ne $Object -and $Object.PSObject.Properties.Name -contains $Name) { + $val = $Object.$Name + if ($null -ne $val) { return $val } + } + return $Default +} + +function ConvertTo-Array { + param($Value) + if ($null -eq $Value) { return @() } + return @($Value) +} + +# ───────────────────────────────────────────────────────────────────────────── +# Validate Azure CLI is available and logged in +# ───────────────────────────────────────────────────────────────────────────── +if (-not (Get-Command az -ErrorAction SilentlyContinue)) { + Write-Host "Error: Azure CLI (az) is not installed" -ForegroundColor Red + exit 2 +} + +$null = az account show -o json 2>$null +if ($LASTEXITCODE -ne 0) { + Write-Host "Error: Not logged in to Azure. Run 'az login' first." -ForegroundColor Red + exit 2 +} + +$DiscoveryTimestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") + +Write-Host "Starting Azure Landing Zone Discovery" -ForegroundColor Blue +Write-Host "Timestamp: $DiscoveryTimestamp" +Write-Host "" + +$PartialFailure = $false + +# ───────────────────────────────────────────────────────────────────────────── +# [1/7] Current Identity +# ───────────────────────────────────────────────────────────────────────────── +Write-Host "[1/7] Discovering current identity..." -ForegroundColor Cyan + +$CurrentAccount = Invoke-AzJson @("account", "show") +$CurrentUser = Get-Prop (Get-Prop $CurrentAccount 'user') 'name' "unknown" +$CurrentTenantId = Get-Prop $CurrentAccount 'tenantId' "unknown" +$CurrentSubId = Get-Prop $CurrentAccount 'id' "unknown" +$CurrentSubName = Get-Prop $CurrentAccount 'name' "unknown" + +Write-Host " User: " -NoNewline; Write-Host "$CurrentUser" -ForegroundColor Green +Write-Host " Tenant: $CurrentTenantId" +Write-Host " Subscription: $CurrentSubName ($CurrentSubId)" +Write-Host "" + +$IdentityJson = [ordered]@{ + user = $CurrentUser + tenantId = $CurrentTenantId + currentSubscription = [ordered]@{ id = $CurrentSubId; name = $CurrentSubName } + roles = @() +} + +# ───────────────────────────────────────────────────────────────────────────── +# [2/7] Management Group Hierarchy +# ───────────────────────────────────────────────────────────────────────────── +Write-Host "[2/7] Discovering management group hierarchy..." -ForegroundColor Cyan + +$MgHierarchy = @() +$MgRoot = "" +$HasManagementGroups = $false + +$MgList = ConvertTo-Array (Invoke-AzJson @("account", "management-group", "list")) + +function Get-MgRole { + param($Mg, [string]$TenantId) + $displayName = Get-Prop $Mg 'displayName' + $name = Get-Prop $Mg 'name' + if ($displayName -eq "Tenant Root Group" -or $name -eq $TenantId) { return "root" } + + $n = ("" + ($(if ($displayName) { $displayName } else { $name }))).ToLower() + + switch -Regex ($n) { + '^platform$' { return "platform" } + '^connectivity$' { return "connectivity" } + '^identity$' { return "identity" } + '^management$' { return "management" } + '^(landing zones|landingzones|landing-zones)$' { return "landing-zones" } + '^corp$' { return "corp" } + '^online$' { return "online" } + '^sandbox$' { return "sandbox" } + '^decommissioned$' { return "decommissioned" } + } + # Substring fallback for custom-named environments (lower precision) + if ($n -match 'platform|infra') { return "platform" } + if ($n -match 'connectivity|network|hub') { return "connectivity" } + if ($n -match 'identity|aad|entra') { return "identity" } + if ($n -match 'management|logging|monitor') { return "management" } + if ($n -match 'landing.?zone|workload|application') { return "landing-zones" } + if ($n -match 'sandbox|dev.?test') { return "sandbox" } + if ($n -match 'decommission|deprecated|retired') { return "decommissioned" } + return "other" +} + +if ($MgList.Count -gt 0) { + $HasManagementGroups = $true + Write-Host " Found " -NoNewline; Write-Host "$($MgList.Count)" -ForegroundColor Green -NoNewline; Write-Host " management groups" + + # Find root management group (Tenant Root Group) + $rootMg = $MgList | Where-Object { + (Get-Prop $_ 'displayName') -eq "Tenant Root Group" -or + (Get-Prop $_ 'name') -eq (Get-Prop $_ 'tenantId') -or + ($null -eq (Get-Prop (Get-Prop (Get-Prop $_ 'properties') 'details') 'parent')) + } | Select-Object -First 1 + $MgRoot = if ($rootMg) { Get-Prop $rootMg 'displayName' "Tenant Root Group" } else { "Tenant Root Group" } + + $MgHierarchy = @($MgList | ForEach-Object { + $parent = Get-Prop (Get-Prop (Get-Prop $_ 'properties') 'details') 'parent' + [ordered]@{ + id = (Get-Prop $_ 'id') + name = (Get-Prop $_ 'name') + displayName = (Get-Prop $_ 'displayName') + role = (Get-MgRole $_ $CurrentTenantId) + parentId = (Get-Prop $parent 'id') + } + }) + + if ($Verbose) { + Write-Host " Management group classification:" + $MgHierarchy | ForEach-Object { Write-Host " $($_.displayName) → $($_.role)" } + } +} +else { + Write-Host " No management groups found (flat subscription model)" -ForegroundColor Yellow +} +Write-Host "" + +# ───────────────────────────────────────────────────────────────────────────── +# [3/7] Subscription Discovery & Classification +# ───────────────────────────────────────────────────────────────────────────── +Write-Host "[3/7] Discovering and classifying subscriptions..." -ForegroundColor Cyan + +$Subscriptions = ConvertTo-Array (Invoke-AzJson @("account", "list", "--query", "[?state=='Enabled']")) +Write-Host " Found " -NoNewline; Write-Host "$($Subscriptions.Count)" -ForegroundColor Green -NoNewline; Write-Host " enabled subscriptions" + +$PlatformSubs = [System.Collections.ArrayList]::new() +$LzSubs = [System.Collections.ArrayList]::new() + +foreach ($sub in $Subscriptions) { + $subId = Get-Prop $sub 'id' + $subName = Get-Prop $sub 'name' + + $mgPath = "" + if ($HasManagementGroups) { + $chain = & az account management-group subscription show ` + --subscription-id "$subId" ` + --query "managementGroupAncestorsChain[].displayName" ` + -o tsv 2>$null + if ($LASTEXITCODE -eq 0 -and $chain) { + $mgPath = (($chain -split "`n" | Where-Object { $_ }) -join '/') + } + } + + $subNameLower = ("" + $subName).ToLower() + $mgPathLower = ("" + $mgPath).ToLower() + $role = "landing-zone" + $environment = "" + + if ($mgPathLower) { + # Prefer MG-path classification (canonical ALZ placement) + if ($mgPathLower -match '/connectivity') { $role = "connectivity" } + elseif ($mgPathLower -match '/identity') { $role = "identity" } + elseif ($mgPathLower -match '/management') { $role = "management" } + elseif ($mgPathLower -match 'platform') { $role = "platform-other" } + elseif ($mgPathLower -match 'landing.*zones'){ $role = "landing-zone" } + elseif ($mgPathLower -match 'sandbox') { $role = "sandbox" } + elseif ($mgPathLower -match 'decommission') { $role = "decommissioned" } + } + + # Name-based fallback only when MG path didn't classify + if ($role -eq "landing-zone") { + if ($subNameLower -match 'connectivity') { $role = "connectivity" } + elseif ($subNameLower -match 'identity|aad|entra') { $role = "identity" } + elseif ($subNameLower -match 'management|logging|monitor'){ $role = "management" } + } + + # Determine environment for landing zone subscriptions + if ($role -eq "landing-zone") { + if ($subNameLower -match 'production') { $environment = "prod" } + elseif ($subNameLower -match 'prod') { $environment = "prod" } + elseif ($subNameLower -match 'staging|stg|uat|qa') { $environment = "staging" } + elseif ($subNameLower -match 'develop|test|sandbox') { $environment = "dev" } + elseif ($subNameLower -match 'dev') { $environment = "dev" } + else { $environment = "unknown" } + } + + $subEntry = [ordered]@{ + id = $subId + name = $subName + role = $role + mgPath = $mgPath + environment = $(if ($environment) { $environment } else { $null }) + } + + if ($role -in @("connectivity", "identity", "management", "platform-other")) { + [void]$PlatformSubs.Add($subEntry) + } + else { + [void]$LzSubs.Add($subEntry) + } + + if ($Verbose) { + $envSuffix = if ($environment) { " ($environment)" } else { "" } + Write-Host " $subName → $role$envSuffix" + } +} + +Write-Host " Platform subscriptions: " -NoNewline; Write-Host "$($PlatformSubs.Count)" -ForegroundColor Green +Write-Host " Landing zone subscriptions: " -NoNewline; Write-Host "$($LzSubs.Count)" -ForegroundColor Green +Write-Host "" + +# ───────────────────────────────────────────────────────────────────────────── +# [4/7] Policy Discovery +# ───────────────────────────────────────────────────────────────────────────── +$Policies = [ordered]@{ + denyEffects = @() + auditEffects = @() + allowedLocations = @() + requiredTags = @() + alzCanonicalAssignments = @() +} + +if (-not $SkipPolicies) { + Write-Host "[4/7] Discovering policy assignments..." -ForegroundColor Cyan + + $assignments = ConvertTo-Array (Invoke-AzJson @("policy", "assignment", "list", "--query", "[?enforcementMode=='Default']")) + Write-Host " Found " -NoNewline; Write-Host "$($assignments.Count)" -ForegroundColor Green -NoNewline; Write-Host " enforced policy assignments" + + $denyPolicies = [System.Collections.ArrayList]::new() + $auditPolicies = [System.Collections.ArrayList]::new() + $allowedLocations = [System.Collections.ArrayList]::new() + $requiredTags = [System.Collections.ArrayList]::new() + $alzCanonical = [System.Collections.ArrayList]::new() + + $alzPattern = 'Deploy-MDFC-Config|Deploy-AzActivity-Log|Deploy-Diag-LogsCat-LAW|Deploy-Diagnostics-LogAnalytics|Enforce-Encryption-CMK|Enforce-EncryptTransit|Enforce-TLS-SSL|Deny-PublicIP|Deny-RDP-From-Internet|Deny-Subnet-Without-Nsg|Deny-Storage-http|Deploy-Resource-Diag|Deploy-VM-Backup|Deploy-Private-DNS-Zones|Audit-UnusedResources' + + foreach ($a in $assignments) { + $displayName = Get-Prop $a 'displayName' + $name = Get-Prop $a 'name' + $params = Get-Prop $a 'parameters' + + # Resolve effect from parameters block + $effect = "" + if ($params) { + $effParam = Get-Prop $params 'effect' + if (-not $effParam) { $effParam = Get-Prop $params 'Effect' } + if ($effParam) { $effect = ("" + (Get-Prop $effParam 'value')).ToLower() } + } + + if ($displayName) { + $dnLower = ("" + $displayName).ToLower() + if ($effect -eq "deny") { + $impact = + if ($dnLower -match 'public.?ip') { "Blocks public IP creation" } + elseif ($dnLower -match 'location|region') { "Restricts allowed regions" } + elseif ($dnLower -match 'storage.*public') { "Blocks public storage access" } + elseif ($dnLower -match 'tag') { "Requires specific tags" } + elseif ($dnLower -match 'subnet.*nsg') { "Requires NSG on subnets" } + elseif ($dnLower -match 'sql.*public') { "Blocks public SQL access" } + else { "Blocks deployments matching this policy" } + [void]$denyPolicies.Add([ordered]@{ + name = $displayName + scope = (Get-Prop $a 'scope') + policyDefinitionId = (Get-Prop $a 'policyDefinitionId') + effect = $effect + impact = $impact + }) + } + elseif ($effect.StartsWith("audit")) { + [void]$auditPolicies.Add([ordered]@{ + name = $displayName + scope = (Get-Prop $a 'scope') + policyDefinitionId = (Get-Prop $a 'policyDefinitionId') + effect = $effect + }) + } + + # Allowed locations parameter + if ($params) { + $locParam = Get-Prop $params 'listOfAllowedLocations' + if ($locParam) { + foreach ($l in (ConvertTo-Array (Get-Prop $locParam 'value'))) { [void]$allowedLocations.Add($l) } + } + $tagParam = Get-Prop $params 'tagName' + if ($tagParam -and (Get-Prop $tagParam 'value')) { [void]$requiredTags.Add((Get-Prop $tagParam 'value')) } + } + } + + # Canonical ALZ accelerator policy assignment names + $matchName = if ($displayName) { $displayName } else { $name } + if ($matchName -and ($matchName -match "(?i)$alzPattern")) { + [void]$alzCanonical.Add($matchName) + } + } + + $Policies['denyEffects'] = @($denyPolicies) + $Policies['auditEffects'] = @($auditPolicies) + $Policies['allowedLocations'] = @($allowedLocations | Select-Object -Unique) + $Policies['requiredTags'] = @($requiredTags | Select-Object -Unique) + $Policies['alzCanonicalAssignments'] = @($alzCanonical | Select-Object -Unique) + + if ($Verbose -and $alzCanonical.Count -gt 0) { + Write-Host " Canonical ALZ policy assignments found:" + $Policies['alzCanonicalAssignments'] | ForEach-Object { Write-Host " ✓ $_" } + } + if ($Verbose -and $denyPolicies.Count -gt 0) { + Write-Host " Deny-effect policies:" + $denyPolicies | ForEach-Object { Write-Host " âš ī¸ $($_.name) — $($_.impact)" } + } +} +else { + Write-Host "[4/7] Skipping policy discovery (-SkipPolicies)" -ForegroundColor Cyan +} +Write-Host "" + +# ───────────────────────────────────────────────────────────────────────────── +# [5/7] Network Topology Discovery +# ───────────────────────────────────────────────────────────────────────────── +$Networking = [ordered]@{ + topology = "unknown" + hubs = @() + privateDnsZones = @() + peerings = @() +} +$Topology = "unknown" +$HasGraph = $false + +if (-not $SkipNetwork) { + Write-Host "[5/7] Discovering network topology..." -ForegroundColor Cyan + + $hubVnets = @() + $peerings = @() + $dnsZoneNames = @() + + # Try Azure Resource Graph first (faster, cross-subscription) + $HasGraph = $true + $null = az graph query -q "Resources | take 1" -o json 2>$null + if ($LASTEXITCODE -ne 0) { $HasGraph = $false } + + if ($HasGraph) { + $hubResult = Invoke-AzJson @("graph", "query", "-q", @" +Resources +| where type == 'microsoft.network/virtualnetworks' +| where name contains 'hub' or tags['network-role'] == 'hub' or tags['NetworkRole'] == 'Hub' +| project id, name, location, subscriptionId, addressPrefixes=properties.addressSpace.addressPrefixes +"@, "--query", "data") + $hubVnets = ConvertTo-Array $hubResult + + if ($hubVnets.Count -gt 0) { + $Topology = "hub-spoke" + Write-Host " Topology: " -NoNewline; Write-Host "Hub-Spoke" -ForegroundColor Green -NoNewline; Write-Host " ($($hubVnets.Count) hub VNets found)" + } + else { + $Topology = "flat" + Write-Host " Topology: " -NoNewline; Write-Host "Flat" -ForegroundColor Yellow -NoNewline; Write-Host " (no hub VNets found)" + } + + $peerResult = Invoke-AzJson @("graph", "query", "-q", @" +Resources +| where type == 'microsoft.network/virtualnetworks' +| mv-expand peering=properties.virtualNetworkPeerings +| project vnetName=name, vnetId=id, peerName=peering.name, remoteVnet=peering.properties.remoteVirtualNetwork.id, peeringState=peering.properties.peeringState +"@, "--query", "data") + $peerings = ConvertTo-Array $peerResult + if ($peerings.Count -gt 0) { + Write-Host " VNet peerings: " -NoNewline; Write-Host "$($peerings.Count)" -ForegroundColor Green + } + + $dnsResult = Invoke-AzJson @("graph", "query", "-q", @" +Resources +| where type == 'microsoft.network/privatednszones' +| project id, name, subscriptionId +"@, "--query", "data") + $dnsZoneNames = @((ConvertTo-Array $dnsResult) | ForEach-Object { Get-Prop $_ 'name' } | Select-Object -Unique) + if ($dnsZoneNames.Count -gt 0) { + Write-Host " Private DNS zones: " -NoNewline; Write-Host "$($dnsZoneNames.Count)" -ForegroundColor Green + } + } + else { + Write-Host " Azure Resource Graph not available, using direct queries" -ForegroundColor Yellow + $PartialFailure = $true + + $vnetQuery = '[?contains(name, ''hub'') || tags."network-role" == ''hub'']' + $vnetList = ConvertTo-Array (Invoke-AzJson @("network", "vnet", "list", "--query", $vnetQuery)) + $hubVnets = @($vnetList | ForEach-Object { + $id = Get-Prop $_ 'id' + [ordered]@{ + id = $id + name = (Get-Prop $_ 'name') + location = (Get-Prop $_ 'location') + subscriptionId = (($id -split '/')[2]) + addressPrefixes = (Get-Prop (Get-Prop $_ 'addressSpace') 'addressPrefixes') + } + }) + + if ($hubVnets.Count -gt 0) { + $Topology = "hub-spoke" + Write-Host " Topology: " -NoNewline; Write-Host "Hub-Spoke" -ForegroundColor Green -NoNewline; Write-Host " ($($hubVnets.Count) hub VNets in current subscription)" + } + else { + $Topology = "flat" + Write-Host " Topology: " -NoNewline; Write-Host "Flat" -ForegroundColor Yellow -NoNewline; Write-Host " (no hub VNets in current subscription)" + } + $dnsZoneNames = @() + } + + $hubsOutput = @($hubVnets | ForEach-Object { + [ordered]@{ + id = (Get-Prop $_ 'id') + name = (Get-Prop $_ 'name') + subscription = (Get-Prop $_ 'subscriptionId') + location = (Get-Prop $_ 'location') + addressPrefixes = (ConvertTo-Array (Get-Prop $_ 'addressPrefixes')) + } + }) + + $Networking = [ordered]@{ + topology = $Topology + hubs = @($hubsOutput) + privateDnsZones = @($dnsZoneNames) + peerings = @($peerings) + } +} +else { + Write-Host "[5/7] Skipping network discovery (-SkipNetwork)" -ForegroundColor Cyan +} +Write-Host "" + +# ───────────────────────────────────────────────────────────────────────────── +# [6/7] Shared Services Discovery +# ───────────────────────────────────────────────────────────────────────────── +$SharedServices = [ordered]@{} + +if (-not $SkipSharedServices) { + Write-Host "[6/7] Discovering shared services..." -ForegroundColor Cyan + + $logAnalytics = [ordered]@{} + $containerRegistry = [ordered]@{} + $keyVault = [ordered]@{} + + if ($HasGraph) { + $laResults = ConvertTo-Array (Invoke-AzJson @("graph", "query", "-q", @" +Resources +| where type == 'microsoft.operationalinsights/workspaces' +| where tags['shared'] == 'true' or name contains 'platform' or name contains 'central' or name contains 'shared' +| project id, name, subscriptionId, location, sku=properties.sku.name, retentionDays=properties.retentionInDays +| take 5 +"@, "--query", "data")) + if ($laResults.Count -gt 0) { + $first = $laResults[0] + $logAnalytics = [ordered]@{ + id = (Get-Prop $first 'id') + name = (Get-Prop $first 'name') + subscription = (Get-Prop $first 'subscriptionId') + location = (Get-Prop $first 'location') + } + Write-Host " Log Analytics: " -NoNewline; Write-Host "$($logAnalytics.name)" -ForegroundColor Green + } + else { + Write-Host " Log Analytics: " -NoNewline; Write-Host "none found" -ForegroundColor Yellow + } + + $acrResults = ConvertTo-Array (Invoke-AzJson @("graph", "query", "-q", @" +Resources +| where type == 'microsoft.containerregistry/registries' +| where tags['shared'] == 'true' or sku.name == 'Premium' +| project id, name, subscriptionId, location, sku=sku.name, loginServer=properties.loginServer +| take 5 +"@, "--query", "data")) + if ($acrResults.Count -gt 0) { + $first = $acrResults[0] + $containerRegistry = [ordered]@{ + id = (Get-Prop $first 'id') + name = (Get-Prop $first 'name') + subscription = (Get-Prop $first 'subscriptionId') + location = (Get-Prop $first 'location') + loginServer = (Get-Prop $first 'loginServer') + } + Write-Host " Container Registry: " -NoNewline; Write-Host "$($containerRegistry.name)" -ForegroundColor Green + } + else { + Write-Host " Container Registry: " -NoNewline; Write-Host "none found" -ForegroundColor Yellow + } + + $kvResults = ConvertTo-Array (Invoke-AzJson @("graph", "query", "-q", @" +Resources +| where type == 'microsoft.keyvault/vaults' +| where tags['shared'] == 'true' or name contains 'platform' or name contains 'shared' +| project id, name, subscriptionId, location +| take 5 +"@, "--query", "data")) + if ($kvResults.Count -gt 0) { + $first = $kvResults[0] + $keyVault = [ordered]@{ + id = (Get-Prop $first 'id') + name = (Get-Prop $first 'name') + subscription = (Get-Prop $first 'subscriptionId') + location = (Get-Prop $first 'location') + } + Write-Host " Key Vault: " -NoNewline; Write-Host "$($keyVault.name)" -ForegroundColor Green + } + else { + Write-Host " Key Vault: " -NoNewline; Write-Host "none found" -ForegroundColor Yellow + } + } + else { + Write-Host " Azure Resource Graph not available, skipping shared services" -ForegroundColor Yellow + $PartialFailure = $true + } + + $SharedServices = [ordered]@{ + logAnalytics = $logAnalytics + containerRegistry = $containerRegistry + keyVault = $keyVault + } +} +else { + Write-Host "[6/7] Skipping shared services discovery (-SkipSharedServices)" -ForegroundColor Cyan +} +Write-Host "" + +# ───────────────────────────────────────────────────────────────────────────── +# [7/7] Landing Zone Detection & Confidence Scoring +# ───────────────────────────────────────────────────────────────────────────── +# Weighted signals (max 100): see discover-lz.sh for the full rationale. +# Top-level MGs 0–30 Platform children 0–20 +# LZ archetypes 0/10 Platform subs 0–10 +# Hub-spoke topology 0/5 Hub in conn sub 0/5 +# Canonical ALZ policy 0–15 +# Confidence: high â‰Ĩ 70, medium â‰Ĩ 40, low â‰Ĩ 10, none < 10. isLandingZone = (score â‰Ĩ 40) +Write-Host "[7/7] Scoring landing zone confidence..." -ForegroundColor Cyan + +$roles = @($MgHierarchy | ForEach-Object { if ($_.role) { $_.role } else { "other" } }) +$hasPlatform = $roles -contains "platform" +$hasLandingZones = $roles -contains "landing-zones" +$hasSandbox = $roles -contains "sandbox" +$hasDecommissioned = $roles -contains "decommissioned" +$hasConnectivity = $roles -contains "connectivity" +$hasIdentity = $roles -contains "identity" +$hasManagement = $roles -contains "management" +$hasCorp = $roles -contains "corp" +$hasOnline = $roles -contains "online" + +$topLevel = @($hasPlatform, $hasLandingZones, $hasSandbox, $hasDecommissioned | Where-Object { $_ }).Count +$platChildren = @($hasConnectivity, $hasIdentity, $hasManagement | Where-Object { $_ }).Count +$platSubCount = $PlatformSubs.Count +$hasHubSpoke = ($Networking.topology -eq "hub-spoke") +$hubs = ConvertTo-Array $Networking.hubs +$connSubIds = @($PlatformSubs | Where-Object { $_.role -eq "connectivity" } | ForEach-Object { $_.id }) +$hubInConn = $false +foreach ($h in $hubs) { + if ($connSubIds -contains (Get-Prop $h 'subscription')) { $hubInConn = $true; break } +} +$alzPolList = @($Policies.alzCanonicalAssignments | Sort-Object) +$alzPolCount = $alzPolList.Count + +# Points per signal +$ptsTop = if ($topLevel -eq 4) { 30 } elseif ($topLevel -eq 3) { 20 } elseif ($topLevel -eq 2) { 10 } else { 0 } +$ptsChildren = if ($platChildren -eq 3) { 20 } elseif ($platChildren -eq 2) { 10 } else { 0 } +$ptsArchetypes = if ($hasCorp -and $hasOnline) { 10 } else { 0 } +$ptsPlatSubs = if ($platSubCount -ge 3) { 10 } elseif ($platSubCount -ge 1) { 5 } else { 0 } +$ptsHubSpoke = if ($hasHubSpoke) { 5 } else { 0 } +$ptsHubInConn = if ($hubInConn) { 5 } else { 0 } +$ptsAlzPols = [Math]::Min($alzPolCount * 5, 15) + +$score = $ptsTop + $ptsChildren + $ptsArchetypes + $ptsPlatSubs + $ptsHubSpoke + $ptsHubInConn + $ptsAlzPols +$confidence = if ($score -ge 70) { "high" } elseif ($score -ge 40) { "medium" } elseif ($score -ge 10) { "low" } else { "none" } + +$matchedSignals = [System.Collections.ArrayList]::new() +if ($topLevel -gt 0) { [void]$matchedSignals.Add([ordered]@{ signal = "alz-top-level-mgs"; points = $ptsTop; evidence = "$topLevel/4 canonical top-level MGs (Platform, Landing zones, Sandbox, Decommissioned)" }) } +if ($platChildren -gt 0) { [void]$matchedSignals.Add([ordered]@{ signal = "platform-children"; points = $ptsChildren; evidence = "$platChildren/3 platform children (Connectivity, Identity, Management)" }) } +if ($hasCorp -and $hasOnline) { [void]$matchedSignals.Add([ordered]@{ signal = "alz-lz-archetypes"; points = $ptsArchetypes; evidence = "Corp and Online MGs present under Landing zones" }) } +if ($platSubCount -gt 0) { [void]$matchedSignals.Add([ordered]@{ signal = "platform-subscriptions"; points = $ptsPlatSubs; evidence = "$platSubCount platform subscription(s) classified" }) } +if ($hasHubSpoke) { [void]$matchedSignals.Add([ordered]@{ signal = "hub-spoke-topology"; points = $ptsHubSpoke; evidence = "Hub VNet(s): $((@($hubs | ForEach-Object { Get-Prop $_ 'name' })) -join ', ')" }) } +if ($hubInConn) { [void]$matchedSignals.Add([ordered]@{ signal = "hub-in-connectivity-sub"; points = $ptsHubInConn; evidence = "Hub VNet sits in a connectivity-classified subscription" }) } +if ($alzPolCount -gt 0) { [void]$matchedSignals.Add([ordered]@{ signal = "alz-canonical-policies"; points = $ptsAlzPols; evidence = "$alzPolCount canonical ALZ policy assignment(s): $($alzPolList -join ', ')" }) } + +$missingSignals = [System.Collections.ArrayList]::new() +if ($topLevel -lt 4) { [void]$missingSignals.Add("alz-top-level-mgs ($topLevel/4)") } +if ($platChildren -lt 3) { [void]$missingSignals.Add("platform-children ($platChildren/3)") } +if (-not ($hasCorp -and $hasOnline)) { [void]$missingSignals.Add("alz-lz-archetypes (Corp/Online MGs)") } +if ($platSubCount -lt 3) { [void]$missingSignals.Add("platform-subscriptions ($platSubCount/3+)") } +if (-not $hasHubSpoke) { [void]$missingSignals.Add("hub-spoke-topology") } +if (-not $hubInConn) { [void]$missingSignals.Add("hub-in-connectivity-sub") } +if ($alzPolCount -eq 0) { [void]$missingSignals.Add("alz-canonical-policies") } + +$Detection = [ordered]@{ + isLandingZone = ($score -ge 40) + confidence = $confidence + confidenceScore = $score + reference = "https://azure.github.io/Azure-Landing-Zones/accelerator/" + matchedSignals = @($matchedSignals) + missingSignals = @($missingSignals) + checks = [ordered]@{ + topLevelMgs = [ordered]@{ + platform = $hasPlatform + landingZones = $hasLandingZones + sandbox = $hasSandbox + decommissioned = $hasDecommissioned + } + platformChildren = [ordered]@{ + connectivity = $hasConnectivity + identity = $hasIdentity + management = $hasManagement + } + lzChildren = [ordered]@{ corp = $hasCorp; online = $hasOnline } + platformSubscriptionCount = $platSubCount + hubSpoke = $hasHubSpoke + hubInConnectivitySubscription = $hubInConn + knownAlzPolicies = @($alzPolList) + } +} + +switch ($confidence) { + "high" { Write-Host " Landing zone detection: " -NoNewline; Write-Host "high" -ForegroundColor Green -NoNewline; Write-Host " ($score/100) — canonical ALZ deployment" } + "medium" { Write-Host " Landing zone detection: " -NoNewline; Write-Host "medium" -ForegroundColor Green -NoNewline; Write-Host " ($score/100) — partial ALZ alignment" } + "low" { Write-Host " Landing zone detection: " -NoNewline; Write-Host "low" -ForegroundColor Yellow -NoNewline; Write-Host " ($score/100) — some LZ signals" } + default { Write-Host " Landing zone detection: " -NoNewline; Write-Host "none" -ForegroundColor Yellow -NoNewline; Write-Host " ($score/100) — no canonical signals" } +} + +if ($Verbose) { + Write-Host " Matched signals:" + $matchedSignals | ForEach-Object { Write-Host " + $($_.points) pts — $($_.signal): $($_.evidence)" } + if ($missingSignals.Count -gt 0) { + Write-Host " Missing signals:" + $missingSignals | ForEach-Object { Write-Host " - $_" } + } +} +Write-Host "" + +# ───────────────────────────────────────────────────────────────────────────── +# Assemble Output +# ───────────────────────────────────────────────────────────────────────────── +Write-Host "Assembling landing zone context..." -ForegroundColor Blue + +$ContextJson = [ordered]@{ + discoveredAt = $DiscoveryTimestamp + discoveryMethod = "auto" + landingZoneDetection = $Detection + managementGroups = [ordered]@{ + root = $MgRoot + hasManagementGroups = $HasManagementGroups + hierarchy = @($MgHierarchy) + } + subscriptions = [ordered]@{ + platform = @($PlatformSubs) + landingZones = @($LzSubs) + } + sharedServices = $SharedServices + networking = $Networking + policies = $Policies + currentIdentity = $IdentityJson +} + +# ───────────────────────────────────────────────────────────────────────────── +# Output +# ───────────────────────────────────────────────────────────────────────────── +if ($OutputFormat -eq "markdown") { + $sb = [System.Text.StringBuilder]::new() + [void]$sb.AppendLine("# Landing Zone Discovery Report").AppendLine() + [void]$sb.AppendLine("**Discovered:** $DiscoveryTimestamp ") + [void]$sb.AppendLine("**User:** $CurrentUser ") + [void]$sb.AppendLine("**Tenant:** $CurrentTenantId").AppendLine() + + [void]$sb.AppendLine("## Management Groups").AppendLine() + if ($HasManagementGroups) { + [void]$sb.AppendLine("Root: $MgRoot").AppendLine() + [void]$sb.AppendLine("| Management Group | Role | ID |") + [void]$sb.AppendLine("|------------------|------|----|") + foreach ($mg in $MgHierarchy) { + [void]$sb.AppendLine("| $($mg.displayName) | $($mg.role) | $($mg.name) |") + } + } + else { + [void]$sb.AppendLine("No management groups found (flat subscription model)") + } + [void]$sb.AppendLine() + + [void]$sb.AppendLine("## Subscriptions").AppendLine() + [void]$sb.AppendLine("### Platform").AppendLine() + if ($PlatformSubs.Count -gt 0) { + [void]$sb.AppendLine("| Name | Role | MG Path |") + [void]$sb.AppendLine("|------|------|---------|") + foreach ($s in $PlatformSubs) { + $mg = if ($s.mgPath) { $s.mgPath } else { "N/A" } + [void]$sb.AppendLine("| $($s.name) | $($s.role) | $mg |") + } + } + else { + [void]$sb.AppendLine("No platform subscriptions found") + } + [void]$sb.AppendLine() + + [void]$sb.AppendLine("### Landing Zones").AppendLine() + if ($LzSubs.Count -gt 0) { + [void]$sb.AppendLine("| Name | Environment | MG Path |") + [void]$sb.AppendLine("|------|-------------|---------|") + foreach ($s in $LzSubs) { + $env = if ($s.environment) { $s.environment } else { "N/A" } + $mg = if ($s.mgPath) { $s.mgPath } else { "N/A" } + [void]$sb.AppendLine("| $($s.name) | $env | $mg |") + } + } + else { + [void]$sb.AppendLine("No landing zone subscriptions found") + } + [void]$sb.AppendLine() + + $output = $sb.ToString() + if ($OutputFile) { $output | Set-Content -Path $OutputFile -Encoding utf8 } else { Write-Host $output } +} +else { + $json = $ContextJson | ConvertTo-Json -Depth 20 + if ($OutputFile) { + $OutputDir = Split-Path -Parent $OutputFile + if ($OutputDir -and -not (Test-Path $OutputDir)) { + New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null + } + $json | Set-Content -Path $OutputFile -Encoding utf8 + Write-Host "Landing zone context saved to: $OutputFile" -ForegroundColor Green + } + else { + Write-Host $json + } +} + +Write-Host "" + +# Summary +Write-Host "Discovery Summary:" -ForegroundColor Blue +if ($HasManagementGroups) { + Write-Host " Management Groups: " -NoNewline; Write-Host "$($MgHierarchy.Count) found" -ForegroundColor Green +} +else { + Write-Host " Management Groups: " -NoNewline; Write-Host "none (flat model)" -ForegroundColor Yellow +} +Write-Host " Platform Subscriptions: " -NoNewline; Write-Host "$($PlatformSubs.Count)" -ForegroundColor Green +Write-Host " Landing Zone Subscriptions: " -NoNewline; Write-Host "$($LzSubs.Count)" -ForegroundColor Green +$netSummary = if ($SkipNetwork) { "skipped" } else { $Topology } +Write-Host " Network Topology: $netSummary" +$polSummary = if ($SkipPolicies) { "skipped" } else { "$(@($Policies.denyEffects).Count) deny-effect" } +Write-Host " Policy Assignments: $polSummary" +Write-Host "" + +if ($PartialFailure) { + Write-Host "âš ī¸ Partial discovery — some targets could not be reached. Results are still usable." -ForegroundColor Yellow + Write-Host " Consider manual injection for missing data: .github/skills/azure-landing-zone-discovery/scripts/inject-lz.ps1" -ForegroundColor Yellow + exit 1 +} + +Write-Host "✅ Landing zone discovery complete" -ForegroundColor Green +exit 0 diff --git a/.github/skills/azure-landing-zone-discovery/scripts/discover-lz.sh b/.github/skills/azure-landing-zone-discovery/scripts/discover-lz.sh new file mode 100755 index 0000000..99ff93b --- /dev/null +++ b/.github/skills/azure-landing-zone-discovery/scripts/discover-lz.sh @@ -0,0 +1,969 @@ +#!/bin/bash +# Azure Landing Zone Discovery Script +# Auto-discovers management groups, subscriptions, policies, networking, and shared services + +set -euo pipefail + +# Color codes +RED='\033[0;31m' +YELLOW='\033[1;33m' +GREEN='\033[0;32m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +# Default values +OUTPUT_FORMAT="json" +OUTPUT_FILE="" +VERBOSE=false +SKIP_NETWORK=false +SKIP_POLICIES=false +SKIP_SHARED_SERVICES=false + +usage() { + cat < Output format: json, markdown (default: json) + --output-file Output file path (default: stdout) + --skip-network Skip network topology discovery + --skip-policies Skip policy assignment discovery + --skip-shared-services Skip shared services discovery + --verbose Show detailed discovery progress + -h, --help Show this help message + +Examples: + $0 --output-file .azure/landing-zone-context.json + $0 --output-format markdown + $0 --output-file .azure/landing-zone-context.json --skip-network + $0 --verbose + +Exit Codes: + 0 Discovery completed successfully + 1 Partial discovery (some targets failed, results still usable) + 2 Discovery failed (no Azure access or critical error) + +EOF + exit 1 +} + +# Parse arguments +while [[ $# -gt 0 ]]; do + case $1 in + --output-format) + OUTPUT_FORMAT="$2" + shift 2 + ;; + --output-file) + OUTPUT_FILE="$2" + shift 2 + ;; + --skip-network) + SKIP_NETWORK=true + shift + ;; + --skip-policies) + SKIP_POLICIES=true + shift + ;; + --skip-shared-services) + SKIP_SHARED_SERVICES=true + shift + ;; + --verbose) + VERBOSE=true + shift + ;; + -h|--help) + usage + ;; + *) + echo "Unknown option: $1" + usage + ;; + esac +done + +# Validate Azure CLI is available and logged in +if ! command -v az &> /dev/null; then + echo -e "${RED}Error: Azure CLI (az) is not installed${NC}" + exit 2 +fi + +if ! az account show &> /dev/null; then + echo -e "${RED}Error: Not logged in to Azure. Run 'az login' first.${NC}" + exit 2 +fi + +# Timestamp for this discovery +DISCOVERY_TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ) + +echo -e "${BLUE}Starting Azure Landing Zone Discovery${NC}" +echo "Timestamp: $DISCOVERY_TIMESTAMP" +echo "" + +# Track partial failures +PARTIAL_FAILURE=false + +# ───────────────────────────────────────────────────────────────────────────── +# Current Identity +# ───────────────────────────────────────────────────────────────────────────── +echo -e "${CYAN}[1/7] Discovering current identity...${NC}" + +CURRENT_ACCOUNT=$(az account show --output json 2>/dev/null) +CURRENT_USER=$(echo "$CURRENT_ACCOUNT" | jq -r '.user.name // "unknown"') +CURRENT_TENANT_ID=$(echo "$CURRENT_ACCOUNT" | jq -r '.tenantId // "unknown"') +CURRENT_SUB_ID=$(echo "$CURRENT_ACCOUNT" | jq -r '.id // "unknown"') +CURRENT_SUB_NAME=$(echo "$CURRENT_ACCOUNT" | jq -r '.name // "unknown"') + +echo -e " User: ${GREEN}$CURRENT_USER${NC}" +echo -e " Tenant: $CURRENT_TENANT_ID" +echo -e " Subscription: $CURRENT_SUB_NAME ($CURRENT_SUB_ID)" +echo "" + +IDENTITY_JSON=$(jq -n \ + --arg user "$CURRENT_USER" \ + --arg tenantId "$CURRENT_TENANT_ID" \ + --arg subId "$CURRENT_SUB_ID" \ + --arg subName "$CURRENT_SUB_NAME" \ + '{ + user: $user, + tenantId: $tenantId, + currentSubscription: { id: $subId, name: $subName }, + roles: [] + }') + +# ───────────────────────────────────────────────────────────────────────────── +# Management Group Hierarchy +# ───────────────────────────────────────────────────────────────────────────── +echo -e "${CYAN}[2/7] Discovering management group hierarchy...${NC}" + +MG_HIERARCHY="[]" +MG_ROOT="" +HAS_MANAGEMENT_GROUPS=false + +MG_LIST=$(az account management-group list --output json 2>/dev/null || echo "[]") + +if [[ "$MG_LIST" != "[]" ]] && [[ -n "$MG_LIST" ]]; then + HAS_MANAGEMENT_GROUPS=true + MG_COUNT=$(echo "$MG_LIST" | jq 'length') + echo -e " Found ${GREEN}$MG_COUNT${NC} management groups" + + # Find root management group (Tenant Root Group) + MG_ROOT=$(echo "$MG_LIST" | jq -r ' + [.[] | select( + .displayName == "Tenant Root Group" or + .name == .tenantId or + (.properties.details.parent == null) + )] | first | .displayName // "Tenant Root Group" + ') + + # Classify management groups by canonical ALZ name first, then fall back to + # substring matching for non-canonical naming. Canonical names come from the + # Azure Landing Zone accelerator: https://azure.github.io/Azure-Landing-Zones/accelerator/ + MG_HIERARCHY=$(echo "$MG_LIST" | jq '[ + .[] | . as $mg | (.displayName // .name | ascii_downcase) as $n | { + id: .id, + name: .name, + displayName: .displayName, + role: ( + if .displayName == "Tenant Root Group" or .name == .tenantId then "root" + # --- Exact-name ALZ archetypes (high precision) --- + elif $n == "platform" then "platform" + elif $n == "connectivity" then "connectivity" + elif $n == "identity" then "identity" + elif $n == "management" then "management" + elif $n == "landing zones" or $n == "landingzones" or $n == "landing-zones" then "landing-zones" + elif $n == "corp" then "corp" + elif $n == "online" then "online" + elif $n == "sandbox" then "sandbox" + elif $n == "decommissioned" then "decommissioned" + # --- Substring fallback for custom-named environments (lower precision) --- + elif ($n | test("platform|infra")) then "platform" + elif ($n | test("connectivity|network|hub")) then "connectivity" + elif ($n | test("identity|aad|entra")) then "identity" + elif ($n | test("management|logging|monitor")) then "management" + elif ($n | test("landing.?zone|workload|application")) then "landing-zones" + elif ($n | test("sandbox|dev.?test")) then "sandbox" + elif ($n | test("decommission|deprecated|retired")) then "decommissioned" + else "other" + end + ), + parentId: (.properties.details.parent.id // null) + } + ]') + + if [[ "$VERBOSE" == "true" ]]; then + echo " Management group classification:" + echo "$MG_HIERARCHY" | jq -r '.[] | " \(.displayName) → \(.role)"' + fi +else + echo -e " ${YELLOW}No management groups found (flat subscription model)${NC}" +fi +echo "" + +# ───────────────────────────────────────────────────────────────────────────── +# Subscription Discovery & Classification +# ───────────────────────────────────────────────────────────────────────────── +echo -e "${CYAN}[3/7] Discovering and classifying subscriptions...${NC}" + +SUBSCRIPTIONS=$(az account list --query "[?state=='Enabled']" --output json 2>/dev/null || echo "[]") +SUB_COUNT=$(echo "$SUBSCRIPTIONS" | jq 'length') +echo -e " Found ${GREEN}$SUB_COUNT${NC} enabled subscriptions" + +PLATFORM_SUBS="[]" +LZ_SUBS="[]" + +for SUB_ID in $(echo "$SUBSCRIPTIONS" | jq -r '.[].id'); do + SUB_NAME=$(echo "$SUBSCRIPTIONS" | jq -r --arg id "$SUB_ID" '.[] | select(.id == $id) | .name') + # Try to get management group path + MG_PATH="" + if [[ "$HAS_MANAGEMENT_GROUPS" == "true" ]]; then + MG_PATH=$(az account management-group subscription show \ + --subscription-id "$SUB_ID" \ + --query "managementGroupAncestorsChain[].displayName" \ + --output tsv 2>/dev/null | tr '\n' '/' | sed 's/\/$//' || echo "") + fi + + # Classify subscription. Per the Azure Landing Zone accelerator, management + # group placement is the authoritative signal — workloads land under + # Landing Zones/, platform subs under Platform/. Fall back + # to subscription name patterns only when no MG hierarchy exists. + SUB_NAME_LOWER=$(echo "$SUB_NAME" | tr '[:upper:]' '[:lower:]') + MG_PATH_LOWER=$(echo "$MG_PATH" | tr '[:upper:]' '[:lower:]') + ROLE="landing-zone" + ENVIRONMENT="" + + if [[ -n "$MG_PATH_LOWER" ]]; then + # Prefer MG-path classification (canonical ALZ placement) + case "$MG_PATH_LOWER" in + */connectivity*) ROLE="connectivity" ;; + */identity*) ROLE="identity" ;; + */management*) ROLE="management" ;; + *platform*) ROLE="platform-other" ;; + *landing*zones*) ROLE="landing-zone" ;; + *sandbox*) ROLE="sandbox" ;; + *decommission*) ROLE="decommissioned" ;; + esac + fi + + # Name-based fallback only when MG path didn't classify (still "landing-zone") + if [[ "$ROLE" == "landing-zone" ]]; then + case "$SUB_NAME_LOWER" in + *connectivity*) ROLE="connectivity" ;; + *identity*|*aad*|*entra*) ROLE="identity" ;; + *management*|*logging*|*monitor*) ROLE="management" ;; + esac + fi + + # Determine environment for landing zone subscriptions + if [[ "$ROLE" == "landing-zone" ]]; then + case "$SUB_NAME_LOWER" in + *production*) + ENVIRONMENT="prod" ;; + *prod*) + ENVIRONMENT="prod" ;; + *staging*|*stg*|*uat*|*qa*) + ENVIRONMENT="staging" ;; + *develop*|*test*|*sandbox*) + ENVIRONMENT="dev" ;; + *dev*) + ENVIRONMENT="dev" ;; + *) + ENVIRONMENT="unknown" ;; + esac + fi + + SUB_ENTRY=$(jq -n \ + --arg id "$SUB_ID" \ + --arg name "$SUB_NAME" \ + --arg role "$ROLE" \ + --arg mgPath "$MG_PATH" \ + --arg env "$ENVIRONMENT" \ + '{ + id: $id, + name: $name, + role: $role, + mgPath: $mgPath, + environment: (if $env != "" then $env else null end) + }') + + if [[ "$ROLE" == "connectivity" ]] || [[ "$ROLE" == "identity" ]] || [[ "$ROLE" == "management" ]] || [[ "$ROLE" == "platform-other" ]]; then + PLATFORM_SUBS=$(echo "$PLATFORM_SUBS" | jq --argjson entry "$SUB_ENTRY" '. += [$entry]') + else + LZ_SUBS=$(echo "$LZ_SUBS" | jq --argjson entry "$SUB_ENTRY" '. += [$entry]') + fi + + if [[ "$VERBOSE" == "true" ]]; then + echo " $SUB_NAME → $ROLE${ENVIRONMENT:+ ($ENVIRONMENT)}" + fi +done + +echo -e " Platform subscriptions: ${GREEN}$(echo "$PLATFORM_SUBS" | jq 'length')${NC}" +echo -e " Landing zone subscriptions: ${GREEN}$(echo "$LZ_SUBS" | jq 'length')${NC}" +echo "" + +# ───────────────────────────────────────────────────────────────────────────── +# Policy Discovery +# ───────────────────────────────────────────────────────────────────────────── +POLICIES_JSON='{"denyEffects":[],"auditEffects":[],"allowedLocations":[],"requiredTags":[],"alzCanonicalAssignments":[]}' + +if [[ "$SKIP_POLICIES" != "true" ]]; then + echo -e "${CYAN}[4/7] Discovering policy assignments...${NC}" + + DENY_POLICIES="[]" + AUDIT_POLICIES="[]" + ALLOWED_LOCATIONS="[]" + REQUIRED_TAGS="[]" + ALZ_CANONICAL="[]" + + # Get policy assignments at current subscription scope + POLICY_ASSIGNMENTS=$(az policy assignment list \ + --query "[?enforcementMode=='Default']" \ + --output json 2>/dev/null || echo "[]") + + POLICY_COUNT=$(echo "$POLICY_ASSIGNMENTS" | jq 'length') + echo -e " Found ${GREEN}$POLICY_COUNT${NC} enforced policy assignments" + + if [[ "$POLICY_COUNT" -gt 0 ]]; then + # Resolve each assignment's effect from the parameters block. Initiatives + # (policySetDefinitions) bundle many inner definitions, so we cannot infer + # a single effect from the assignment alone — those get effect="initiative" + # and are excluded from both denyEffects and auditEffects. + # Single-definition assignments without an effect parameter get "unknown". + DENY_POLICIES=$(echo "$POLICY_ASSIGNMENTS" | jq '[ + .[] | + select(.displayName != null) | + ( + (.parameters // {}) as $p | + ( + ($p.effect.value // $p.Effect.value // "") | tostring | ascii_downcase + ) + ) as $effect | + select($effect == "deny") | + { + name: .displayName, + scope: .scope, + policyDefinitionId: .policyDefinitionId, + effect: $effect, + impact: ( + if (.displayName | ascii_downcase | test("public.?ip")) then "Blocks public IP creation" + elif (.displayName | ascii_downcase | test("location|region")) then "Restricts allowed regions" + elif (.displayName | ascii_downcase | test("storage.*public")) then "Blocks public storage access" + elif (.displayName | ascii_downcase | test("tag")) then "Requires specific tags" + elif (.displayName | ascii_downcase | test("subnet.*nsg")) then "Requires NSG on subnets" + elif (.displayName | ascii_downcase | test("sql.*public")) then "Blocks public SQL access" + else "Blocks deployments matching this policy" + end + ) + } + ]' 2>/dev/null || echo "[]") + + # Extract audit-effect policies (Audit, AuditIfNotExists) + AUDIT_POLICIES=$(echo "$POLICY_ASSIGNMENTS" | jq '[ + .[] | + select(.displayName != null) | + ( + (.parameters // {}) as $p | + ( + ($p.effect.value // $p.Effect.value // "") | tostring | ascii_downcase + ) + ) as $effect | + select($effect | startswith("audit")) | + { + name: .displayName, + scope: .scope, + policyDefinitionId: .policyDefinitionId, + effect: $effect + } + ]' 2>/dev/null || echo "[]") + + # Check for allowed locations policy (Deny + listOfAllowedLocations param) + ALLOWED_LOCATIONS=$(echo "$POLICY_ASSIGNMENTS" | jq '[ + .[] | + select(.displayName != null) | + select(.parameters.listOfAllowedLocations.value != null) | + .parameters.listOfAllowedLocations.value | .[] + ] | unique' 2>/dev/null || echo "[]") + + # Check for required tags (Require-Tag style policies expose tagName) + REQUIRED_TAGS=$(echo "$POLICY_ASSIGNMENTS" | jq '[ + .[] | + select(.displayName != null) | + select(.parameters.tagName.value != null) | + .parameters.tagName.value + ] | unique' 2>/dev/null || echo "[]") + + # Match against canonical ALZ accelerator policy assignment names. + # Reference: https://github.com/Azure/Enterprise-Scale/wiki/ALZ-Policies + # These names are deployed by the ALZ accelerator and are a high-precision + # ALZ signature regardless of effect. + ALZ_CANONICAL=$(echo "$POLICY_ASSIGNMENTS" | jq '[ + .[] | + select(.displayName != null or .name != null) | + ((.displayName // .name) | tostring) as $n | + select($n | test("Deploy-MDFC-Config|Deploy-AzActivity-Log|Deploy-Diag-LogsCat-LAW|Deploy-Diagnostics-LogAnalytics|Enforce-Encryption-CMK|Enforce-EncryptTransit|Enforce-TLS-SSL|Deny-PublicIP|Deny-RDP-From-Internet|Deny-Subnet-Without-Nsg|Deny-Storage-http|Deploy-Resource-Diag|Deploy-VM-Backup|Deploy-Private-DNS-Zones|Audit-UnusedResources"; "i")) | + $n + ] | unique' 2>/dev/null || echo "[]") + + if [[ "$VERBOSE" == "true" ]] && [[ $(echo "$ALZ_CANONICAL" | jq 'length') -gt 0 ]]; then + echo " Canonical ALZ policy assignments found:" + echo "$ALZ_CANONICAL" | jq -r '.[] | " \u2713 \(.)"' + fi + fi + + POLICIES_JSON=$(jq -n \ + --argjson deny "$DENY_POLICIES" \ + --argjson audit "$AUDIT_POLICIES" \ + --argjson locations "$ALLOWED_LOCATIONS" \ + --argjson tags "$REQUIRED_TAGS" \ + --argjson alzCanonical "$ALZ_CANONICAL" \ + '{ + denyEffects: $deny, + auditEffects: $audit, + allowedLocations: $locations, + requiredTags: $tags, + alzCanonicalAssignments: $alzCanonical + }') + + if [[ "$VERBOSE" == "true" ]] && [[ $(echo "$DENY_POLICIES" | jq 'length') -gt 0 ]]; then + echo " Deny-effect policies:" + echo "$DENY_POLICIES" | jq -r '.[] | " âš ī¸ \(.name) — \(.impact)"' + fi +else + echo -e "${CYAN}[4/7] Skipping policy discovery (--skip-policies)${NC}" +fi +echo "" + +# ───────────────────────────────────────────────────────────────────────────── +# Network Topology Discovery +# ───────────────────────────────────────────────────────────────────────────── +NETWORKING_JSON='{"topology":"unknown","hubs":[],"privateDnsZones":[],"peerings":[]}' + +if [[ "$SKIP_NETWORK" != "true" ]]; then + echo -e "${CYAN}[5/7] Discovering network topology...${NC}" + + HUB_VNETS="[]" + PEERINGS="[]" + DNS_ZONES="[]" + TOPOLOGY="unknown" + + # Try Azure Resource Graph first (faster, cross-subscription) + HAS_GRAPH=true + az graph query -q "Resources | take 1" --output json &> /dev/null || HAS_GRAPH=false + + if [[ "$HAS_GRAPH" == "true" ]]; then + # Find hub VNets via Resource Graph + HUB_VNETS=$(az graph query -q " + Resources + | where type == 'microsoft.network/virtualnetworks' + | where name contains 'hub' or tags['network-role'] == 'hub' or tags['NetworkRole'] == 'Hub' + | project id, name, location, subscriptionId, + addressPrefixes=properties.addressSpace.addressPrefixes + " --query "data" --output json 2>/dev/null || echo "[]") + + HUB_COUNT=$(echo "$HUB_VNETS" | jq 'length') + + if [[ "$HUB_COUNT" -gt 0 ]]; then + TOPOLOGY="hub-spoke" + echo -e " Topology: ${GREEN}Hub-Spoke${NC} ($HUB_COUNT hub VNets found)" + else + # Discovery ran successfully but found no hub — record as 'flat' + # so downstream consumers can distinguish from skipped/failed runs. + TOPOLOGY="flat" + echo -e " Topology: ${YELLOW}Flat${NC} (no hub VNets found)" + fi + + # Find VNet peerings + PEERINGS=$(az graph query -q " + Resources + | where type == 'microsoft.network/virtualnetworks' + | mv-expand peering=properties.virtualNetworkPeerings + | project vnetName=name, vnetId=id, + peerName=peering.name, + remoteVnet=peering.properties.remoteVirtualNetwork.id, + peeringState=peering.properties.peeringState + " --query "data" --output json 2>/dev/null || echo "[]") + + PEERING_COUNT=$(echo "$PEERINGS" | jq 'length') + if [[ "$PEERING_COUNT" -gt 0 ]]; then + echo -e " VNet peerings: ${GREEN}$PEERING_COUNT${NC}" + fi + + # Find private DNS zones + DNS_ZONES=$(az graph query -q " + Resources + | where type == 'microsoft.network/privatednszones' + | project id, name, subscriptionId + " --query "data" --output json 2>/dev/null || echo "[]") + + DNS_ZONE_NAMES=$(echo "$DNS_ZONES" | jq '[.[].name] | unique') + DNS_COUNT=$(echo "$DNS_ZONE_NAMES" | jq 'length') + if [[ "$DNS_COUNT" -gt 0 ]]; then + echo -e " Private DNS zones: ${GREEN}$DNS_COUNT${NC}" + fi + else + echo -e " ${YELLOW}Azure Resource Graph not available, using direct queries${NC}" + PARTIAL_FAILURE=true + + # Fallback: query VNets in current subscription + HUB_VNETS=$(az network vnet list \ + --query "[?contains(name, 'hub') || tags.\"network-role\" == 'hub']" \ + --output json 2>/dev/null | jq '[.[] | { + id: .id, + name: .name, + location: .location, + subscriptionId: (.id | split("/")[2]), + addressPrefixes: .addressSpace.addressPrefixes + }]' || echo "[]") + + HUB_COUNT=$(echo "$HUB_VNETS" | jq 'length') + if [[ "$HUB_COUNT" -gt 0 ]]; then + TOPOLOGY="hub-spoke" + echo -e " Topology: ${GREEN}Hub-Spoke${NC} ($HUB_COUNT hub VNets in current subscription)" + else + # Fallback path completed without finding a hub in the current sub. + # Mark as 'flat' to distinguish from skipped/failed network discovery. + TOPOLOGY="flat" + echo -e " Topology: ${YELLOW}Flat${NC} (no hub VNets in current subscription)" + fi + + DNS_ZONE_NAMES="[]" + fi + + # Format hub VNets for output + HUBS_OUTPUT=$(echo "$HUB_VNETS" | jq '[.[] | { + id: .id, + name: .name, + subscription: .subscriptionId, + location: .location, + addressPrefixes: .addressPrefixes + }]') + + NETWORKING_JSON=$(jq -n \ + --arg topology "$TOPOLOGY" \ + --argjson hubs "$HUBS_OUTPUT" \ + --argjson dnsZones "$DNS_ZONE_NAMES" \ + --argjson peerings "$PEERINGS" \ + '{ + topology: $topology, + hubs: $hubs, + privateDnsZones: $dnsZones, + peerings: $peerings + }') +else + echo -e "${CYAN}[5/7] Skipping network discovery (--skip-network)${NC}" +fi +echo "" + +# ───────────────────────────────────────────────────────────────────────────── +# Shared Services Discovery +# ───────────────────────────────────────────────────────────────────────────── +SHARED_SERVICES_JSON='{}' + +if [[ "$SKIP_SHARED_SERVICES" != "true" ]]; then + echo -e "${CYAN}[6/7] Discovering shared services...${NC}" + + LOG_ANALYTICS="{}" + CONTAINER_REGISTRY="{}" + KEY_VAULT="{}" + + if [[ "$HAS_GRAPH" == "true" ]]; then + # Find shared Log Analytics workspaces + LA_RESULTS=$(az graph query -q " + Resources + | where type == 'microsoft.operationalinsights/workspaces' + | where tags['shared'] == 'true' or name contains 'platform' or name contains 'central' or name contains 'shared' + | project id, name, subscriptionId, location, + sku=properties.sku.name, retentionDays=properties.retentionInDays + | take 5 + " --query "data" --output json 2>/dev/null || echo "[]") + + LA_COUNT=$(echo "$LA_RESULTS" | jq 'length') + if [[ "$LA_COUNT" -gt 0 ]]; then + LOG_ANALYTICS=$(echo "$LA_RESULTS" | jq 'first | { + id: .id, + name: .name, + subscription: .subscriptionId, + location: .location + }') + LA_NAME=$(echo "$LOG_ANALYTICS" | jq -r '.name') + echo -e " Log Analytics: ${GREEN}$LA_NAME${NC}" + else + echo -e " Log Analytics: ${YELLOW}none found${NC}" + fi + + # Find shared Container Registries + ACR_RESULTS=$(az graph query -q " + Resources + | where type == 'microsoft.containerregistry/registries' + | where tags['shared'] == 'true' or sku.name == 'Premium' + | project id, name, subscriptionId, location, sku=sku.name, + loginServer=properties.loginServer + | take 5 + " --query "data" --output json 2>/dev/null || echo "[]") + + ACR_COUNT=$(echo "$ACR_RESULTS" | jq 'length') + if [[ "$ACR_COUNT" -gt 0 ]]; then + CONTAINER_REGISTRY=$(echo "$ACR_RESULTS" | jq 'first | { + id: .id, + name: .name, + subscription: .subscriptionId, + location: .location, + loginServer: .loginServer + }') + ACR_NAME=$(echo "$CONTAINER_REGISTRY" | jq -r '.name') + echo -e " Container Registry: ${GREEN}$ACR_NAME${NC}" + else + echo -e " Container Registry: ${YELLOW}none found${NC}" + fi + + # Find shared Key Vaults + KV_RESULTS=$(az graph query -q " + Resources + | where type == 'microsoft.keyvault/vaults' + | where tags['shared'] == 'true' or name contains 'platform' or name contains 'shared' + | project id, name, subscriptionId, location + | take 5 + " --query "data" --output json 2>/dev/null || echo "[]") + + KV_COUNT=$(echo "$KV_RESULTS" | jq 'length') + if [[ "$KV_COUNT" -gt 0 ]]; then + KEY_VAULT=$(echo "$KV_RESULTS" | jq 'first | { + id: .id, + name: .name, + subscription: .subscriptionId, + location: .location + }') + KV_NAME=$(echo "$KEY_VAULT" | jq -r '.name') + echo -e " Key Vault: ${GREEN}$KV_NAME${NC}" + else + echo -e " Key Vault: ${YELLOW}none found${NC}" + fi + else + echo -e " ${YELLOW}Azure Resource Graph not available, skipping shared services${NC}" + PARTIAL_FAILURE=true + fi + + SHARED_SERVICES_JSON=$(jq -n \ + --argjson logAnalytics "$LOG_ANALYTICS" \ + --argjson containerRegistry "$CONTAINER_REGISTRY" \ + --argjson keyVault "$KEY_VAULT" \ + '{ + logAnalytics: $logAnalytics, + containerRegistry: $containerRegistry, + keyVault: $keyVault + }') +else + echo -e "${CYAN}[6/7] Skipping shared services discovery (--skip-shared-services)${NC}" +fi +echo "" + +# ───────────────────────────────────────────────────────────────────────────── +# Landing Zone Detection & Confidence Scoring +# ───────────────────────────────────────────────────────────────────────────── +# Scores the discovered topology against canonical Azure Landing Zone (ALZ) +# accelerator signals: https://azure.github.io/Azure-Landing-Zones/accelerator/ +# +# Weighted signals (max 100): +# Top-level MGs (Platform/Landing zones/Sandbox/Decommissioned) 0–30 +# Platform children (Connectivity/Identity/Management) 0–20 +# Landing-zone archetypes (Corp + Online) 0 or 10 +# Platform subscriptions 0–10 +# Hub-spoke topology 0 or 5 +# Hub VNet in a connectivity-classified subscription 0 or 5 +# Canonical ALZ policy assignment names 0–15 +# +# Confidence buckets: high â‰Ĩ 70, medium â‰Ĩ 40, low â‰Ĩ 10, none < 10. +# isLandingZone = (confidenceScore â‰Ĩ 40). +echo -e "${CYAN}[7/7] Scoring landing zone confidence...${NC}" + +DETECTION_JSON=$(jq -n \ + --argjson mgHierarchy "$MG_HIERARCHY" \ + --argjson platformSubs "$PLATFORM_SUBS" \ + --argjson lzSubs "$LZ_SUBS" \ + --argjson networking "$NETWORKING_JSON" \ + --argjson policies "$POLICIES_JSON" \ + ' + ($mgHierarchy | map(.role // "other")) as $roles | + { + hasPlatform: ($roles | any(. == "platform")), + hasLandingZones: ($roles | any(. == "landing-zones")), + hasSandbox: ($roles | any(. == "sandbox")), + hasDecommissioned: ($roles | any(. == "decommissioned")), + hasConnectivity: ($roles | any(. == "connectivity")), + hasIdentity: ($roles | any(. == "identity")), + hasManagement: ($roles | any(. == "management")), + hasCorp: ($roles | any(. == "corp")), + hasOnline: ($roles | any(. == "online")) + } as $f | + + ([$f.hasPlatform, $f.hasLandingZones, $f.hasSandbox, $f.hasDecommissioned] + | map(select(.)) | length) as $topLevel | + ([$f.hasConnectivity, $f.hasIdentity, $f.hasManagement] + | map(select(.)) | length) as $platChildren | + ($platformSubs | length) as $platSubCount | + ($networking.topology == "hub-spoke") as $hasHubSpoke | + ($networking.hubs // []) as $hubs | + ([$platformSubs[]? | select(.role == "connectivity") | .id]) as $connSubIds | + ($hubs | any(.subscription as $h | $connSubIds | any(. == $h))) as $hubInConn | + (($policies.alzCanonicalAssignments // []) | length) as $alzPolCount | + (($policies.alzCanonicalAssignments // []) | sort) as $alzPolList | + + # Points per signal + (if $topLevel == 4 then 30 + elif $topLevel == 3 then 20 + elif $topLevel == 2 then 10 + else 0 end) as $ptsTop | + (if $platChildren == 3 then 20 + elif $platChildren == 2 then 10 + else 0 end) as $ptsChildren | + (if $f.hasCorp and $f.hasOnline then 10 else 0 end) as $ptsArchetypes | + (if $platSubCount >= 3 then 10 + elif $platSubCount >= 1 then 5 + else 0 end) as $ptsPlatSubs | + (if $hasHubSpoke then 5 else 0 end) as $ptsHubSpoke | + (if $hubInConn then 5 else 0 end) as $ptsHubInConn | + ([$alzPolCount * 5, 15] | min) as $ptsAlzPols | + + ($ptsTop + $ptsChildren + $ptsArchetypes + $ptsPlatSubs + + $ptsHubSpoke + $ptsHubInConn + $ptsAlzPols) as $score | + + (if $score >= 70 then "high" + elif $score >= 40 then "medium" + elif $score >= 10 then "low" + else "none" end) as $confidence | + + { + isLandingZone: ($score >= 40), + confidence: $confidence, + confidenceScore: $score, + reference: "https://azure.github.io/Azure-Landing-Zones/accelerator/", + matchedSignals: [ + (if $topLevel > 0 then { + signal: "alz-top-level-mgs", points: $ptsTop, + evidence: "\($topLevel)/4 canonical top-level MGs (Platform, Landing zones, Sandbox, Decommissioned)" + } else empty end), + (if $platChildren > 0 then { + signal: "platform-children", points: $ptsChildren, + evidence: "\($platChildren)/3 platform children (Connectivity, Identity, Management)" + } else empty end), + (if $f.hasCorp and $f.hasOnline then { + signal: "alz-lz-archetypes", points: $ptsArchetypes, + evidence: "Corp and Online MGs present under Landing zones" + } else empty end), + (if $platSubCount > 0 then { + signal: "platform-subscriptions", points: $ptsPlatSubs, + evidence: "\($platSubCount) platform subscription(s) classified" + } else empty end), + (if $hasHubSpoke then { + signal: "hub-spoke-topology", points: $ptsHubSpoke, + evidence: "Hub VNet(s): \([$hubs[]?.name] | join(", "))" + } else empty end), + (if $hubInConn then { + signal: "hub-in-connectivity-sub", points: $ptsHubInConn, + evidence: "Hub VNet sits in a connectivity-classified subscription" + } else empty end), + (if $alzPolCount > 0 then { + signal: "alz-canonical-policies", points: $ptsAlzPols, + evidence: "\($alzPolCount) canonical ALZ policy assignment(s): \($alzPolList | join(", "))" + } else empty end) + ], + missingSignals: [ + (if $topLevel < 4 then "alz-top-level-mgs (\($topLevel)/4)" else empty end), + (if $platChildren < 3 then "platform-children (\($platChildren)/3)" else empty end), + (if ($f.hasCorp and $f.hasOnline | not) then "alz-lz-archetypes (Corp/Online MGs)" else empty end), + (if $platSubCount < 3 then "platform-subscriptions (\($platSubCount)/3+)" else empty end), + (if ($hasHubSpoke | not) then "hub-spoke-topology" else empty end), + (if ($hubInConn | not) then "hub-in-connectivity-sub" else empty end), + (if $alzPolCount == 0 then "alz-canonical-policies" else empty end) + ], + checks: { + topLevelMgs: { + platform: $f.hasPlatform, + landingZones: $f.hasLandingZones, + sandbox: $f.hasSandbox, + decommissioned: $f.hasDecommissioned + }, + platformChildren: { + connectivity: $f.hasConnectivity, + identity: $f.hasIdentity, + management: $f.hasManagement + }, + lzChildren: { corp: $f.hasCorp, online: $f.hasOnline }, + platformSubscriptionCount: $platSubCount, + hubSpoke: $hasHubSpoke, + hubInConnectivitySubscription: $hubInConn, + knownAlzPolicies: $alzPolList + } + } + ') + +DET_CONF=$(echo "$DETECTION_JSON" | jq -r '.confidence') +DET_SCORE=$(echo "$DETECTION_JSON" | jq -r '.confidenceScore') +case "$DET_CONF" in + high) echo -e " Landing zone detection: ${GREEN}high${NC} ($DET_SCORE/100) — canonical ALZ deployment" ;; + medium) echo -e " Landing zone detection: ${GREEN}medium${NC} ($DET_SCORE/100) — partial ALZ alignment" ;; + low) echo -e " Landing zone detection: ${YELLOW}low${NC} ($DET_SCORE/100) — some LZ signals" ;; + *) echo -e " Landing zone detection: ${YELLOW}none${NC} ($DET_SCORE/100) — no canonical signals" ;; +esac + +if [[ "$VERBOSE" == "true" ]]; then + echo " Matched signals:" + echo "$DETECTION_JSON" | jq -r '.matchedSignals[] | " + \(.points) pts — \(.signal): \(.evidence)"' + MISSING_COUNT=$(echo "$DETECTION_JSON" | jq '.missingSignals | length') + if [[ "$MISSING_COUNT" -gt 0 ]]; then + echo " Missing signals:" + echo "$DETECTION_JSON" | jq -r '.missingSignals[] | " - \(.)"' + fi +fi +echo "" + +# ───────────────────────────────────────────────────────────────────────────── +# Assemble Output +# ───────────────────────────────────────────────────────────────────────────── +echo -e "${BLUE}Assembling landing zone context...${NC}" + +MG_SECTION=$(jq -n \ + --arg root "$MG_ROOT" \ + --argjson hierarchy "$MG_HIERARCHY" \ + --argjson hasMG "$HAS_MANAGEMENT_GROUPS" \ + '{ + root: $root, + hasManagementGroups: $hasMG, + hierarchy: $hierarchy + }') + +SUBS_SECTION=$(jq -n \ + --argjson platform "$PLATFORM_SUBS" \ + --argjson landingZones "$LZ_SUBS" \ + '{ + platform: $platform, + landingZones: $landingZones + }') + +CONTEXT_JSON=$(jq -n \ + --arg discoveredAt "$DISCOVERY_TIMESTAMP" \ + --arg discoveryMethod "auto" \ + --argjson managementGroups "$MG_SECTION" \ + --argjson subscriptions "$SUBS_SECTION" \ + --argjson sharedServices "$SHARED_SERVICES_JSON" \ + --argjson networking "$NETWORKING_JSON" \ + --argjson policies "$POLICIES_JSON" \ + --argjson currentIdentity "$IDENTITY_JSON" \ + --argjson landingZoneDetection "$DETECTION_JSON" \ + '{ + discoveredAt: $discoveredAt, + discoveryMethod: $discoveryMethod, + landingZoneDetection: $landingZoneDetection, + managementGroups: $managementGroups, + subscriptions: $subscriptions, + sharedServices: $sharedServices, + networking: $networking, + policies: $policies, + currentIdentity: $currentIdentity + }') + +# ───────────────────────────────────────────────────────────────────────────── +# Output +# ───────────────────────────────────────────────────────────────────────────── + +if [[ "$OUTPUT_FORMAT" == "markdown" ]]; then + # Markdown output + OUTPUT="" + OUTPUT+="# Landing Zone Discovery Report\n\n" + OUTPUT+="**Discovered:** $DISCOVERY_TIMESTAMP\n" + OUTPUT+="**User:** $CURRENT_USER\n" + OUTPUT+="**Tenant:** $CURRENT_TENANT_ID\n\n" + + OUTPUT+="## Management Groups\n\n" + if [[ "$HAS_MANAGEMENT_GROUPS" == "true" ]]; then + OUTPUT+="Root: $MG_ROOT\n\n" + OUTPUT+="| Management Group | Role | ID |\n" + OUTPUT+="|------------------|------|----|\n" + while IFS= read -r line; do + MG_NAME=$(echo "$line" | jq -r '.displayName') + MG_ROLE=$(echo "$line" | jq -r '.role') + MG_ID=$(echo "$line" | jq -r '.name') + OUTPUT+="| $MG_NAME | $MG_ROLE | $MG_ID |\n" + done <<< "$(echo "$MG_HIERARCHY" | jq -c '.[]')" + else + OUTPUT+="No management groups found (flat subscription model)\n" + fi + OUTPUT+="\n" + + OUTPUT+="## Subscriptions\n\n" + OUTPUT+="### Platform\n\n" + PLATFORM_COUNT=$(echo "$PLATFORM_SUBS" | jq 'length') + if [[ "$PLATFORM_COUNT" -gt 0 ]]; then + OUTPUT+="| Name | Role | MG Path |\n" + OUTPUT+="|------|------|---------|\n" + while IFS= read -r line; do + S_NAME=$(echo "$line" | jq -r '.name') + S_ROLE=$(echo "$line" | jq -r '.role') + S_MG=$(echo "$line" | jq -r '.mgPath // "N/A"') + OUTPUT+="| $S_NAME | $S_ROLE | $S_MG |\n" + done <<< "$(echo "$PLATFORM_SUBS" | jq -c '.[]')" + else + OUTPUT+="No platform subscriptions found\n" + fi + OUTPUT+="\n" + + OUTPUT+="### Landing Zones\n\n" + LZ_COUNT=$(echo "$LZ_SUBS" | jq 'length') + if [[ "$LZ_COUNT" -gt 0 ]]; then + OUTPUT+="| Name | Environment | MG Path |\n" + OUTPUT+="|------|-------------|---------|\n" + while IFS= read -r line; do + S_NAME=$(echo "$line" | jq -r '.name') + S_ENV=$(echo "$line" | jq -r '.environment // "N/A"') + S_MG=$(echo "$line" | jq -r '.mgPath // "N/A"') + OUTPUT+="| $S_NAME | $S_ENV | $S_MG |\n" + done <<< "$(echo "$LZ_SUBS" | jq -c '.[]')" + else + OUTPUT+="No landing zone subscriptions found\n" + fi + OUTPUT+="\n" + + if [[ -n "$OUTPUT_FILE" ]]; then + echo -e "$OUTPUT" > "$OUTPUT_FILE" + else + echo -e "$OUTPUT" + fi +else + # JSON output (default) + if [[ -n "$OUTPUT_FILE" ]]; then + # Ensure output directory exists + mkdir -p "$(dirname "$OUTPUT_FILE")" + echo "$CONTEXT_JSON" | jq '.' > "$OUTPUT_FILE" + echo -e "${GREEN}Landing zone context saved to: $OUTPUT_FILE${NC}" + else + echo "$CONTEXT_JSON" | jq '.' + fi +fi + +echo "" + +# Summary +echo -e "${BLUE}Discovery Summary:${NC}" +echo -e " Management Groups: $(if [[ "$HAS_MANAGEMENT_GROUPS" == "true" ]]; then echo -e "${GREEN}$(echo "$MG_HIERARCHY" | jq 'length') found${NC}"; else echo -e "${YELLOW}none (flat model)${NC}"; fi)" +echo -e " Platform Subscriptions: ${GREEN}$(echo "$PLATFORM_SUBS" | jq 'length')${NC}" +echo -e " Landing Zone Subscriptions: ${GREEN}$(echo "$LZ_SUBS" | jq 'length')${NC}" +echo -e " Network Topology: $(if [[ "$SKIP_NETWORK" == "true" ]]; then echo "skipped"; else echo "$TOPOLOGY"; fi)" +echo -e " Policy Assignments: $(if [[ "$SKIP_POLICIES" == "true" ]]; then echo "skipped"; else echo "$(echo "$POLICIES_JSON" | jq '.denyEffects | length') deny-effect"; fi)" +echo "" + +if [[ "$PARTIAL_FAILURE" == "true" ]]; then + echo -e "${YELLOW}âš ī¸ Partial discovery — some targets could not be reached. Results are still usable.${NC}" + echo -e "${YELLOW} Consider manual injection for missing data: .github/skills/azure-landing-zone-discovery/scripts/inject-lz.sh${NC}" + exit 1 +fi + +echo -e "${GREEN}✅ Landing zone discovery complete${NC}" +exit 0 diff --git a/.github/skills/azure-landing-zone-discovery/scripts/inject-lz.ps1 b/.github/skills/azure-landing-zone-discovery/scripts/inject-lz.ps1 new file mode 100644 index 0000000..2d242e7 --- /dev/null +++ b/.github/skills/azure-landing-zone-discovery/scripts/inject-lz.ps1 @@ -0,0 +1,318 @@ +#!/usr/bin/env pwsh +# Azure Landing Zone Manual Injection Script (PowerShell) +# Creates or updates .azure/landing-zone-context.json from user-provided values +# Use when auto-discovery is not possible (cross-tenant, limited RBAC, air-gapped) +# +# PowerShell parity port of inject-lz.sh. Produces an identical +# landing-zone-context.json schema. + +[CmdletBinding()] +param( + [string]$HubVnetId = "", + [string]$LogAnalyticsId = "", + [string]$AcrId = "", + [string]$KeyVaultId = "", + [string]$AllowedLocations = "", + [string]$RequiredTags = "", + [switch]$DenyPublicIp, + [switch]$DenyPublicStorage, + [string]$OutputFile = ".azure/landing-zone-context.json", + [switch]$Merge, + [switch]$Help +) + +$ErrorActionPreference = 'Stop' + +function Show-Usage { + @" +Azure Landing Zone Manual Injection Script (PowerShell) + +Creates or updates .azure/landing-zone-context.json from user-provided values. +Use when auto-discovery is not possible (cross-tenant, limited RBAC, air-gapped). + +Usage: ./inject-lz.ps1 [OPTIONS] + +Options: + -HubVnetId Azure resource ID of the hub VNet + -LogAnalyticsId Azure resource ID of the shared Log Analytics workspace + -AcrId Azure resource ID of the shared Container Registry + -KeyVaultId Azure resource ID of the shared Key Vault + -AllowedLocations Comma-separated list of allowed Azure regions + -RequiredTags Comma-separated list of required tag names + -DenyPublicIp Flag: public IPs are denied by policy + -DenyPublicStorage Flag: public storage access is denied by policy + -OutputFile Output file path (default: .azure/landing-zone-context.json) + -Merge Merge with existing context file instead of replacing + -Help Show this help message + +Examples: + # Inject hub VNet and Log Analytics + ./inject-lz.ps1 -HubVnetId "/subscriptions/.../providers/Microsoft.Network/virtualNetworks/vnet-hub" ` + -LogAnalyticsId "/subscriptions/.../providers/Microsoft.OperationalInsights/workspaces/log-central" + + # Inject policy constraints + ./inject-lz.ps1 -AllowedLocations "eastus,westus2,westeurope" ` + -RequiredTags "Environment,Project,CostCenter" -DenyPublicIp + + # Merge with existing discovery + ./inject-lz.ps1 -Merge -AcrId "/subscriptions/.../providers/Microsoft.ContainerRegistry/registries/crshared" +"@ | Write-Host + exit 1 +} + +if ($Help) { Show-Usage } + +# Check if at least one value was provided +if (-not $HubVnetId -and -not $LogAnalyticsId -and -not $AcrId -and ` + -not $KeyVaultId -and -not $AllowedLocations -and -not $RequiredTags -and ` + -not $DenyPublicIp -and -not $DenyPublicStorage) { + Write-Host "Error: At least one landing zone parameter must be provided" -ForegroundColor Red + Write-Host "" + Show-Usage +} + +$InjectionTimestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") + +Write-Host "Injecting landing zone context..." -ForegroundColor Blue +Write-Host "" + +# ───────────────────────────────────────────────────────────────────────────── +# Helpers: extract name / subscription / location hint from a resource ID +# ───────────────────────────────────────────────────────────────────────────── +function Get-ResourceName { + param([string]$Id) + return ($Id -split '/')[-1] +} + +function Get-ResourceSubscription { + param([string]$Id) + if ($Id -match 'subscriptions/([^/]+)') { return $matches[1] } + return "" +} + +function Get-LocationFromName { + param([string]$Name) + $locations = @( + "eastus", "eastus2", "westus", "westus2", "westus3", "centralus", + "northcentralus", "southcentralus", "westeurope", "northeurope", + "uksouth", "ukwest", "southeastasia", "eastasia", "australiaeast", + "japaneast", "brazilsouth", "canadacentral", "francecentral", + "germanywestcentral", "norwayeast", "switzerlandnorth" + ) + foreach ($loc in $locations) { + if ($Name -match "(?i)$loc") { return $loc } + } + return "unknown" +} + +# ───────────────────────────────────────────────────────────────────────────── +# Build shared services section +# ───────────────────────────────────────────────────────────────────────────── +$SharedServices = [ordered]@{} + +$VnetName = ""; $VnetSub = ""; $VnetLoc = "" +if ($HubVnetId) { + $VnetName = Get-ResourceName $HubVnetId + $VnetSub = Get-ResourceSubscription $HubVnetId + $VnetLoc = Get-LocationFromName $VnetName + Write-Host " Hub VNet: " -NoNewline; Write-Host "$VnetName" -ForegroundColor Green -NoNewline; Write-Host " (sub: $VnetSub)" +} + +if ($LogAnalyticsId) { + $laName = Get-ResourceName $LogAnalyticsId + $SharedServices['logAnalytics'] = [ordered]@{ + id = $LogAnalyticsId + name = $laName + subscription = (Get-ResourceSubscription $LogAnalyticsId) + location = (Get-LocationFromName $laName) + } + Write-Host " Log Analytics: " -NoNewline; Write-Host "$laName" -ForegroundColor Green +} + +if ($AcrId) { + $acrName = Get-ResourceName $AcrId + $SharedServices['containerRegistry'] = [ordered]@{ + id = $AcrId + name = $acrName + subscription = (Get-ResourceSubscription $AcrId) + location = (Get-LocationFromName $acrName) + } + Write-Host " Container Registry: " -NoNewline; Write-Host "$acrName" -ForegroundColor Green +} + +if ($KeyVaultId) { + $kvName = Get-ResourceName $KeyVaultId + $SharedServices['keyVault'] = [ordered]@{ + id = $KeyVaultId + name = $kvName + subscription = (Get-ResourceSubscription $KeyVaultId) + location = (Get-LocationFromName $kvName) + } + Write-Host " Key Vault: " -NoNewline; Write-Host "$kvName" -ForegroundColor Green +} + +# ───────────────────────────────────────────────────────────────────────────── +# Build networking section +# ───────────────────────────────────────────────────────────────────────────── +$Networking = [ordered]@{ + topology = "unknown" + hubs = @() + privateDnsZones = @() + peerings = @() +} + +if ($HubVnetId) { + $Networking['topology'] = "hub-spoke" + $Networking['hubs'] = @( + [ordered]@{ + id = $HubVnetId + name = $VnetName + subscription = $VnetSub + location = $VnetLoc + addressPrefixes = @() + } + ) +} + +# ───────────────────────────────────────────────────────────────────────────── +# Build policies section +# ───────────────────────────────────────────────────────────────────────────── +$DenyEffects = [System.Collections.ArrayList]::new() + +if ($DenyPublicIp) { + [void]$DenyEffects.Add([ordered]@{ + name = "Deny-Public-IP" + scope = "manual-injection" + impact = "Blocks public IP creation" + }) + Write-Host " Policy: " -NoNewline; Write-Host "Deny-Public-IP" -ForegroundColor Yellow +} + +if ($DenyPublicStorage) { + [void]$DenyEffects.Add([ordered]@{ + name = "Deny-Storage-Public-Access" + scope = "manual-injection" + impact = "Blocks public storage access" + }) + Write-Host " Policy: " -NoNewline; Write-Host "Deny-Storage-Public-Access" -ForegroundColor Yellow +} + +$LocationsArr = @() +if ($AllowedLocations) { + $LocationsArr = @($AllowedLocations -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + Write-Host " Allowed locations: " -NoNewline; Write-Host "$AllowedLocations" -ForegroundColor Green +} + +$TagsArr = @() +if ($RequiredTags) { + $TagsArr = @($RequiredTags -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + Write-Host " Required tags: " -NoNewline; Write-Host "$RequiredTags" -ForegroundColor Green +} + +$Policies = [ordered]@{ + denyEffects = @($DenyEffects) + auditEffects = @() + allowedLocations = @($LocationsArr) + requiredTags = @($TagsArr) +} + +# ───────────────────────────────────────────────────────────────────────────── +# Assemble context object +# ───────────────────────────────────────────────────────────────────────────── +Write-Host "" + +$NewContext = [ordered]@{ + discoveredAt = $InjectionTimestamp + discoveryMethod = "manual" + managementGroups = [ordered]@{ root = ""; hasManagementGroups = $false; hierarchy = @() } + subscriptions = [ordered]@{ platform = @(); landingZones = @() } + sharedServices = $SharedServices + networking = $Networking + policies = $Policies + currentIdentity = [ordered]@{ + user = "manual-injection" + tenantId = "" + currentSubscription = [ordered]@{ id = ""; name = "" } + roles = @() + } +} + +# ───────────────────────────────────────────────────────────────────────────── +# Merge with existing context if requested +# ───────────────────────────────────────────────────────────────────────────── +function Get-Prop { + param($Object, [string]$Name, $Default = $null) + if ($null -ne $Object -and $Object.PSObject.Properties.Name -contains $Name) { + return $Object.$Name + } + return $Default +} + +$FinalContext = $NewContext + +if ($Merge -and (Test-Path $OutputFile)) { + Write-Host "Merging with existing context file..." -ForegroundColor Blue + $existing = Get-Content $OutputFile -Raw | ConvertFrom-Json + + # sharedServices: existing merged with new (new keys override) + $mergedShared = [ordered]@{} + $existingShared = Get-Prop $existing 'sharedServices' + if ($existingShared) { + foreach ($p in $existingShared.PSObject.Properties) { $mergedShared[$p.Name] = $p.Value } + } + foreach ($k in $SharedServices.Keys) { $mergedShared[$k] = $SharedServices[$k] } + + # networking: use new only when it carries a concrete topology + $existingNet = Get-Prop $existing 'networking' + $mergedNet = if ($Networking.topology -ne "unknown") { $Networking } else { $existingNet } + + # policies: union deny/audit by name, prefer new allowedLocations when set, + # union requiredTags + $existingPol = Get-Prop $existing 'policies' + $exDeny = @(Get-Prop $existingPol 'denyEffects' @()) + $exAudit = @(Get-Prop $existingPol 'auditEffects' @()) + $exLoc = @(Get-Prop $existingPol 'allowedLocations' @()) + $exTags = @(Get-Prop $existingPol 'requiredTags' @()) + + $unionDeny = @($exDeny + @($DenyEffects)) | Group-Object -Property name | ForEach-Object { $_.Group[0] } + $unionAudit = @($exAudit) | Group-Object -Property name | ForEach-Object { $_.Group[0] } + $finalLoc = if ($LocationsArr.Count -gt 0) { @($LocationsArr) } else { @($exLoc) } + $finalTags = @($exTags + $TagsArr) | Select-Object -Unique + + $mergedPolicies = [ordered]@{ + denyEffects = @($unionDeny) + auditEffects = @($unionAudit) + allowedLocations = @($finalLoc) + requiredTags = @($finalTags) + } + + $FinalContext = [ordered]@{ + discoveredAt = $InjectionTimestamp + discoveryMethod = "merged" + managementGroups = (Get-Prop $existing 'managementGroups' $NewContext.managementGroups) + subscriptions = (Get-Prop $existing 'subscriptions' $NewContext.subscriptions) + sharedServices = $mergedShared + networking = $mergedNet + policies = $mergedPolicies + currentIdentity = (Get-Prop $existing 'currentIdentity' $NewContext.currentIdentity) + } + # Preserve landingZoneDetection from an existing auto-discovery if present + $existingDetection = Get-Prop $existing 'landingZoneDetection' + if ($existingDetection) { $FinalContext['landingZoneDetection'] = $existingDetection } +} + +# ───────────────────────────────────────────────────────────────────────────── +# Write output +# ───────────────────────────────────────────────────────────────────────────── +$OutputDir = Split-Path -Parent $OutputFile +if ($OutputDir -and -not (Test-Path $OutputDir)) { + New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null +} + +$FinalContext | ConvertTo-Json -Depth 20 | Set-Content -Path $OutputFile -Encoding utf8 + +Write-Host "✅ Landing zone context saved to: $OutputFile" -ForegroundColor Green +Write-Host "" +Write-Host "To verify: " -NoNewline; Write-Host "Get-Content $OutputFile | ConvertFrom-Json" -ForegroundColor Blue +Write-Host "To merge with auto-discovery: " -NoNewline; Write-Host ".github/skills/azure-landing-zone-discovery/scripts/discover-lz.ps1 -OutputFile $OutputFile" -ForegroundColor Blue +exit 0 diff --git a/.github/skills/azure-landing-zone-discovery/scripts/inject-lz.sh b/.github/skills/azure-landing-zone-discovery/scripts/inject-lz.sh new file mode 100755 index 0000000..59be6ec --- /dev/null +++ b/.github/skills/azure-landing-zone-discovery/scripts/inject-lz.sh @@ -0,0 +1,329 @@ +#!/bin/bash +# Azure Landing Zone Manual Injection Script +# Creates or updates .azure/landing-zone-context.json from user-provided values +# Use when auto-discovery is not possible (cross-tenant, limited RBAC, air-gapped) + +set -euo pipefail + +# Color codes +RED='\033[0;31m' +YELLOW='\033[1;33m' +GREEN='\033[0;32m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Default values +HUB_VNET_ID="" +LOG_ANALYTICS_ID="" +ACR_ID="" +KEY_VAULT_ID="" +ALLOWED_LOCATIONS="" +REQUIRED_TAGS="" +DENY_PUBLIC_IP=false +DENY_PUBLIC_STORAGE=false +OUTPUT_FILE=".azure/landing-zone-context.json" +MERGE_MODE=false + +usage() { + cat < Azure resource ID of the hub VNet + --log-analytics-id Azure resource ID of the shared Log Analytics workspace + --acr-id Azure resource ID of the shared Container Registry + --key-vault-id Azure resource ID of the shared Key Vault + --allowed-locations Comma-separated list of allowed Azure regions + --required-tags Comma-separated list of required tag names + --deny-public-ip Flag: public IPs are denied by policy + --deny-public-storage Flag: public storage access is denied by policy + --output-file Output file path (default: .azure/landing-zone-context.json) + --merge Merge with existing context file instead of replacing + -h, --help Show this help message + +Examples: + # Inject hub VNet and Log Analytics + $0 --hub-vnet-id "/subscriptions/.../providers/Microsoft.Network/virtualNetworks/vnet-hub" \\ + --log-analytics-id "/subscriptions/.../providers/Microsoft.OperationalInsights/workspaces/log-central" + + # Inject policy constraints + $0 --allowed-locations "eastus,westus2,westeurope" \\ + --required-tags "Environment,Project,CostCenter" \\ + --deny-public-ip + + # Merge with existing discovery + $0 --merge --acr-id "/subscriptions/.../providers/Microsoft.ContainerRegistry/registries/crshared" + +EOF + exit 1 +} + +# Parse arguments +while [[ $# -gt 0 ]]; do + case $1 in + --hub-vnet-id) + HUB_VNET_ID="$2" + shift 2 + ;; + --log-analytics-id) + LOG_ANALYTICS_ID="$2" + shift 2 + ;; + --acr-id) + ACR_ID="$2" + shift 2 + ;; + --key-vault-id) + KEY_VAULT_ID="$2" + shift 2 + ;; + --allowed-locations) + ALLOWED_LOCATIONS="$2" + shift 2 + ;; + --required-tags) + REQUIRED_TAGS="$2" + shift 2 + ;; + --deny-public-ip) + DENY_PUBLIC_IP=true + shift + ;; + --deny-public-storage) + DENY_PUBLIC_STORAGE=true + shift + ;; + --output-file) + OUTPUT_FILE="$2" + shift 2 + ;; + --merge) + MERGE_MODE=true + shift + ;; + -h|--help) + usage + ;; + *) + echo "Unknown option: $1" + usage + ;; + esac +done + +# Check if at least one value was provided +if [[ -z "$HUB_VNET_ID" ]] && [[ -z "$LOG_ANALYTICS_ID" ]] && [[ -z "$ACR_ID" ]] && \ + [[ -z "$KEY_VAULT_ID" ]] && [[ -z "$ALLOWED_LOCATIONS" ]] && [[ -z "$REQUIRED_TAGS" ]] && \ + [[ "$DENY_PUBLIC_IP" == "false" ]] && [[ "$DENY_PUBLIC_STORAGE" == "false" ]]; then + echo -e "${RED}Error: At least one landing zone parameter must be provided${NC}" + echo "" + usage +fi + +INJECTION_TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ) + +echo -e "${BLUE}Injecting landing zone context...${NC}" +echo "" + +# ───────────────────────────────────────────────────────────────────────────── +# Helper: extract name and subscription from resource ID +# ───────────────────────────────────────────────────────────────────────────── +extract_name() { + echo "$1" | grep -oE '[^/]+$' +} + +extract_subscription() { + echo "$1" | grep -oE 'subscriptions/[^/]+' | cut -d/ -f2 +} + +extract_location_from_name() { + # Try to extract location hint from resource name (e.g., vnet-hub-eastus) + local name="$1" + local locations=("eastus" "eastus2" "westus" "westus2" "westus3" "centralus" "northcentralus" "southcentralus" "westeurope" "northeurope" "uksouth" "ukwest" "southeastasia" "eastasia" "australiaeast" "japaneast" "brazilsouth" "canadacentral" "francecentral" "germanywestcentral" "norwayeast" "switzerlandnorth") + for loc in "${locations[@]}"; do + if echo "$name" | grep -qi "$loc"; then + echo "$loc" + return + fi + done + echo "unknown" +} + +# ───────────────────────────────────────────────────────────────────────────── +# Build shared services section +# ───────────────────────────────────────────────────────────────────────────── +SHARED_SERVICES='{}' + +if [[ -n "$HUB_VNET_ID" ]]; then + VNET_NAME=$(extract_name "$HUB_VNET_ID") + VNET_SUB=$(extract_subscription "$HUB_VNET_ID") + VNET_LOC=$(extract_location_from_name "$VNET_NAME") + echo -e " Hub VNet: ${GREEN}$VNET_NAME${NC} (sub: $VNET_SUB)" +fi + +if [[ -n "$LOG_ANALYTICS_ID" ]]; then + LA_NAME=$(extract_name "$LOG_ANALYTICS_ID") + LA_SUB=$(extract_subscription "$LOG_ANALYTICS_ID") + LA_LOC=$(extract_location_from_name "$LA_NAME") + SHARED_SERVICES=$(echo "$SHARED_SERVICES" | jq \ + --arg id "$LOG_ANALYTICS_ID" \ + --arg name "$LA_NAME" \ + --arg sub "$LA_SUB" \ + --arg loc "$LA_LOC" \ + '. + { logAnalytics: { id: $id, name: $name, subscription: $sub, location: $loc } }') + echo -e " Log Analytics: ${GREEN}$LA_NAME${NC}" +fi + +if [[ -n "$ACR_ID" ]]; then + ACR_NAME=$(extract_name "$ACR_ID") + ACR_SUB=$(extract_subscription "$ACR_ID") + ACR_LOC=$(extract_location_from_name "$ACR_NAME") + SHARED_SERVICES=$(echo "$SHARED_SERVICES" | jq \ + --arg id "$ACR_ID" \ + --arg name "$ACR_NAME" \ + --arg sub "$ACR_SUB" \ + --arg loc "$ACR_LOC" \ + '. + { containerRegistry: { id: $id, name: $name, subscription: $sub, location: $loc } }') + echo -e " Container Registry: ${GREEN}$ACR_NAME${NC}" +fi + +if [[ -n "$KEY_VAULT_ID" ]]; then + KV_NAME=$(extract_name "$KEY_VAULT_ID") + KV_SUB=$(extract_subscription "$KEY_VAULT_ID") + KV_LOC=$(extract_location_from_name "$KV_NAME") + SHARED_SERVICES=$(echo "$SHARED_SERVICES" | jq \ + --arg id "$KEY_VAULT_ID" \ + --arg name "$KV_NAME" \ + --arg sub "$KV_SUB" \ + --arg loc "$KV_LOC" \ + '. + { keyVault: { id: $id, name: $name, subscription: $sub, location: $loc } }') + echo -e " Key Vault: ${GREEN}$KV_NAME${NC}" +fi + +# ───────────────────────────────────────────────────────────────────────────── +# Build networking section +# ───────────────────────────────────────────────────────────────────────────── +NETWORKING='{"topology":"unknown","hubs":[],"privateDnsZones":[],"peerings":[]}' + +if [[ -n "$HUB_VNET_ID" ]]; then + NETWORKING=$(echo "$NETWORKING" | jq \ + --arg topology "hub-spoke" \ + --arg id "$HUB_VNET_ID" \ + --arg name "$VNET_NAME" \ + --arg sub "$VNET_SUB" \ + --arg loc "$VNET_LOC" \ + '.topology = $topology | .hubs = [{ id: $id, name: $name, subscription: $sub, location: $loc, addressPrefixes: [] }]') +fi + +# ───────────────────────────────────────────────────────────────────────────── +# Build policies section +# ───────────────────────────────────────────────────────────────────────────── +DENY_EFFECTS="[]" +LOCATIONS_JSON="[]" +TAGS_JSON="[]" + +if [[ "$DENY_PUBLIC_IP" == "true" ]]; then + DENY_EFFECTS=$(echo "$DENY_EFFECTS" | jq '. += [{ + "name": "Deny-Public-IP", + "scope": "manual-injection", + "impact": "Blocks public IP creation" + }]') + echo -e " Policy: ${YELLOW}Deny-Public-IP${NC}" +fi + +if [[ "$DENY_PUBLIC_STORAGE" == "true" ]]; then + DENY_EFFECTS=$(echo "$DENY_EFFECTS" | jq '. += [{ + "name": "Deny-Storage-Public-Access", + "scope": "manual-injection", + "impact": "Blocks public storage access" + }]') + echo -e " Policy: ${YELLOW}Deny-Storage-Public-Access${NC}" +fi + +if [[ -n "$ALLOWED_LOCATIONS" ]]; then + LOCATIONS_JSON=$(echo "$ALLOWED_LOCATIONS" | tr ',' '\n' | jq -R . | jq -s .) + echo -e " Allowed locations: ${GREEN}$ALLOWED_LOCATIONS${NC}" +fi + +if [[ -n "$REQUIRED_TAGS" ]]; then + TAGS_JSON=$(echo "$REQUIRED_TAGS" | tr ',' '\n' | jq -R . | jq -s .) + echo -e " Required tags: ${GREEN}$REQUIRED_TAGS${NC}" +fi + +POLICIES_JSON=$(jq -n \ + --argjson deny "$DENY_EFFECTS" \ + --argjson locations "$LOCATIONS_JSON" \ + --argjson tags "$TAGS_JSON" \ + '{ + denyEffects: $deny, + auditEffects: [], + allowedLocations: $locations, + requiredTags: $tags + }') + +# ───────────────────────────────────────────────────────────────────────────── +# Assemble context file +# ───────────────────────────────────────────────────────────────────────────── +echo "" + +NEW_CONTEXT=$(jq -n \ + --arg discoveredAt "$INJECTION_TIMESTAMP" \ + --arg discoveryMethod "manual" \ + --argjson sharedServices "$SHARED_SERVICES" \ + --argjson networking "$NETWORKING" \ + --argjson policies "$POLICIES_JSON" \ + '{ + discoveredAt: $discoveredAt, + discoveryMethod: $discoveryMethod, + managementGroups: { root: "", hasManagementGroups: false, hierarchy: [] }, + subscriptions: { platform: [], landingZones: [] }, + sharedServices: $sharedServices, + networking: $networking, + policies: $policies, + currentIdentity: { user: "manual-injection", tenantId: "", currentSubscription: { id: "", name: "" }, roles: [] } + }') + +# Merge with existing if requested +if [[ "$MERGE_MODE" == "true" ]] && [[ -f "$OUTPUT_FILE" ]]; then + echo -e "${BLUE}Merging with existing context file...${NC}" + EXISTING=$(cat "$OUTPUT_FILE") + + # Deep merge: new values override existing, arrays are replaced + MERGED=$(echo "$EXISTING" "$NEW_CONTEXT" | jq -s ' + .[0] as $existing | + .[1] as $new | + $existing * { + discoveredAt: $new.discoveredAt, + discoveryMethod: "merged", + sharedServices: ($existing.sharedServices * $new.sharedServices), + networking: ( + if ($new.networking.topology != "unknown") then $new.networking + else $existing.networking end + ), + policies: { + denyEffects: (($existing.policies.denyEffects // []) + ($new.policies.denyEffects // []) | unique_by(.name)), + auditEffects: (($existing.policies.auditEffects // []) + ($new.policies.auditEffects // []) | unique_by(.name)), + allowedLocations: (if ($new.policies.allowedLocations | length) > 0 then $new.policies.allowedLocations else ($existing.policies.allowedLocations // []) end), + requiredTags: (($existing.policies.requiredTags // []) + ($new.policies.requiredTags // []) | unique) + } + } + ') + FINAL_CONTEXT="$MERGED" +else + FINAL_CONTEXT="$NEW_CONTEXT" +fi + +# Write output +mkdir -p "$(dirname "$OUTPUT_FILE")" +echo "$FINAL_CONTEXT" | jq '.' > "$OUTPUT_FILE" + +echo -e "${GREEN}✅ Landing zone context saved to: $OUTPUT_FILE${NC}" +echo "" +echo -e "To verify: ${BLUE}cat $OUTPUT_FILE | jq '.'${NC}" +echo -e "To merge with auto-discovery: ${BLUE}.github/skills/azure-landing-zone-discovery/scripts/discover-lz.sh --output-file $OUTPUT_FILE${NC}" +exit 0 diff --git a/.github/skills/azure-policy-advisor/SKILL.md b/.github/skills/azure-policy-advisor/SKILL.md index 201d748..117ea24 100644 --- a/.github/skills/azure-policy-advisor/SKILL.md +++ b/.github/skills/azure-policy-advisor/SKILL.md @@ -26,6 +26,27 @@ Read compliance preferences from the `## Compliance & Azure Policy` section in ` - **Enforcement mode** (Audit or Deny) - **Policy categories** (identity, networking, storage, compute, monitoring, tagging) +**Also load landing zone context** if `.azure/landing-zone-context.json` exists (produced by `/azure-landing-zone-discovery`): + +```bash +LZ_CONTEXT_FILE=".azure/landing-zone-context.json" +if [[ -f "$LZ_CONTEXT_FILE" ]]; then + LZ_CONFIDENCE=$(jq -r '.landingZoneDetection.confidence // "unknown"' "$LZ_CONTEXT_FILE") + LZ_DENY_EFFECTS=$(jq -r '.policies.denyEffects[]? | .displayName // .name' "$LZ_CONTEXT_FILE") + LZ_AUDIT_EFFECTS=$(jq -r '.policies.auditEffects[]? | .displayName // .name' "$LZ_CONTEXT_FILE") + LZ_CANONICAL=$(jq -r '.policies.alzCanonicalAssignments[]?' "$LZ_CONTEXT_FILE") + LZ_ALLOWED_LOCATIONS=$(jq -r '.policies.allowedLocations[]?' "$LZ_CONTEXT_FILE") + LZ_REQUIRED_TAGS=$(jq -r '.policies.requiredTags[]?' "$LZ_CONTEXT_FILE") +fi +``` + +Use this data in two ways: + +1. **Dedupe**: Any recommendation that matches a name in `denyEffects[]`, `auditEffects[]`, or `alzCanonicalAssignments[]` should be marked `✓ inherited` in Part 2 — do not re-recommend it. +2. **Align defaults**: Use `allowedLocations[]` and `requiredTags[]` to seed Part 1 recommendations (region/tag policies) instead of guessing. + +**Confidence gating:** If `LZ_CONFIDENCE` is `low` or `none`, treat the LZ policy lists as informational. Do not rely on them as the source of truth — the tenant may not actually be ALZ-managed. + If no compliance section exists in copilot-instructions.md, ask the user: ``` diff --git a/.github/skills/git-ape-onboarding/SKILL.md b/.github/skills/git-ape-onboarding/SKILL.md index 71f8150..71d3bf2 100644 --- a/.github/skills/git-ape-onboarding/SKILL.md +++ b/.github/skills/git-ape-onboarding/SKILL.md @@ -109,7 +109,8 @@ OIDC_PREFIX="repository_owner_id::repository_id:" 8. Create GitHub environments and branch policies when permissions allow. 9. Scaffold workflow files and deployment standards into the user's working copy (see below). 10. Capture compliance and Azure Policy preferences (see below). -11. Verify federated credentials, role assignments, and secrets. +11. **Run landing zone discovery** (see Step 11). +12. Verify federated credentials, role assignments, and secrets. ### Step 9: Scaffold workflow files and deployment standards @@ -184,6 +185,26 @@ After RBAC and environment setup, ask the user about compliance requirements and preferences and a suggested patch in chat so the user can apply it. - In all cases, leave changes unstaged and let the user commit them. +### Step 11: Landing Zone Discovery + +Run `/azure-landing-zone-discovery` against each onboarded subscription to populate `.azure/landing-zone-context.json`. This file is consumed by the requirements gatherer, template generator, and policy advisor so deployments are landing-zone-aware from the first run. + +```bash +.github/skills/azure-landing-zone-discovery/scripts/discover-lz.sh \ + --subscription "$AZURE_SUBSCRIPTION_ID" \ + --output-file .azure/landing-zone-context.json +``` + +**Inspect the result:** + +```bash +jq '.landingZoneDetection | {isLandingZone, confidence, confidenceScore}' .azure/landing-zone-context.json +``` + +- `confidence` = `high` or `medium` → commit `.azure/landing-zone-context.json` to the repo so the team shares the same topology view. +- `confidence` = `low` or `none` → the workspace will deploy in standalone mode. Tell the user they can later run `inject-lz.sh` for manual injection if they know the tenant *is* ALZ-managed. +- If discovery fails (e.g., no management group read permission), document the limitation and proceed without the context — the user can re-run discovery once permissions are granted. + ## Safe-Execution Rules 1. Echo target repository and subscription(s) before execution. diff --git a/.gitignore b/.gitignore index 75e4660..daa71a1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .env .azure/* !.azure/deployments/ +!.azure/landing-zone-context.json # Extension build artifacts extension/package.json diff --git a/README.md b/README.md index 0b8e9a6..df73f7b 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,7 @@ Skills are invoked by agents at specific stages. Each skill handles one focused |-------|-------|---------| | **Pre-Deploy** | `/azure-rest-api-reference` | Look up ARM property schemas and API versions. **Mandatory before any template generation.** | | | `/azure-naming-research` | CAF abbreviation lookup, naming constraint validation | +| | `/azure-landing-zone-discovery` | Discover ALZ topology (management groups, platform subscriptions, hub-spoke networking, policy gates) with a confidence score | | | `/azure-resource-availability` | SKU restrictions, version support, API compatibility, quota | | | `/azure-security-analyzer` | Per-resource security assessment with blocking gate | | | `/azure-policy-advisor` | Azure Policy compliance recommendations against CIS, NIST, or general best-practice frameworks | @@ -208,7 +209,7 @@ graph TD U --> S1 S1["Stage 1: Requirements
Requirements Gatherer interviews user"] - SK1["/azure-naming-research
/azure-resource-availability"] + SK1["/azure-naming-research
/azure-resource-availability
/azure-landing-zone-discovery"] S1 -. skills .-> SK1 S1 --> S2 diff --git a/tests/fixtures/landing-zone/README.md b/tests/fixtures/landing-zone/README.md new file mode 100644 index 0000000..1f5c636 --- /dev/null +++ b/tests/fixtures/landing-zone/README.md @@ -0,0 +1,11 @@ +# Landing Zone Discovery — Test Fixtures + +Sanitized, deterministic `.azure/landing-zone-context.json` shapes for use in skill evals and downstream consumer tests. + +| Fixture | Topology | Management groups | Platform subs | Policies | Shared services | Use for | +|---------|----------|-------------------|---------------|----------|------------------|---------| +| [flat-tenant.json](./flat-tenant.json) | `flat` | Tenant Root only | none | none | none | Solo dev / hobby tenant. Verifies the "no LZ but discovery ran" code path. | +| [hub-spoke-tenant.json](./hub-spoke-tenant.json) | `hub-spoke` | Platform + Landing Zones | connectivity / identity / management | 2 deny + 1 audit | LA / ACR / KV | Enterprise ALZ. Verifies platform-subscription warnings, policy gates, shared-service wiring. | +| [skipped-network.json](./skipped-network.json) | `unknown` | none | none | none | none | Discovery ran with `--skip-network` or network discovery failed. Verifies consumers treat `unknown` conservatively. | + +All UUIDs, emails, and resource names are synthetic placeholders. diff --git a/tests/fixtures/landing-zone/flat-tenant.json b/tests/fixtures/landing-zone/flat-tenant.json new file mode 100644 index 0000000..be4aafc --- /dev/null +++ b/tests/fixtures/landing-zone/flat-tenant.json @@ -0,0 +1,84 @@ +{ + "_comment": "Test fixture: a flat tenant (no hub, no shared services, no policies). Use for skill evals and downstream consumer tests. Sanitized from a real discovery run. Expected detection: confidence=none.", + "discoveredAt": "2026-05-11T03:28:33Z", + "discoveryMethod": "auto", + "landingZoneDetection": { + "isLandingZone": false, + "confidence": "none", + "confidenceScore": 0, + "reference": "https://azure.github.io/Azure-Landing-Zones/accelerator/", + "matchedSignals": [], + "missingSignals": [ + "alz-top-level-mgs (0/4)", + "platform-children (0/3)", + "alz-lz-archetypes (Corp/Online MGs)", + "platform-subscriptions (0/3+)", + "hub-spoke-topology", + "hub-in-connectivity-sub", + "alz-canonical-policies" + ], + "checks": { + "topLevelMgs": { "platform": false, "landingZones": false, "sandbox": false, "decommissioned": false }, + "platformChildren": { "connectivity": false, "identity": false, "management": false }, + "lzChildren": { "corp": false, "online": false }, + "platformSubscriptionCount": 0, + "hubSpoke": false, + "hubInConnectivitySubscription": false, + "knownAlzPolicies": [] + } + }, + "managementGroups": { + "root": "Tenant Root Group", + "hasManagementGroups": true, + "hierarchy": [ + { + "id": "/providers/Microsoft.Management/managementGroups/00000000-0000-0000-0000-000000000000", + "name": "00000000-0000-0000-0000-000000000000", + "displayName": "Tenant Root Group", + "role": "root", + "parentId": null + } + ] + }, + "subscriptions": { + "platform": [], + "landingZones": [ + { + "id": "11111111-1111-1111-1111-111111111111", + "name": "example-landing-zone", + "role": "landing-zone", + "mgPath": "", + "environment": "unknown" + } + ] + }, + "sharedServices": { + "logAnalytics": {}, + "containerRegistry": {}, + "keyVault": {} + }, + "networking": { + "topology": "flat", + "hubs": [], + "privateDnsZones": [ + "privatelink.vaultcore.azure.net" + ], + "peerings": [] + }, + "policies": { + "denyEffects": [], + "auditEffects": [], + "allowedLocations": [], + "requiredTags": [], + "alzCanonicalAssignments": [] + }, + "currentIdentity": { + "user": "user@example.com", + "tenantId": "00000000-0000-0000-0000-000000000000", + "currentSubscription": { + "id": "11111111-1111-1111-1111-111111111111", + "name": "example-landing-zone" + }, + "roles": [] + } +} diff --git a/tests/fixtures/landing-zone/hub-spoke-tenant.json b/tests/fixtures/landing-zone/hub-spoke-tenant.json new file mode 100644 index 0000000..13b6347 --- /dev/null +++ b/tests/fixtures/landing-zone/hub-spoke-tenant.json @@ -0,0 +1,96 @@ +{ + "_comment": "Test fixture: enterprise hub-spoke tenant with full canonical ALZ accelerator topology (Platform/Landing zones/Sandbox/Decommissioned + Connectivity/Identity/Management + Corp/Online), platform subscriptions, deny + audit policies, canonical ALZ policy assignments, and shared services. Synthetic — for skill evals and downstream consumer tests. Expected detection: confidence=high.", + "discoveredAt": "2026-04-30T10:00:00Z", + "discoveryMethod": "auto", + "landingZoneDetection": { + "isLandingZone": true, + "confidence": "high", + "confidenceScore": 90, + "reference": "https://azure.github.io/Azure-Landing-Zones/accelerator/", + "matchedSignals": [ + { "signal": "alz-top-level-mgs", "points": 30, "evidence": "4/4 canonical top-level MGs (Platform, Landing zones, Sandbox, Decommissioned)" }, + { "signal": "platform-children", "points": 20, "evidence": "3/3 platform children (Connectivity, Identity, Management)" }, + { "signal": "alz-lz-archetypes", "points": 10, "evidence": "Corp and Online MGs present under Landing zones" }, + { "signal": "platform-subscriptions", "points": 10, "evidence": "3 platform subscription(s) classified" }, + { "signal": "hub-spoke-topology", "points": 5, "evidence": "Hub VNet(s): vnet-hub-eastus" }, + { "signal": "hub-in-connectivity-sub", "points": 5, "evidence": "Hub VNet sits in a connectivity-classified subscription" }, + { "signal": "alz-canonical-policies", "points": 10, "evidence": "2 canonical ALZ policy assignment(s): Deny-PublicIP, Deploy-MDFC-Config" } + ], + "missingSignals": [], + "checks": { + "topLevelMgs": { "platform": true, "landingZones": true, "sandbox": true, "decommissioned": true }, + "platformChildren": { "connectivity": true, "identity": true, "management": true }, + "lzChildren": { "corp": true, "online": true }, + "platformSubscriptionCount": 3, + "hubSpoke": true, + "hubInConnectivitySubscription": true, + "knownAlzPolicies": ["Deny-PublicIP", "Deploy-MDFC-Config"] + } + }, + "managementGroups": { + "root": "Tenant Root Group", + "hasManagementGroups": true, + "hierarchy": [ + { "id": "/providers/Microsoft.Management/managementGroups/contoso-root", "name": "contoso-root", "displayName": "Contoso Root", "role": "root", "parentId": null }, + { "id": "/providers/Microsoft.Management/managementGroups/mg-platform", "name": "mg-platform", "displayName": "Platform", "role": "platform", "parentId": "/providers/Microsoft.Management/managementGroups/contoso-root" }, + { "id": "/providers/Microsoft.Management/managementGroups/mg-landing-zones", "name": "mg-landing-zones", "displayName": "Landing Zones", "role": "landing-zones", "parentId": "/providers/Microsoft.Management/managementGroups/contoso-root" }, + { "id": "/providers/Microsoft.Management/managementGroups/mg-sandbox", "name": "mg-sandbox", "displayName": "Sandbox", "role": "sandbox", "parentId": "/providers/Microsoft.Management/managementGroups/contoso-root" }, + { "id": "/providers/Microsoft.Management/managementGroups/mg-decommissioned", "name": "mg-decommissioned", "displayName": "Decommissioned", "role": "decommissioned", "parentId": "/providers/Microsoft.Management/managementGroups/contoso-root" }, + { "id": "/providers/Microsoft.Management/managementGroups/mg-connectivity", "name": "mg-connectivity", "displayName": "Connectivity", "role": "connectivity", "parentId": "/providers/Microsoft.Management/managementGroups/mg-platform" }, + { "id": "/providers/Microsoft.Management/managementGroups/mg-identity", "name": "mg-identity", "displayName": "Identity", "role": "identity", "parentId": "/providers/Microsoft.Management/managementGroups/mg-platform" }, + { "id": "/providers/Microsoft.Management/managementGroups/mg-management", "name": "mg-management", "displayName": "Management", "role": "management", "parentId": "/providers/Microsoft.Management/managementGroups/mg-platform" }, + { "id": "/providers/Microsoft.Management/managementGroups/mg-corp", "name": "mg-corp", "displayName": "Corp", "role": "corp", "parentId": "/providers/Microsoft.Management/managementGroups/mg-landing-zones" }, + { "id": "/providers/Microsoft.Management/managementGroups/mg-online", "name": "mg-online", "displayName": "Online", "role": "online", "parentId": "/providers/Microsoft.Management/managementGroups/mg-landing-zones" } + ] + }, + "subscriptions": { + "platform": [ + { "id": "20000000-0000-0000-0000-000000000001", "name": "sub-connectivity-prod", "role": "connectivity", "mgPath": "mg-platform/mg-connectivity" }, + { "id": "20000000-0000-0000-0000-000000000002", "name": "sub-identity-prod", "role": "identity", "mgPath": "mg-platform/mg-identity" }, + { "id": "20000000-0000-0000-0000-000000000003", "name": "sub-management-prod", "role": "management", "mgPath": "mg-platform/mg-management" } + ], + "landingZones": [ + { "id": "30000000-0000-0000-0000-000000000001", "name": "sub-app-dev", "role": "landing-zone-dev", "mgPath": "mg-landing-zones/mg-corp", "environment": "dev" }, + { "id": "30000000-0000-0000-0000-000000000002", "name": "sub-app-prod", "role": "landing-zone-prod", "mgPath": "mg-landing-zones/mg-corp", "environment": "prod" }, + { "id": "30000000-0000-0000-0000-000000000003", "name": "sub-online-prod", "role": "landing-zone-prod", "mgPath": "mg-landing-zones/mg-online", "environment": "prod" } + ] + }, + "sharedServices": { + "logAnalytics": { "id": "/subscriptions/20000000-0000-0000-0000-000000000003/resourceGroups/rg-mgmt/providers/Microsoft.OperationalInsights/workspaces/log-platform-prod-eastus", "name": "log-platform-prod-eastus", "subscription": "sub-management-prod", "location": "eastus" }, + "containerRegistry": { "id": "/subscriptions/20000000-0000-0000-0000-000000000003/resourceGroups/rg-mgmt/providers/Microsoft.ContainerRegistry/registries/crplatformprod", "name": "crplatformprod", "subscription": "sub-management-prod", "location": "eastus" }, + "keyVault": { "id": "/subscriptions/20000000-0000-0000-0000-000000000003/resourceGroups/rg-mgmt/providers/Microsoft.KeyVault/vaults/kv-platform-prod-eus", "name": "kv-platform-prod-eus", "subscription": "sub-management-prod", "location": "eastus" } + }, + "networking": { + "topology": "hub-spoke", + "hubs": [ + { "id": "/subscriptions/20000000-0000-0000-0000-000000000001/resourceGroups/rg-hub-eastus/providers/Microsoft.Network/virtualNetworks/vnet-hub-eastus", "name": "vnet-hub-eastus", "subscription": "20000000-0000-0000-0000-000000000001", "location": "eastus", "addressPrefixes": ["10.0.0.0/16"] } + ], + "privateDnsZones": [ + "privatelink.blob.core.windows.net", + "privatelink.vaultcore.azure.net", + "privatelink.azurewebsites.net" + ], + "peerings": [] + }, + "policies": { + "denyEffects": [ + { "name": "Deny-PublicIP", "scope": "/providers/Microsoft.Management/managementGroups/mg-landing-zones", "impact": "Blocks public IP creation in landing zone subscriptions" }, + { "name": "Deny-Public-Storage", "scope": "/providers/Microsoft.Management/managementGroups/mg-landing-zones", "impact": "Blocks storage accounts with public network access" } + ], + "auditEffects": [ + { "name": "Audit-Missing-Tags", "scope": "/providers/Microsoft.Management/managementGroups/mg-landing-zones", "impact": "Audits resources missing required tags" } + ], + "allowedLocations": ["eastus", "westus2", "westeurope"], + "requiredTags": ["Environment", "Project", "CostCenter"], + "alzCanonicalAssignments": ["Deny-PublicIP", "Deploy-MDFC-Config"] + }, + "currentIdentity": { + "user": "user@example.com", + "tenantId": "10000000-0000-0000-0000-000000000000", + "currentSubscription": { + "id": "30000000-0000-0000-0000-000000000001", + "name": "sub-app-dev" + }, + "roles": [] + } +} diff --git a/tests/fixtures/landing-zone/skipped-network.json b/tests/fixtures/landing-zone/skipped-network.json new file mode 100644 index 0000000..a07c88a --- /dev/null +++ b/tests/fixtures/landing-zone/skipped-network.json @@ -0,0 +1,74 @@ +{ + "_comment": "Test fixture: discovery run with --skip-network. networking.topology is 'unknown' — downstream consumers must NOT assume any topology. Synthetic. Expected detection: confidence=none (no MGs, no platform subs, network skipped).", + "discoveredAt": "2026-04-30T10:00:00Z", + "discoveryMethod": "auto", + "landingZoneDetection": { + "isLandingZone": false, + "confidence": "none", + "confidenceScore": 0, + "reference": "https://azure.github.io/Azure-Landing-Zones/accelerator/", + "matchedSignals": [], + "missingSignals": [ + "alz-top-level-mgs (0/4)", + "platform-children (0/3)", + "alz-lz-archetypes (Corp/Online MGs)", + "platform-subscriptions (0/3+)", + "hub-spoke-topology", + "hub-in-connectivity-sub", + "alz-canonical-policies" + ], + "checks": { + "topLevelMgs": { "platform": false, "landingZones": false, "sandbox": false, "decommissioned": false }, + "platformChildren": { "connectivity": false, "identity": false, "management": false }, + "lzChildren": { "corp": false, "online": false }, + "platformSubscriptionCount": 0, + "hubSpoke": false, + "hubInConnectivitySubscription": false, + "knownAlzPolicies": [] + } + }, + "managementGroups": { + "root": "Tenant Root Group", + "hasManagementGroups": false, + "hierarchy": [] + }, + "subscriptions": { + "platform": [], + "landingZones": [ + { + "id": "11111111-1111-1111-1111-111111111111", + "name": "example-landing-zone", + "role": "landing-zone", + "mgPath": "", + "environment": "unknown" + } + ] + }, + "sharedServices": { + "logAnalytics": {}, + "containerRegistry": {}, + "keyVault": {} + }, + "networking": { + "topology": "unknown", + "hubs": [], + "privateDnsZones": [], + "peerings": [] + }, + "policies": { + "denyEffects": [], + "auditEffects": [], + "allowedLocations": [], + "requiredTags": [], + "alzCanonicalAssignments": [] + }, + "currentIdentity": { + "user": "user@example.com", + "tenantId": "00000000-0000-0000-0000-000000000000", + "currentSubscription": { + "id": "11111111-1111-1111-1111-111111111111", + "name": "example-landing-zone" + }, + "roles": [] + } +} diff --git a/website/docs/agents/azure-policy-advisor.md b/website/docs/agents/azure-policy-advisor.md index d2246be..c2fa849 100644 --- a/website/docs/agents/azure-policy-advisor.md +++ b/website/docs/agents/azure-policy-advisor.md @@ -59,18 +59,24 @@ Always use the `/azure-policy-advisor` skill for procedure, classification tiers - A general subscription audit - Compliance with a specific framework (CIS, NIST, etc.) 2. Read compliance preferences from `copilot-instructions.md` (the `## Compliance & Azure Policy` section). -3. If an ARM template is provided, parse resource types. Otherwise, ask what resource types to assess. -4. Execute the `/azure-policy-advisor` skill procedure: +3. **Load landing zone context** if `.azure/landing-zone-context.json` exists (produced by `/azure-landing-zone-discovery`). Use it to: + - **Dedupe** recommendations against `policies.denyEffects[]`, `policies.auditEffects[]`, and `policies.alzCanonicalAssignments[]` — do not recommend policies the tenant is already enforcing + - **Align region recommendations** with `policies.allowedLocations[]` instead of guessing + - **Align tag recommendations** with `policies.requiredTags[]` + - **Note inherited posture** in Part 2 (subscription-level actions) — say "✓ inherited from management group" for canonical ALZ assignments rather than re-recommending them + - Respect `landingZoneDetection.confidence` — when `low`/`none`, treat the policy lists as informational only (the tenant may not actually be ALZ-managed) +4. If an ARM template is provided, parse resource types. Otherwise, ask what resource types to assess. +5. Execute the `/azure-policy-advisor` skill procedure: - **Step 2:** Query existing policy assignments in the Azure subscription (via `az policy assignment list`) - **Step 3:** Discover unassigned custom/built-in policy definitions (via `az policy definition list`) - **Step 4:** Query Microsoft Learn for current built-in policy definitions per resource type - - **Step 5:** Classify and prioritize — cross-reference template config, existing assignments, and custom definitions + - **Step 5:** Classify and prioritize — cross-reference template config, existing assignments, custom definitions, **and the LZ context's tenant policy state** - **Step 6:** Generate split report: - **Part 1: Template Improvements** — gaps fixable by modifying the ARM template (developer action) - - **Part 2: Subscription-Level Actions** — policy/initiative assignments (platform team action) + - **Part 2: Subscription-Level Actions** — policy/initiative assignments (platform team action), with canonical ALZ assignments already enforced marked as "✓ already inherited" - **Step 7:** Provide implementation options for both tracks -5. Present the policy assessment report with the split Part 1 / Part 2 format. -6. Save `policy-assessment.md` and `policy-recommendations.json` to the deployment directory if one exists. +6. Present the policy assessment report with the split Part 1 / Part 2 format. +7. Save `policy-assessment.md` and `policy-recommendations.json` to the deployment directory if one exists. ## Output Requirements diff --git a/website/docs/agents/azure-requirements-gatherer.md b/website/docs/agents/azure-requirements-gatherer.md index 7f6b607..a938b08 100644 --- a/website/docs/agents/azure-requirements-gatherer.md +++ b/website/docs/agents/azure-requirements-gatherer.md @@ -142,6 +142,119 @@ User intent: Deploy Azure Function App - Ensure globally unique names for resources that require it - Follow organizational naming conventions +### 0.7. Detect Landing Zone Context + +Check whether a landing zone context has been discovered for this workspace. The file is produced by the **azure-landing-zone-discovery** skill and lets this agent route workloads to the right subscription, warn on policy conflicts, and surface shared services. + +**Step 1 — read the context:** + +```bash +LZ_CONTEXT_FILE=".azure/landing-zone-context.json" + +if [[ -f "$LZ_CONTEXT_FILE" ]]; then + DISCOVERED_AT=$(jq -r '.discoveredAt' "$LZ_CONTEXT_FILE") + # Stale-check (warn if > 7 days old) + AGE_DAYS=$(( ( $(date -u +%s) - $(date -u -d "$DISCOVERED_AT" +%s 2>/dev/null || date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$DISCOVERED_AT" +%s) ) / 86400 )) + [[ $AGE_DAYS -gt 7 ]] && echo "âš ī¸ Landing zone context is $AGE_DAYS days old — consider refreshing" + + # Detection confidence (see azure-landing-zone-discovery skill, "Landing Zone Detection Confidence") + LZ_CONFIDENCE=$(jq -r '.landingZoneDetection.confidence // "unknown"' "$LZ_CONTEXT_FILE") + LZ_SCORE=$(jq -r '.landingZoneDetection.confidenceScore // 0' "$LZ_CONTEXT_FILE") + LZ_IS_LZ=$(jq -r '.landingZoneDetection.isLandingZone // false' "$LZ_CONTEXT_FILE") +else + echo "â„šī¸ No landing zone context found at $LZ_CONTEXT_FILE" + echo " Run /azure-landing-zone-discovery to enable landing-zone-aware deployments," + echo " or proceed with subscription-only context." + # Continue without LZ context — do not block the user +fi +``` + +**How to treat `landingZoneDetection.confidence`:** + +| Confidence | Treatment | +|---|---| +| `high` (score â‰Ĩ 70) | Trust auto-classified subscription roles, hub-spoke topology, and shared services without prompting. | +| `medium` (40–69) | Surface matched + missing signals to the user (`.landingZoneDetection.matchedSignals[]`, `.landingZoneDetection.missingSignals[]`) and confirm before assuming ALZ-managed behavior. | +| `low` (10–39) | Note partial ALZ signals but default to standalone-tenant treatment. Do not auto-attach to hub or shared services. | +| `none` (< 10) | Treat as a flat/standalone tenant. If the user *knows* the tenant is ALZ-managed, suggest manual injection via the `inject-lz.sh` script. | + + +**Step 2 — classify the current subscription:** + +If the context exists, look up the active subscription in `subscriptions.platform[]` and `subscriptions.landingZones[]`: + +```bash +CURRENT_SUB_ID=$(az account show --query id -o tsv) +SUB_ROLE=$(jq -r --arg id "$CURRENT_SUB_ID" ' + (.subscriptions.platform[] | select(.id == $id) | .role) // + (.subscriptions.landingZones[] | select(.id == $id) | .role) // + "unclassified" +' "$LZ_CONTEXT_FILE") +``` + +**Warn if the user is targeting a platform subscription:** + +| Detected `role` | Action | +|------------------|--------| +| `connectivity`, `identity`, `management` | âš ī¸ **Block by default.** Display "This is a platform subscription. Workloads should land in an application landing zone." Offer to switch. | +| `landing-zone`, `landing-zone-dev`, `landing-zone-staging`, `landing-zone-prod` | ✅ Proceed. | +| `unclassified` (sub not in context) | Note that the subscription isn't part of the discovered topology. Ask user to confirm intent. | + +**Step 3 — surface the policy gates that may block this deployment:** + +```bash +DENY_COUNT=$(jq '.policies.denyEffects | length' "$LZ_CONTEXT_FILE") +ALLOWED_LOCATIONS=$(jq -r '.policies.allowedLocations | join(", ")' "$LZ_CONTEXT_FILE") +REQUIRED_TAGS=$(jq -r '.policies.requiredTags | join(", ")' "$LZ_CONTEXT_FILE") + +if [[ "$DENY_COUNT" -gt 0 ]]; then + echo "🛑 $DENY_COUNT Deny-effect policies apply to this scope:" + jq -r '.policies.denyEffects[] | " â€ĸ \(.name) — \(.impact)"' "$LZ_CONTEXT_FILE" +fi +[[ -n "$ALLOWED_LOCATIONS" && "$ALLOWED_LOCATIONS" != "" ]] && echo "📍 Allowed locations: $ALLOWED_LOCATIONS" +[[ -n "$REQUIRED_TAGS" && "$REQUIRED_TAGS" != "" ]] && echo "đŸˇī¸ Required tags: $REQUIRED_TAGS" +``` + +Treat `denyEffects` as gating: a user-requested region outside `allowedLocations`, a missing required tag, or a configuration that matches a deny impact MUST be raised before template generation. + +**Do NOT** surface entries from `auditEffects` as blockers — those are informational only. + +**Step 4 — note available shared services:** + +If `sharedServices.logAnalytics.id` / `containerRegistry.id` / `keyVault.id` are present, record them so Stage 2 (template generation) wires diagnostics, container images, and secrets to the shared platform resources instead of creating new ones. + +If `networking.topology == "hub-spoke"` and `networking.hubs[]` is non-empty, record the hub VNet ID(s) for VNet peering in Stage 2. + +Skip this step gracefully when: +- `discoveryMethod == "manual"` and fields are absent (user has not provided them) +- `topology == "flat"` (no hub-spoke to integrate with) +- `topology == "unknown"` (treat conservatively — do not assume any shared infra) + +**Display the landing zone summary to the user:** + +```markdown +## Landing Zone Context + +| Property | Value | +|----------|-------| +| **Discovered** | {discoveredAt} ({age} ago) | +| **Method** | {auto / manual} | +| **Detection** | {confidence} ({confidenceScore}/100) — isLandingZone: {true/false} | +| **Current subscription role** | {connectivity / landing-zone-prod / unclassified ...} | +| **Network topology** | {hub-spoke / flat / unknown} | +| **Deny policies** | {N} ({list if N ≤ 5}) | +| **Allowed locations** | {list or "any"} | +| **Required tags** | {list or "none"} | +| **Shared Log Analytics** | {name / "none"} | +| **Shared ACR** | {name / "none"} | +| **Hub VNet** | {name / "none"} | + +{If confidence is "medium" or "low":} Detected ALZ signals: {matchedSignals[].signal}. Missing: {missingSignals[]}. +{If platform subscription:} âš ī¸ Target subscription is a platform subscription — workloads should typically deploy elsewhere. +``` + +**Pass landing zone context to downstream stages** by including a `landingZone` block in the requirements output (see Section 4). + ### 1. Identify Resource Type(s) **Support Multi-Resource Deployments** - Ask if user wants single or multiple resources: @@ -411,6 +524,29 @@ Resource 3 (App Insights) → Resource 2 (Function App) "displayName": "{tenantDisplayName}", "domain": "{tenantDomain}" }, + "landingZone": { + "contextFile": ".azure/landing-zone-context.json", + "discoveredAt": "{ISO 8601 or null if no context}", + "discoveryMethod": "auto|manual|none", + "detection": { + "isLandingZone": false, + "confidence": "high|medium|low|none", + "confidenceScore": 0 + }, + "currentSubscriptionRole": "landing-zone|connectivity|identity|management|unclassified", + "topology": "hub-spoke|flat|unknown", + "policyGates": { + "denyEffectCount": 0, + "allowedLocations": [], + "requiredTags": [] + }, + "sharedServices": { + "logAnalyticsId": "{id or null}", + "containerRegistryId": "{id or null}", + "keyVaultId": "{id or null}", + "hubVnetIds": [] + } + }, "resources": [ { "type": "Microsoft.Web/sites", diff --git a/website/docs/agents/azure-template-generator.md b/website/docs/agents/azure-template-generator.md index 7889ca8..4019aa7 100644 --- a/website/docs/agents/azure-template-generator.md +++ b/website/docs/agents/azure-template-generator.md @@ -292,6 +292,61 @@ For **ALL resources**: All other per-resource hardening (TLS versions, blob soft delete, threat detection, health probes, auto-scaling, etc.) is owned by the security analyzer in Step 3 and the policy advisor in Step 4 — they will flag anything missing with severity tags, and Critical / High findings are auto-applied or BLOCK the security gate. +### 2.5. Apply Landing Zone Context (When Available) + +Before invoking the security/policy/preflight skills, check whether the workspace has discovered landing zone context at `.azure/landing-zone-context.json` (produced by `/azure-landing-zone-discovery`). When present, the template MUST respect the discovered tenant configuration. + +**Load the context once:** + +```bash +LZ_CONTEXT_FILE=".azure/landing-zone-context.json" +if [[ -f "$LZ_CONTEXT_FILE" ]]; then + LZ_CONFIDENCE=$(jq -r '.landingZoneDetection.confidence // "unknown"' "$LZ_CONTEXT_FILE") + LZ_ALLOWED_LOCATIONS=$(jq -r '.policies.allowedLocations[]? // empty' "$LZ_CONTEXT_FILE") + LZ_REQUIRED_TAGS=$(jq -r '.policies.requiredTags[]? // empty' "$LZ_CONTEXT_FILE") + LZ_LAW_ID=$(jq -r '.sharedServices.logAnalytics.id // empty' "$LZ_CONTEXT_FILE") + LZ_ACR_ID=$(jq -r '.sharedServices.containerRegistry.id // empty' "$LZ_CONTEXT_FILE") + LZ_HUB_VNET_ID=$(jq -r '.networking.hubs[0].id // empty' "$LZ_CONTEXT_FILE") + LZ_TOPOLOGY=$(jq -r '.networking.topology // "unknown"' "$LZ_CONTEXT_FILE") +fi +``` + +**How to act on each field (gated by `landingZoneDetection.confidence`):** + +| Field | Action when `confidence` â‰Ĩ `medium` | +|-------|--------------------------------------| +| `policies.allowedLocations[]` | **Reject** the template if the target region is not in the list. Surface the allowed list to the user and ask them to pick one. | +| `policies.requiredTags[]` | Inject each required tag as a parameter in the template; if the user didn't provide a value, ask before generating. Apply to all resources via `tags` block. | +| `policies.denyEffects[]` | Cross-check template properties against the deny rules. If the template would be denied (e.g., public IP when `Deny-PublicIP` is enforced), flag it as a security-gate blocker before invoking the security analyzer. | +| `policies.alzCanonicalAssignments[]` | Document the matched canonical ALZ policies in the deployment plan so the user understands the tenant baseline. | +| `sharedServices.logAnalytics.id` | Wire `diagnosticSettings` for every resource that supports it to this workspace instead of creating a new one. | +| `sharedServices.containerRegistry.id` | If deploying Container Apps / AKS, reference this ACR (with pull RBAC on the workload identity). Skip creating a new ACR unless the user explicitly asks. | +| `networking.hubs[0].id` (when `topology` = `hub-spoke`) | Generate VNet peering from the workload spoke to the hub. Use the hub's resource group / subscription from the discovered ID. | +| `networking.privateDnsZones[]` | When generating private endpoints, link them to the discovered private DNS zones for end-to-end name resolution. | + +**Confidence handling:** + +- `high` (â‰Ĩ70) — Apply all the above automatically. Note each LZ-driven decision in the deployment plan. +- `medium` (40–69) — Surface the proposed LZ-driven choices to the user and ask to confirm before applying. +- `low` (10–39) / `none` (<10) — Use **only** the policy fields (`allowedLocations`, `requiredTags`, `denyEffects`) when they are explicitly populated. Do **not** auto-wire shared services or hub peering — the topology may be misclassified. +- Context missing — Skip this entire step; proceed with the user-provided values. + +**Surface in the deployment plan:** + +Add a "Landing Zone Compliance" subsection between "Security Configuration" and "Security Best Practices Analysis": + +```markdown +### Landing Zone Compliance + +- **Confidence:** {high|medium|low|none} ({score}/100) +- **Target subscription role:** {landing-zone|sandbox|standalone} +- **Region check:** ✓ {region} is in `allowedLocations` +- **Required tags applied:** Environment, Project, CostCenter +- **Diagnostics:** routed to `{logAnalyticsId}` (shared) +- **Hub peering:** {generated to hub-vnet-id | skipped — topology=flat} +- **Policy gate check:** ✓ no template properties conflict with tenant `denyEffects` +``` + ### 3. Analyze Security Best Practices (Per Resource) **Invoke skill:** `/azure-security-analyzer` diff --git a/website/docs/agents/git-ape-onboarding.md b/website/docs/agents/git-ape-onboarding.md index eef2c6e..bbd200d 100644 --- a/website/docs/agents/git-ape-onboarding.md +++ b/website/docs/agents/git-ape-onboarding.md @@ -100,7 +100,8 @@ Treat this as a **non-negotiable contract** for the gated first reply: regardles Both scripts produce byte-identical output. Report which files were created vs skipped. 9. Ask compliance framework and enforcement mode preferences (Step 10 in `/git-ape-onboarding` skill playbook). 10. Update the `## Compliance & Azure Policy` section in `.github/copilot-instructions.md` with the user's choices. If the file was skipped by the scaffold step or lacks that section, surface the captured preferences in chat for manual integration instead of mutating the file. -11. Summarize created/updated artifacts and next checks. +11. **Run landing zone discovery** against each onboarded subscription using `/azure-landing-zone-discovery`. This populates `.azure/landing-zone-context.json` with the tenant's management group hierarchy, platform subscriptions, hub-spoke networking, and policy gates so the requirements gatherer, template generator, and policy advisor can be landing-zone-aware on the very first deployment. If discovery reports `confidence` = `low`/`none`, tell the user the workspace will deploy in standalone mode and document how to fall back to manual injection. +12. Summarize created/updated artifacts and next checks. ## Output Requirements diff --git a/website/docs/agents/git-ape.md b/website/docs/agents/git-ape.md index 3a956d0..a6fd5b8 100644 --- a/website/docs/agents/git-ape.md +++ b/website/docs/agents/git-ape.md @@ -159,6 +159,7 @@ Coordinate the deployment of Azure resources by delegating to specialized subage **Skills (invoked during workflow):** - `/azure-rest-api-reference` — ARM template property schemas, required fields, valid values, and latest stable API versions. **Must be invoked before generating or modifying any ARM template resource.** - `/azure-naming-research` — CAF abbreviation lookup and naming validation +- `/azure-landing-zone-discovery` — Discover Azure Landing Zone topology (management groups, platform subscriptions, hub-spoke networking, policy gates) and emit `.azure/landing-zone-context.json` with a confidence score (high/medium/low/none). Consumed by requirements gatherer, template generator, and policy advisor. - `/azure-security-analyzer` — Per-resource security best practices assessment - `/azure-policy-advisor` - assess the template against Azure Policy compliance - `/azure-deployment-preflight` — What-if analysis and preflight validation @@ -168,6 +169,38 @@ Coordinate the deployment of Azure resources by delegating to specialized subage - `/azure-role-selector` — Least-privilege RBAC role recommendations - `/azure-cost-estimator` — Real-time cost estimation via Azure Retail Prices API +## Stage 0: Landing Zone Context (Pre-Flight) + +**Before starting any deployment**, check whether the workspace has a discovered landing zone topology at `.azure/landing-zone-context.json`. This file is produced by the `/azure-landing-zone-discovery` skill and lets the requirements gatherer, template generator, and policy advisor route workloads correctly, respect tenant policy gates, and reuse shared services. + +**Flow:** + +```bash +LZ_CONTEXT_FILE=".azure/landing-zone-context.json" +if [[ -f "$LZ_CONTEXT_FILE" ]]; then + LZ_CONFIDENCE=$(jq -r '.landingZoneDetection.confidence // "unknown"' "$LZ_CONTEXT_FILE") + LZ_SCORE=$(jq -r '.landingZoneDetection.confidenceScore // 0' "$LZ_CONTEXT_FILE") + LZ_AGE_DAYS=$(( ($(date +%s) - $(date -r "$LZ_CONTEXT_FILE" +%s)) / 86400 )) + echo "✓ Landing zone context: confidence=$LZ_CONFIDENCE (score $LZ_SCORE/100), age $LZ_AGE_DAYS days" + [[ $LZ_AGE_DAYS -gt 7 ]] && echo "âš ī¸ Context is stale — consider re-running /azure-landing-zone-discovery" +else + echo "â„šī¸ No landing zone context — workspace will deploy in standalone mode." + echo " Run /azure-landing-zone-discovery to enable LZ-aware deployments." +fi +``` + +**How to act on the result:** + +| Situation | Action | +|-----------|--------| +| Context missing | Proceed standalone. Show one-line hint suggesting `/azure-landing-zone-discovery` for ALZ-managed tenants. Do **not** force discovery — many users deploy into solo subscriptions. | +| Context present, `confidence` = `high` | Trust auto-classification. Pass the path to every subagent (requirements gatherer, template generator, policy advisor). | +| Context present, `confidence` = `medium` | Pass it through, but tell subagents to confirm matched/missing signals with the user before applying ALZ-specific behavior (hub peering, shared diagnostics). | +| Context present, `confidence` = `low`/`none` | Pass it through for the policy/region data only. Subagents must treat tenant as standalone unless the user explicitly opts in. | +| Context older than 7 days | Surface the staleness warning; offer to re-run discovery. | + +**Propagation:** Every downstream subagent receives the path `.azure/landing-zone-context.json`. They are responsible for parsing the parts they need (subscriptions, policies, shared services, hubs) and respecting the confidence bucket. + ## Pre-Deployment Drift Check (Optional) **Before starting new deployments**, check if there are existing deployments with configuration drift. @@ -212,10 +245,14 @@ Coordinate the deployment of Azure resources by delegating to specialized subage ## Workflow Stages +> **Pre-flight:** Always run the **Stage 0: Landing Zone Context** check above before Stage 1. If the context exists, every downstream subagent must read it. + ### Stage 1: Requirements Gathering **Delegate to:** `azure-requirements-gatherer` -The gatherer will interview the user to collect: +The gatherer will: +- **Read `.azure/landing-zone-context.json`** if present (Stage 0). Use the LZ context to auto-route the deployment to the right subscription, surface tenant policy gates, and pre-fill shared service references. Respect the `landingZoneDetection.confidence` bucket: `high` = trust; `medium` = confirm with user; `low`/`none` = treat as standalone. +- Interview the user to collect: - Resource type (Function App, Storage Account, SQL Database, etc.) - SKU/tier and sizing requirements - Region and resource group details diff --git a/website/docs/authoring/skills.md b/website/docs/authoring/skills.md index 0a68055..78e5256 100644 --- a/website/docs/authoring/skills.md +++ b/website/docs/authoring/skills.md @@ -39,6 +39,27 @@ Then write the frontmatter and body following the template below. No further reg The directory name **must** match the `name:` field in frontmatter. +### Helper scripts: ship both shells + +When a skill shells out to a helper script in `scripts/`, follow this cross-shell parity convention: + +- **User-invocable skills that ship helper scripts SHOULD provide both a `.sh` (bash) and a `.ps1` (PowerShell) port.** A user invoking a skill from VS Code Copilot Chat may be on Windows, macOS, or Linux, so a bash-only helper silently fails for Windows users who do not have `git-bash` on `PATH`. The two ports must accept equivalent flags and produce byte-compatible output (same JSON schema, same files). +- **CI-only / deployment skills MAY ship `.sh` only** when they are invoked exclusively from a known runner (e.g. a GitHub Actions workflow that pins `ubuntu-latest`). Document the assumption in the skill body. +- The repository baseline is a Bash-compatible shell (`git-bash` on Windows; see the [README prerequisites](https://github.com/Azure/git-ape#prerequisites)). The `.ps1` ports remove that dependency for native Windows users. + +Existing skills that follow the dual-shell pattern — use them as references: + +| Skill | Scripts | +|-------|---------| +| [`azure-stack-deploy`](https://github.com/Azure/git-ape/tree/main/.github/skills/azure-stack-deploy/scripts) | `deploy-stack.sh` + `deploy-stack.ps1` | +| [`azure-stack-destroy`](https://github.com/Azure/git-ape/tree/main/.github/skills/azure-stack-destroy/scripts) | `destroy-stack.sh` + `destroy-stack.ps1` | +| [`git-ape-onboarding`](https://github.com/Azure/git-ape/tree/main/.github/skills/git-ape-onboarding/scripts) | `scaffold-repo.sh` + `scaffold-repo.ps1` | +| [`azure-landing-zone-discovery`](https://github.com/Azure/git-ape/tree/main/.github/skills/azure-landing-zone-discovery/scripts) | `discover-lz.sh` + `discover-lz.ps1`, `inject-lz.sh` + `inject-lz.ps1` | + +When you document the invocation in `SKILL.md`, show **both** shells as sibling fenced blocks — a ` ```bash ` block and a ` ```powershell ` block — so the agent can pick the right one for the host OS. + + + ## SKILL.md template ```markdown diff --git a/website/docs/deployment/landing-zone-context.md b/website/docs/deployment/landing-zone-context.md new file mode 100644 index 0000000..f3f3c13 --- /dev/null +++ b/website/docs/deployment/landing-zone-context.md @@ -0,0 +1,192 @@ +--- +title: "Landing Zone Context" +sidebar_label: "Landing Zone Context" +sidebar_position: 4 +description: "Schema and confidence model for .azure/landing-zone-context.json" +keywords: [landing zone, context, schema, confidence, ALZ, JSON] +--- + +# Landing Zone Context + +:::warning +EXPERIMENTAL ONLY: The context schema is subject to change. Do not rely on it for production governance, audit, or compliance reporting. +::: + +The `.azure/landing-zone-context.json` file is the single source of truth for landing-zone-aware behavior across every Git-Ape agent. This page documents its schema, the confidence-scoring model, and how each field is consumed. + +## How It's Produced + +The file is generated by the [`/azure-landing-zone-discovery`](/docs/skills/azure-landing-zone-discovery) skill — auto-discovered from `az` CLI + Azure Resource Graph, or manually injected via `inject-lz.sh`. + +See the [Landing Zone-Aware Deployment](/docs/use-cases/landing-zone-aware-deployment) use case for the end-to-end workflow. + +## Top-Level Schema + +```json +{ + "discoveredAt": "2026-04-30T10:00:00Z", + "discoveryMethod": "auto", + "landingZoneDetection": { /* see below */ }, + "managementGroups": { /* hierarchy */ }, + "subscriptions": { /* platform vs landing zones */ }, + "sharedServices": { /* LAW, ACR, Key Vault */ }, + "networking": { /* topology, hubs, DNS zones */ }, + "policies": { /* denyEffects, auditEffects, allowedLocations, requiredTags */ }, + "currentIdentity": { /* user, tenant */ } +} +``` + +| Field | Type | Purpose | +|---|---|---| +| `discoveredAt` | ISO 8601 timestamp | Used by the orchestrator to warn on stale context (> 7 days) | +| `discoveryMethod` | `"auto"` or `"manual"` | Distinguishes auto-discovered vs injected context | +| `landingZoneDetection` | object | Confidence model — see below | +| `managementGroups` | object | Tenant management group hierarchy | +| `subscriptions` | object | Subscriptions split into `platform[]` and `landingZones[]` | +| `sharedServices` | object | IDs of shared LAW, ACR, Key Vault | +| `networking` | object | Hub VNets, peerings, private DNS zones | +| `policies` | object | Deny / Audit / ALZ-canonical assignments + tenant constraints | +| `currentIdentity` | object | Discovery principal and tenant | + +## Confidence Model + +`landingZoneDetection` rates how strongly the tenant matches the [Azure Landing Zone accelerator](https://azure.github.io/Azure-Landing-Zones/accelerator/) reference. Consumers gate behavior on `confidence`, not the raw `confidenceScore`. + +### Weighted Signals + +| Signal | Max points | Source | +|---|---:|---| +| `alz-top-level-mgs` — `Platform`, `Landing zones`, `Sandbox`, `Decommissioned` | 30 | Management group hierarchy | +| `platform-children` — `Connectivity`, `Identity`, `Management` under Platform | 20 | Management group hierarchy | +| `alz-lz-archetypes` — both `Corp` and `Online` under Landing zones | 10 | Management group hierarchy | +| `platform-subscriptions` — subs classified as connectivity/identity/management | 10 | Subscription role classification | +| `hub-spoke-topology` — at least one hub VNet discovered | 5 | Network topology | +| `hub-in-connectivity-sub` — hub VNet lives in a connectivity subscription | 5 | Network + subscription cross-check | +| `alz-canonical-policies` — assignments matching ALZ accelerator policy names | 15 (3 × 5) | Policy assignments | + +Signal scaling: + +- Top-level MGs: 4/4 = 30, 3/4 = 20, 2/4 = 10 +- Platform children: 3/3 = 20, 2/3 = 10 +- Platform subs: â‰Ĩ3 = 10, â‰Ĩ1 = 5 +- ALZ canonical policies: `min(matches × 5, 15)` + +### Confidence Buckets + +| `confidence` | Score range | `isLandingZone` | Suggested treatment | +|---|---:|---:|---| +| `high` | â‰Ĩ 70 | `true` | Trust auto-classification. Proceed without prompting. | +| `medium` | 40–69 | `true` | Surface matched + missing signals. Ask user to confirm. | +| `low` | 10–39 | `false` | Treat as standalone. Mention partial signals. | +| `none` | < 10 | `false` | Default to flat-tenant. Recommend manual injection if user knows the tenant is ALZ. | + +### Inspecting the Detection Block + +```bash +jq '.landingZoneDetection' .azure/landing-zone-context.json +``` + +```json +{ + "isLandingZone": true, + "confidence": "high", + "confidenceScore": 85, + "reference": "https://azure.github.io/Azure-Landing-Zones/accelerator/", + "matchedSignals": [ + { "signal": "alz-top-level-mgs", "points": 30, "evidence": "4/4 canonical top-level MGs" }, + { "signal": "platform-children", "points": 20, "evidence": "3/3 platform children" }, + { "signal": "alz-canonical-policies", "points": 5, "evidence": "1 canonical assignment: Deploy-MDFC-Config" } + ], + "missingSignals": [], + "checks": { + "topLevelMgs": { "platform": true, "landingZones": true, "sandbox": true, "decommissioned": true }, + "platformChildren": { "connectivity": true, "identity": true, "management": true }, + "hubSpoke": true, + "hubInConnectivitySubscription": true, + "knownAlzPolicies": ["Deploy-MDFC-Config"] + } +} +``` + +- `matchedSignals[]` — every scoring signal with its points and a human-readable evidence string +- `missingSignals[]` — signals that scored zero (use this to tell the user *why* confidence is low) +- `checks` — raw booleans/counts the scorer evaluated + +## Networking Topology + +`networking.topology` is one of: + +| Value | Meaning | Consumer behavior | +|---|---|---| +| `"hub-spoke"` | Hub VNet discovered (named or tagged `hub`) | Generate VNet peering for workloads needing network connectivity | +| `"flat"` | Network discovery ran, no hub found | Treat as single-VNet, non-enterprise environment | +| `"unknown"` | Discovery skipped (`--skip-network`) or failed | Do not assume any topology | + +## Policy Classification + +`discover-lz.sh` reads `parameters.effect.value` (case-insensitive) from each policy assignment and partitions: + +| Effect | Goes into | +|---|---| +| `Deny` | `policies.denyEffects[]` | +| `Audit`, `AuditIfNotExists` | `policies.auditEffects[]` | +| `DeployIfNotExists`, `Modify`, `Append`, `Disabled` | excluded | +| Initiatives with no top-level effect param | excluded (effect varies by inner definition) | +| Missing / unrecognized effect | excluded | + +**Special list:** `policies.alzCanonicalAssignments[]` contains assignments whose name matches a known ALZ accelerator policy (e.g. `Deploy-MDFC-Config`, `Deny-PublicIP`) — a high-precision ALZ signature regardless of effect. + +## Consumers + +Each Git-Ape agent reads only the fields relevant to its role: + +| Agent / skill | Fields consumed | Effect | +|---|---|---| +| `@git-ape` orchestrator | `landingZoneDetection`, `discoveredAt` | Stage 0 pre-flight: surface confidence, warn on stale context | +| `@azure-requirements-gatherer` | `subscriptions.landingZones[]`, `policies.allowedLocations[]` | Auto-pick correct subscription per environment, reject blocked regions | +| `@azure-template-generator` | `sharedServices.*`, `networking.hubs[]`, `networking.privateDnsZones[]`, `policies.requiredTags[]`, `policies.denyEffects[]` | Wire shared diagnostics, peer to hub, link private endpoints, inject tags, cross-check denies | +| `@azure-policy-advisor` (+ skill) | `policies.denyEffects[]`, `policies.auditEffects[]`, `policies.alzCanonicalAssignments[]`, `policies.allowedLocations[]`, `policies.requiredTags[]` | Dedupe recommendations; mark inherited as "✓ inherited from management group" | +| `@Git-Ape Onboarding` | (writes the file) | Runs `/azure-landing-zone-discovery` as step 10 | + +## Edge Cases + +| Scenario | Handling | `networking.topology` | +|---|---|---| +| Hub VNet found | Record hubs and peerings | `hub-spoke` | +| Network discovery ran, no hub | Treat as flat | `flat` | +| Network discovery skipped or failed | Downstream must not assume topology | `unknown` | +| No management groups (flat sub) | Skip hierarchy, sub-level only | (independent) | +| Cross-tenant landing zone | Manual injection required | (set by `inject-lz.sh`) | +| Limited RBAC (no MG read) | Fall back to sub-level + manual hierarchy injection | (network may still run) | +| Multiple LZs for same env | Present options, let user choose | (independent) | +| `confidence` = `low` or `none` | Treat as standalone; do not auto-attach to hub/shared services | (preserved) | +| `confidence` = `medium` | Surface signals; ask user to confirm | (preserved) | +| Stale context (> 7 days) | Orchestrator warns, offers refresh | (preserved) | +| No Resource Graph access | Fall back to `az` CLI per subscription (slower) | `hub-spoke` or `flat` | + +## Refresh Cadence + +The orchestrator computes `(now - discoveredAt)` and warns if older than **7 days**. Re-run discovery when: + +- Context is stale +- A new shared service was provisioned +- A new management group policy was assigned +- A new application landing zone subscription was created +- You switched tenants or rotated credentials + +## Committing the File + +`.azure/landing-zone-context.json` is **whitelisted** in `.gitignore` (the broader `.azure/` is ignored, this single file is allowed). Commit decisions: + +| Confidence | Recommendation | +|---|---| +| `high` / `medium` (team workspaces) | Commit — team shares one topology view | +| Personal sandbox | Add the file to `.gitignore` overrides — avoids leaking tenant identifiers | +| `low` / `none` | Mostly empty; commit only if manual injection was used | + +## Related + +- [Use case: Landing Zone-Aware Deployment](/docs/use-cases/landing-zone-aware-deployment) +- [Skill: Azure Landing Zone Discovery](/docs/skills/azure-landing-zone-discovery) +- [Agent: Azure Policy Advisor](/docs/agents/azure-policy-advisor) +- [Deployment State Management](/docs/deployment/state) diff --git a/website/docs/getting-started/onboarding.md b/website/docs/getting-started/onboarding.md index 9e442f4..5f654b2 100644 --- a/website/docs/getting-started/onboarding.md +++ b/website/docs/getting-started/onboarding.md @@ -121,6 +121,11 @@ The skill collects five inputs (or uses sensible defaults): 4. **Azure subscription(s)** — defaults to your current `az` subscription 5. **RBAC role(s)** — Contributor (default) or Contributor + User Access Administrator +After OIDC/RBAC setup the playbook runs two final steps: + +- **Compliance & Azure Policy preferences** — captured in `.github/copilot-instructions.md` +- **Landing zone discovery** — runs [`/azure-landing-zone-discovery`](/docs/use-cases/landing-zone-aware-deployment) against each onboarded subscription to populate `.azure/landing-zone-context.json`. This lets the requirements gatherer, template generator, and policy advisor be landing-zone-aware from the first deployment. + ### Example: single environment ```text diff --git a/website/docs/intro.md b/website/docs/intro.md index 775e1ca..1a0a6cc 100644 --- a/website/docs/intro.md +++ b/website/docs/intro.md @@ -111,6 +111,7 @@ See [Installation & Prerequisites](./getting-started/installation) for every ins - [Deploy anything](./use-cases/deploy-anything) - [Security Analysis](./use-cases/security-analysis) +- [Landing Zone-Aware Deployment](./use-cases/landing-zone-aware-deployment) - [CI/CD Pipeline](./use-cases/cicd-pipeline) - [Headless / Coding Agent Mode](./use-cases/headless-mode) diff --git a/website/docs/skills/azure-landing-zone-discovery.md b/website/docs/skills/azure-landing-zone-discovery.md new file mode 100644 index 0000000..f236213 --- /dev/null +++ b/website/docs/skills/azure-landing-zone-discovery.md @@ -0,0 +1,587 @@ +--- +title: "Azure Landing Zone Discovery" +sidebar_label: "Azure Landing Zone Discovery" +description: "Auto-discover Azure landing zone topology including management groups, platform vs. application subscriptions, policy assignments, hub-spoke networking, and shared services. Use when deploying into enterprise environments, checking policy conflicts, or connecting to shared infrastructure." +--- + + + + +# Azure Landing Zone Discovery + +> Auto-discover Azure landing zone topology including management groups, platform vs. application subscriptions, policy assignments, hub-spoke networking, and shared services. Use when deploying into enterprise environments, checking policy conflicts, or connecting to shared infrastructure. + +## Details + +| Property | Value | +|----------|-------| +| **Skill Directory** | `.github/skills/azure-landing-zone-discovery/` | +| **Phase** | General | +| **User Invocable** | ✅ Yes | +| **Usage** | `/azure-landing-zone-discovery Discovery scope or manual injection mode (e.g. 'full discovery', 'inject context', 'check policies for eastus')` | + + +## Documentation + +# Azure Landing Zone Discovery + +## Overview + +Discover the enterprise Azure landing zone topology from the current Azure context, enabling Git-Ape to make landing zone-aware deployment decisions — routing workloads to the correct subscription, connecting to shared services, and avoiding policy conflicts. + +Enterprise Azure environments follow the [Cloud Adoption Framework landing zone architecture](https://learn.microsoft.com/azure/cloud-adoption-framework/ready/landing-zone/) with management groups, platform subscriptions, application landing zones, and hub-spoke networking. + +**Triggers:** + +- User asks: "discover landing zone", "show management groups", "what policies apply?" +- Before first deployment in a new subscription (auto-detect enterprise topology) +- User asks: "connect to hub VNet", "use shared Log Analytics", "which subscription for prod?" +- User provides manual landing zone context for air-gapped or cross-tenant environments + +**Output:** + +- `.azure/landing-zone-context.json` — Machine-readable landing zone topology +- Landing zone summary displayed to user with management group hierarchy visualization + +## Procedure + +### 1. Check for Existing Context + +Before running discovery, check if a landing zone context already exists: + +```bash +LZ_CONTEXT_FILE=".azure/landing-zone-context.json" + +if [[ -f "$LZ_CONTEXT_FILE" ]]; then + DISCOVERED_AT=$(jq -r '.discoveredAt' "$LZ_CONTEXT_FILE") + echo "Existing landing zone context found (discovered: $DISCOVERED_AT)" + echo "" + echo "Options:" + echo " A. Use existing context" + echo " B. Re-run discovery (refresh)" + echo " C. Manually update context" +fi +``` + +### 2. Run Full Discovery + +Run the discovery script to auto-detect the landing zone topology. The skill ships parity implementations for both shells — use the bash script on Linux/macOS and the PowerShell script on Windows (both produce an identical `landing-zone-context.json`): + +```bash +# Bash (Linux/macOS, git-bash on Windows) +.github/skills/azure-landing-zone-discovery/scripts/discover-lz.sh \ + --output-format json \ + --output-file .azure/landing-zone-context.json +``` + +```powershell +# PowerShell (Windows, or pwsh on any platform) +.github/skills/azure-landing-zone-discovery/scripts/discover-lz.ps1 ` + -OutputFormat json ` + -OutputFile .azure/landing-zone-context.json +``` + +**Discovery targets:** + +| Target | Azure CLI Command | Fallback | +|--------|------------------|----------| +| Management group hierarchy | `az account management-group list` | Subscription-only mode | +| Subscription classification | Tags, naming convention, management group placement | Manual classification | +| Policy assignments | `az policy assignment list --scope ` | Skip policy check | +| Network topology | `az network vnet list`, peerings, DNS zones | Manual VNet ID input | +| Shared services | Resource Graph query for Log Analytics, ACR, Key Vault | Manual resource IDs | +| RBAC | `az role assignment list` | Note limited permissions | + +**The script handles these scenarios gracefully:** + +- **No management groups (flat subscription):** Skips hierarchy discovery, uses subscription-level context only +- **Limited RBAC (no management group read):** Falls back to subscription-level discovery, prompts for manual hierarchy input +- **Cross-tenant landing zone:** Manual injection required — discovery limited to current tenant +- **No network resources:** Skips networking discovery, notes that hub connectivity is not configured + +### 3. Management Group Hierarchy Discovery + +```bash +# Discover management group tree +MG_LIST=$(az account management-group list --output json 2>/dev/null) + +if [[ $? -ne 0 ]] || [[ -z "$MG_LIST" ]] || [[ "$MG_LIST" == "[]" ]]; then + echo "âš ī¸ Cannot read management groups (insufficient RBAC or flat subscription)" + echo "Falling back to subscription-level discovery" + # Continue with subscription-only mode +fi +``` + +**Classification heuristics:** + +- Management groups named `*platform*`, `*connectivity*`, `*identity*`, `*management*` → Platform +- Management groups named `*landing*zone*`, `*workload*`, `*application*`, `*corp*`, `*online*` → Landing Zones +- Management groups named `*sandbox*`, `*decommission*` → Non-production +- Tags `mg-type`, `lz-type` override naming heuristics + +### 4. Subscription Classification + +Classify subscriptions as platform or application landing zones: + +```bash +# List all accessible subscriptions +SUBSCRIPTIONS=$(az account list --query "[?state=='Enabled']" --output json) + +# For each subscription, determine its role +for SUB in $(echo "$SUBSCRIPTIONS" | jq -r '.[].id'); do + SUB_NAME=$(echo "$SUBSCRIPTIONS" | jq -r --arg id "$SUB" '.[] | select(.id == $id) | .name') + + # Check management group placement + MG_PATH=$(az account management-group subscription show \ + --subscription-id "$SUB" \ + --query "managementGroupAncestorsChain[].displayName" \ + --output tsv 2>/dev/null || echo "unknown") + + # Classify by naming convention and MG placement + case "$SUB_NAME" in + *connectivity*|*network*|*hub*) + ROLE="connectivity" ;; + *identity*|*aad*) + ROLE="identity" ;; + *management*|*logging*|*monitor*) + ROLE="management" ;; + *sandbox*|*dev*|*test*) + ROLE="landing-zone-dev" ;; + *staging*|*uat*|*qa*) + ROLE="landing-zone-staging" ;; + *prod*|*production*) + ROLE="landing-zone-prod" ;; + *) + ROLE="landing-zone" ;; + esac +done +``` + +### 5. Policy Conflict Detection + +Discover policy assignments that may affect deployments: + +```bash +# Get policy assignments at management group and subscription level +POLICY_ASSIGNMENTS=$(az policy assignment list \ + --scope "/subscriptions/$SUBSCRIPTION_ID" \ + --query "[?enforcementMode=='Default']" \ + --output json) + +# Identify high-risk policies (Deny effect) +DENY_POLICIES=$(echo "$POLICY_ASSIGNMENTS" | jq '[ + .[] | select(.parameters != null) | + { + name: .displayName, + scope: .scope, + effect: ( + .parameters.effect.value // + .parameters.Effect.value // + "unknown" + ), + policyDefinitionId: .policyDefinitionId + } | select(.effect == "Deny" or .effect == "deny") +]') + +# Check for common deployment-blocking policies +# - Deny-Public-IP +# - Allowed-Locations +# - Deny-Storage-Public-Access +# - Require-Tag +``` + +**Policy conflict categories:** + +| Policy | Impact | Deployment Concern | +|--------|--------|--------------------| +| Deny-Public-IP | Blocks public IP creation | Use private endpoints or internal load balancers | +| Allowed-Locations | Restricts regions | Template must use allowed regions only | +| Deny-Storage-Public-Access | Blocks public storage | Require private endpoints for storage | +| Require-Tag | Blocks untagged resources | Ensure all resources have required tags | +| Deny-Subnet-Without-NSG | Blocks subnets without NSGs | Include NSG in template | + +### 6. Network Topology Discovery + +Discover hub-spoke networking configuration: + +```bash +# Find hub VNets (typically in connectivity subscription) +HUB_VNETS=$(az graph query -q " + Resources + | where type == 'microsoft.network/virtualnetworks' + | where name contains 'hub' or tags['network-role'] == 'hub' + | project id, name, location, subscriptionId, + addressPrefixes=properties.addressSpace.addressPrefixes +" --output json 2>/dev/null) + +# Find VNet peerings +PEERINGS=$(az graph query -q " + Resources + | where type == 'microsoft.network/virtualnetworks' + | mv-expand peering=properties.virtualNetworkPeerings + | project vnetName=name, peerName=peering.name, + remoteVnet=peering.properties.remoteVirtualNetwork.id, + peeringState=peering.properties.peeringState +" --output json 2>/dev/null) + +# Find private DNS zones +DNS_ZONES=$(az graph query -q " + Resources + | where type == 'microsoft.network/privatednszones' + | project id, name, subscriptionId +" --output json 2>/dev/null) +``` + +### 7. Shared Services Discovery + +Discover shared infrastructure for workload integration: + +```bash +# Find shared Log Analytics workspaces +LOG_ANALYTICS=$(az graph query -q " + Resources + | where type == 'microsoft.operationalinsights/workspaces' + | where tags['shared'] == 'true' or name contains 'platform' or name contains 'central' + | project id, name, subscriptionId, location, + sku=properties.sku.name, retentionDays=properties.retentionInDays +" --output json 2>/dev/null) + +# Find shared Container Registries +ACR=$(az graph query -q " + Resources + | where type == 'microsoft.containerregistry/registries' + | where tags['shared'] == 'true' or sku.name == 'Premium' + | project id, name, subscriptionId, location, sku=sku.name, + loginServer=properties.loginServer +" --output json 2>/dev/null) + +# Find shared Key Vaults +KEY_VAULTS=$(az graph query -q " + Resources + | where type == 'microsoft.keyvault/vaults' + | where tags['shared'] == 'true' or name contains 'platform' + | project id, name, subscriptionId, location +" --output json 2>/dev/null) +``` + +### 8. Generate Landing Zone Context File + +Assemble all discovery results into the context file: + +```bash +# The discover-lz.sh script outputs the full context +# See .azure/landing-zone-context.json for the output format + +cat .azure/landing-zone-context.json | jq '.' +``` + +**Output format (`landing-zone-context.json`):** + +Notable field semantics: + +- `landingZoneDetection` rates how confidently the discovered topology matches the canonical [Azure Landing Zone accelerator](https://azure.github.io/Azure-Landing-Zones/accelerator/) reference. Treat `confidence` as the primary signal — see the confidence model below. +- `networking.topology` is one of `"hub-spoke"` (hub VNet discovered), `"flat"` (discovery ran, no hub found), or `"unknown"` (discovery skipped or failed). See the Edge Cases table below. +- `policies.denyEffects[]` contains only assignments whose `effect` parameter resolves to `Deny`. `DeployIfNotExists`, `Modify`, and initiatives are excluded — see the Policy effect classification table below. +- `policies.alzCanonicalAssignments[]` lists policy assignments whose name matches a known ALZ accelerator policy (e.g. `Deploy-MDFC-Config`, `Deny-PublicIP`). High-precision ALZ signature regardless of `effect`. + +```json +{ + "discoveredAt": "2026-04-30T10:00:00Z", + "discoveryMethod": "auto", + "landingZoneDetection": { + "isLandingZone": true, + "confidence": "high", + "confidenceScore": 85, + "reference": "https://azure.github.io/Azure-Landing-Zones/accelerator/", + "matchedSignals": [ + { "signal": "alz-top-level-mgs", "points": 30, "evidence": "4/4 canonical top-level MGs (Platform, Landing zones, Sandbox, Decommissioned)" }, + { "signal": "platform-children", "points": 20, "evidence": "3/3 platform children (Connectivity, Identity, Management)" }, + { "signal": "alz-lz-archetypes", "points": 10, "evidence": "Corp and Online MGs present under Landing zones" }, + { "signal": "platform-subscriptions", "points": 10, "evidence": "3 platform subscription(s) classified" }, + { "signal": "hub-spoke-topology", "points": 5, "evidence": "Hub VNet(s): vnet-hub-eastus" }, + { "signal": "hub-in-connectivity-sub", "points": 5, "evidence": "Hub VNet sits in a connectivity-classified subscription" }, + { "signal": "alz-canonical-policies", "points": 5, "evidence": "1 canonical ALZ policy assignment(s): Deploy-MDFC-Config" } + ], + "missingSignals": [], + "checks": { + "topLevelMgs": { "platform": true, "landingZones": true, "sandbox": true, "decommissioned": true }, + "platformChildren": { "connectivity": true, "identity": true, "management": true }, + "lzChildren": { "corp": true, "online": true }, + "platformSubscriptionCount": 3, + "hubSpoke": true, + "hubInConnectivitySubscription": true, + "knownAlzPolicies": ["Deploy-MDFC-Config"] + } + }, + "managementGroups": { + "root": "Tenant Root Group", + "hierarchy": [ + { + "id": "/providers/Microsoft.Management/managementGroups/mg-platform", + "displayName": "Platform", + "role": "platform", + "children": ["mg-connectivity", "mg-identity", "mg-management"] + }, + { + "id": "/providers/Microsoft.Management/managementGroups/mg-landing-zones", + "displayName": "Landing Zones", + "role": "landing-zones", + "children": ["mg-corp", "mg-online"] + } + ] + }, + "subscriptions": { + "platform": [ + { "id": "...", "name": "sub-connectivity-prod", "role": "connectivity", "mgPath": "mg-platform/mg-connectivity" }, + { "id": "...", "name": "sub-identity-prod", "role": "identity", "mgPath": "mg-platform/mg-identity" }, + { "id": "...", "name": "sub-management-prod", "role": "management", "mgPath": "mg-platform/mg-management" } + ], + "landingZones": [ + { "id": "...", "name": "sub-app-dev", "environment": "dev", "mgPath": "mg-landing-zones/mg-corp" }, + { "id": "...", "name": "sub-app-prod", "environment": "prod", "mgPath": "mg-landing-zones/mg-corp" } + ] + }, + "sharedServices": { + "logAnalytics": { "id": "...", "name": "log-platform-prod-eastus", "subscription": "sub-management-prod", "location": "eastus" }, + "containerRegistry": { "id": "...", "name": "crplatformprod", "subscription": "sub-management-prod", "location": "eastus" }, + "keyVault": { "id": "...", "name": "kv-platform-prod-eus", "subscription": "sub-management-prod", "location": "eastus" } + }, + "networking": { + "topology": "hub-spoke", + "hubs": [ + { "id": "...", "name": "vnet-hub-eastus", "subscription": "sub-connectivity-prod", "location": "eastus", "addressPrefixes": ["10.0.0.0/16"] } + ], + "privateDnsZones": [ + "privatelink.blob.core.windows.net", + "privatelink.vaultcore.azure.net", + "privatelink.azurewebsites.net" + ], + "peerings": [] + }, + "policies": { + "denyEffects": [ + { "name": "Deny-Public-IP", "scope": "/providers/Microsoft.Management/managementGroups/mg-landing-zones", "impact": "Blocks public IP creation in landing zone subscriptions" } + ], + "auditEffects": [], + "allowedLocations": ["eastus", "westus2", "westeurope"], + "requiredTags": ["Environment", "Project", "CostCenter"], + "alzCanonicalAssignments": ["Deploy-MDFC-Config", "Deploy-Diag-LogsCat-LAW"] + }, + "currentIdentity": { + "user": "user@contoso.com", + "tenantId": "...", + "roles": [] + } +} +``` + +### 9. Landing Zone Visualization + +Generate a Mermaid diagram of the management group hierarchy: + +````markdown +## Landing Zone Topology + +```mermaid +graph TD + TRG["đŸĸ Tenant Root Group"] + TRG --> MG_PLATFORM["📋 Platform"] + TRG --> MG_LZ["📋 Landing Zones"] + TRG --> MG_SANDBOX["📋 Sandbox"] + TRG --> MG_DECOM["📋 Decommissioned"] + + MG_PLATFORM --> MG_CONN["🔌 Connectivity"] + MG_PLATFORM --> MG_IDENTITY["🔐 Identity"] + MG_PLATFORM --> MG_MGMT["📊 Management"] + + MG_CONN --> SUB_CONN["đŸ’ŗ sub-connectivity-prod"] + MG_IDENTITY --> SUB_ID["đŸ’ŗ sub-identity-prod"] + MG_MGMT --> SUB_MGMT["đŸ’ŗ sub-management-prod"] + + MG_LZ --> MG_CORP["đŸ—ī¸ Corp"] + MG_LZ --> MG_ONLINE["🌐 Online"] + + MG_CORP --> SUB_DEV["đŸ’ŗ sub-app-dev"] + MG_CORP --> SUB_PROD["đŸ’ŗ sub-app-prod"] + + SUB_CONN -.->|"hub VNet"| VNET_HUB["🔗 vnet-hub-eastus"] + SUB_MGMT -.->|"shared"| LOG["📊 log-platform-prod-eastus"] + SUB_MGMT -.->|"shared"| ACR["đŸ“Ļ crplatformprod"] + + style SUB_DEV fill:#e1f5fe + style SUB_PROD fill:#fff3e0 + style VNET_HUB fill:#e8f5e9 + style LOG fill:#f3e5f5 +``` +```` + +## Landing Zone Detection Confidence + +Discovery rates every tenant against the canonical [Azure Landing Zone (ALZ) accelerator](https://azure.github.io/Azure-Landing-Zones/accelerator/) reference and emits a `landingZoneDetection` block in the context file. The score (0–100) drives downstream tooling decisions: trust the auto-classified MGs/subscriptions vs. fall back to manual injection or user confirmation. + +### Weighted Signals + +| Signal | Max points | Source | +|---|---:|---| +| `alz-top-level-mgs` — `Platform`, `Landing zones`, `Sandbox`, `Decommissioned` | 30 | Management group hierarchy | +| `platform-children` — `Connectivity`, `Identity`, `Management` under Platform | 20 | Management group hierarchy | +| `alz-lz-archetypes` — both `Corp` and `Online` under Landing zones | 10 | Management group hierarchy | +| `platform-subscriptions` — subs classified as connectivity/identity/management/platform-other | 10 | Subscription role classification | +| `hub-spoke-topology` — at least one hub VNet discovered | 5 | Network topology | +| `hub-in-connectivity-sub` — hub VNet lives in a connectivity subscription | 5 | Network + subscription cross-check | +| `alz-canonical-policies` — assignments matching ALZ accelerator policy names (e.g. `Deploy-MDFC-Config`, `Deny-PublicIP`) | 15 (3 × 5) | Policy assignments | + +Top-level and platform-children signals scale: 4/4 top-level MGs = 30, 3/4 = 20, 2/4 = 10; 3/3 platform children = 20, 2/3 = 10; â‰Ĩ3 platform subs = 10, â‰Ĩ1 = 5; ALZ canonical policy points = `min(matches × 5, 15)`. + +### Confidence Buckets + +| `confidence` | Score range | `isLandingZone` | Suggested treatment | +|---|---:|---:|---| +| `high` | â‰Ĩ 70 | `true` | Trust auto-classification. Proceed with hub-attach + shared services without prompting. | +| `medium` | 40–69 | `true` | Surface matched + missing signals to the user. Ask to confirm before assuming the tenant is ALZ-managed. | +| `low` | 10–39 | `false` | Treat as standalone tenant. Mention partial signals so the user can decide whether to manually inject. | +| `none` | < 10 | `false` | No ALZ signature. Default to flat-tenant assumptions. Recommend manual injection only if the user knows the tenant *is* ALZ-managed. | + +Inspect the full breakdown: + +```bash +jq '.landingZoneDetection' .azure/landing-zone-context.json +``` + +`matchedSignals[]` records every signal that scored, including the points awarded and a human-readable evidence string. `missingSignals[]` lists the signals that scored zero — useful for telling the user *why* confidence is low. `checks` exposes the raw booleans/counts the scorer evaluated. + +## Manual Injection + +When discovery cannot reach the landing zone (cross-tenant, limited RBAC, air-gapped environments), users can inject context manually. + +### Option A: Provide the Context File Directly + +Create or edit `.azure/landing-zone-context.json` with your landing zone topology. Set `"discoveryMethod": "manual"`. + +### Option B: Use the Injection Script + +The injection script ships in both shells (bash and PowerShell parity ports). Both write an identical `landing-zone-context.json`: + +```bash +# Bash (Linux/macOS, git-bash on Windows) +.github/skills/azure-landing-zone-discovery/scripts/inject-lz.sh \ + --hub-vnet-id "/subscriptions/.../resourceGroups/.../providers/Microsoft.Network/virtualNetworks/vnet-hub" \ + --log-analytics-id "/subscriptions/.../resourceGroups/.../providers/Microsoft.OperationalInsights/workspaces/log-central" \ + --acr-id "/subscriptions/.../resourceGroups/.../providers/Microsoft.ContainerRegistry/registries/crshared" \ + --allowed-locations "eastus,westus2" \ + --required-tags "Environment,Project,CostCenter" +``` + +```powershell +# PowerShell (Windows, or pwsh on any platform) +.github/skills/azure-landing-zone-discovery/scripts/inject-lz.ps1 ` + -HubVnetId "/subscriptions/.../resourceGroups/.../providers/Microsoft.Network/virtualNetworks/vnet-hub" ` + -LogAnalyticsId "/subscriptions/.../resourceGroups/.../providers/Microsoft.OperationalInsights/workspaces/log-central" ` + -AcrId "/subscriptions/.../resourceGroups/.../providers/Microsoft.ContainerRegistry/registries/crshared" ` + -AllowedLocations "eastus,westus2" ` + -RequiredTags "Environment,Project,CostCenter" +``` + +### Option C: Interactive Questionnaire + +When invoked without arguments, the agent asks targeted questions: + +```markdown +I'll help you set up your landing zone context. Please answer what you can: + +1. **Management Groups:** What management group should workloads land in? + - Provide the management group name or ID, or type "none" for flat subscription + +2. **Hub Networking:** Do you have a hub VNet to peer with? + - Provide the hub VNet resource ID, or type "none" + +3. **Shared Log Analytics:** Which Log Analytics workspace should resources send diagnostics to? + - Provide the workspace resource ID, or type "none" + +4. **Shared Container Registry:** Do you have a shared ACR for container workloads? + - Provide the ACR resource ID, or type "none" + +5. **Azure Policies:** Are there Azure Policies at the management group level that restrict: + - Public IPs? (yes/no) + - Allowed regions? (list regions, or "any") + - Required tags? (list tag names, or "none") + - Public storage access? (yes/no) + +6. **Landing Zone Subscriptions:** List your subscriptions by environment: + - Dev: subscription ID or name + - Staging: subscription ID or name + - Prod: subscription ID or name +``` + +## Integration with Deployment Workflow + +### Stage 1: Requirements Gathering (Landing Zone-Aware) + +When landing zone context is available: + +- **Auto-select target subscription:** Route `dev` deployments to dev landing zone, `prod` to prod landing zone +- **Warn on platform subscriptions:** If user targets a platform subscription (connectivity/identity/management), warn that it's not for workloads +- **Show policy constraints:** Display Deny-effect policies that may affect the deployment before template generation + +### Stage 2: Template Generation (Landing Zone-Aware) + +When landing zone context is available: + +- **Auto-connect diagnostics:** Route `diagnosticSettings` to the shared Log Analytics workspace +- **Hub VNet peering:** Generate VNet peering to the hub VNet for workloads that need network connectivity +- **Private endpoints:** Use discovered private DNS zones for private endpoint DNS integration +- **Container Registry:** Reference shared ACR for container workloads instead of creating a new one +- **Policy-compliant defaults:** Use allowed locations, apply required tags, avoid public IPs if denied + +### Stage 2.5: Security Gate (Landing Zone-Aware) + +- **Policy validation:** Check if the deployment template would be denied by management group policies +- **Flag conflicts:** Warn if template uses resources/configurations blocked by landing zone policies +- **Suggest alternatives:** Recommend policy-compliant configurations (e.g., private endpoints instead of public IPs) + +## Edge Cases + +| Scenario | Handling | `networking.topology` | +|----------|----------|------------------------| +| Hub VNet found (named/tagged `hub`) | Record hubs and peerings | `hub-spoke` | +| Network discovery ran, no hub found | Treat as single-VNet / non-enterprise environment | `flat` | +| Network discovery skipped (`--skip-network`) or failed | Downstream must not assume any topology | `unknown` | +| No management groups (flat subscription) | Skip hierarchy discovery, use subscription-level context only | (independent of topology field) | +| Cross-tenant landing zone (CSP, MCA) | Manual injection required — discovery limited to current tenant | (set by `inject-lz.sh`) | +| Limited RBAC (no management group read) | Fall back to subscription-level discovery + manual injection for hierarchy | (network may still run) | +| Multiple landing zones for same environment | Present options, let user choose | (independent) | +| Landing zone not yet deployed | Guide user to ALZ accelerator or suggest manual setup | `flat` | +| `landingZoneDetection.confidence` is `low` or `none` | Treat tenant as standalone; do not auto-attach to hub/shared services. If user knows it *is* ALZ-managed, fall back to manual injection | (preserved from discovery) | +| `landingZoneDetection.confidence` is `medium` | Surface `matchedSignals` and `missingSignals` to the user; ask to confirm ALZ-managed behavior before relying on auto-classification | (preserved from discovery) | +| Stale context (old discovery) | Warn if context is older than 7 days, offer refresh | (preserved from prior run) | +| No Azure Resource Graph access | Fall back to individual `az` CLI queries (slower, current-subscription only) | `hub-spoke` or `flat` | + +### Policy effect classification + +`discover-lz.sh` reads `parameters.effect.value` (or `Effect.value`) from each assignment and partitions the result: + +| Effect parameter (case-insensitive) | Goes into | +|-------------------------------------|-----------| +| `Deny` | `policies.denyEffects[]` | +| `Audit`, `AuditIfNotExists` | `policies.auditEffects[]` | +| `DeployIfNotExists`, `Modify`, `Append`, `Disabled` | excluded from both arrays | +| Initiatives (`policySetDefinitions/*`) with no top-level effect param | excluded — effect varies by inner definition | +| Missing or unrecognized effect param | excluded | + +Downstream consumers (Stage 2.5 Security Gate, Stage 1 warnings) should treat `denyEffects` as deployment-blocking and `auditEffects` as informational. + +## Best Practices + +1. **Run discovery at onboarding time** — Include in the `/git-ape-onboarding` flow +2. **Refresh periodically** — Re-run discovery if the context is older than 7 days +3. **Commit context to repo** — `.azure/landing-zone-context.json` should be version-controlled for team consistency +4. **Use tags for classification** — Tag management groups and subscriptions with `lz-role`, `environment`, `shared=true` for reliable discovery +5. **Review policy conflicts early** — Check policies before template generation, not at deploy time + +## Related Skills + +- `/azure-policy-advisor` — Detailed policy compliance assessment for ARM templates +- `/azure-security-analyzer` — Security best practices analysis +- `/azure-resource-visualizer` — Live resource group visualization +- `/azure-drift-detector` — Configuration drift detection +- `/prereq-check` — Verify Azure CLI and authentication prerequisites diff --git a/website/docs/skills/azure-policy-advisor.md b/website/docs/skills/azure-policy-advisor.md index c36f266..95dda58 100644 --- a/website/docs/skills/azure-policy-advisor.md +++ b/website/docs/skills/azure-policy-advisor.md @@ -44,6 +44,27 @@ Read compliance preferences from the `## Compliance & Azure Policy` section in ` - **Enforcement mode** (Audit or Deny) - **Policy categories** (identity, networking, storage, compute, monitoring, tagging) +**Also load landing zone context** if `.azure/landing-zone-context.json` exists (produced by `/azure-landing-zone-discovery`): + +```bash +LZ_CONTEXT_FILE=".azure/landing-zone-context.json" +if [[ -f "$LZ_CONTEXT_FILE" ]]; then + LZ_CONFIDENCE=$(jq -r '.landingZoneDetection.confidence // "unknown"' "$LZ_CONTEXT_FILE") + LZ_DENY_EFFECTS=$(jq -r '.policies.denyEffects[]? | .displayName // .name' "$LZ_CONTEXT_FILE") + LZ_AUDIT_EFFECTS=$(jq -r '.policies.auditEffects[]? | .displayName // .name' "$LZ_CONTEXT_FILE") + LZ_CANONICAL=$(jq -r '.policies.alzCanonicalAssignments[]?' "$LZ_CONTEXT_FILE") + LZ_ALLOWED_LOCATIONS=$(jq -r '.policies.allowedLocations[]?' "$LZ_CONTEXT_FILE") + LZ_REQUIRED_TAGS=$(jq -r '.policies.requiredTags[]?' "$LZ_CONTEXT_FILE") +fi +``` + +Use this data in two ways: + +1. **Dedupe**: Any recommendation that matches a name in `denyEffects[]`, `auditEffects[]`, or `alzCanonicalAssignments[]` should be marked `✓ inherited` in Part 2 — do not re-recommend it. +2. **Align defaults**: Use `allowedLocations[]` and `requiredTags[]` to seed Part 1 recommendations (region/tag policies) instead of guessing. + +**Confidence gating:** If `LZ_CONFIDENCE` is `low` or `none`, treat the LZ policy lists as informational. Do not rely on them as the source of truth — the tenant may not actually be ALZ-managed. + If no compliance section exists in copilot-instructions.md, ask the user: ``` diff --git a/website/docs/skills/git-ape-onboarding.md b/website/docs/skills/git-ape-onboarding.md index 6c33126..ce5a87a 100644 --- a/website/docs/skills/git-ape-onboarding.md +++ b/website/docs/skills/git-ape-onboarding.md @@ -127,7 +127,8 @@ OIDC_PREFIX="repository_owner_id::repository_id:" 8. Create GitHub environments and branch policies when permissions allow. 9. Scaffold workflow files and deployment standards into the user's working copy (see below). 10. Capture compliance and Azure Policy preferences (see below). -11. Verify federated credentials, role assignments, and secrets. +11. **Run landing zone discovery** (see Step 11). +12. Verify federated credentials, role assignments, and secrets. ### Step 9: Scaffold workflow files and deployment standards @@ -202,6 +203,26 @@ After RBAC and environment setup, ask the user about compliance requirements and preferences and a suggested patch in chat so the user can apply it. - In all cases, leave changes unstaged and let the user commit them. +### Step 11: Landing Zone Discovery + +Run `/azure-landing-zone-discovery` against each onboarded subscription to populate `.azure/landing-zone-context.json`. This file is consumed by the requirements gatherer, template generator, and policy advisor so deployments are landing-zone-aware from the first run. + +```bash +.github/skills/azure-landing-zone-discovery/scripts/discover-lz.sh \ + --subscription "$AZURE_SUBSCRIPTION_ID" \ + --output-file .azure/landing-zone-context.json +``` + +**Inspect the result:** + +```bash +jq '.landingZoneDetection | {isLandingZone, confidence, confidenceScore}' .azure/landing-zone-context.json +``` + +- `confidence` = `high` or `medium` → commit `.azure/landing-zone-context.json` to the repo so the team shares the same topology view. +- `confidence` = `low` or `none` → the workspace will deploy in standalone mode. Tell the user they can later run `inject-lz.sh` for manual injection if they know the tenant *is* ALZ-managed. +- If discovery fails (e.g., no management group read permission), document the limitation and proceed without the context — the user can re-run discovery once permissions are granted. + ## Safe-Execution Rules 1. Echo target repository and subscription(s) before execution. diff --git a/website/docs/skills/overview.md b/website/docs/skills/overview.md index 52c46d4..bdefb2d 100644 --- a/website/docs/skills/overview.md +++ b/website/docs/skills/overview.md @@ -44,6 +44,7 @@ Skills are focused capabilities invoked by agents at specific stages of the depl | Skill | Description | Invocable | |-------|-------------|:---------:| +| [Azure Landing Zone Discovery](./azure-landing-zone-discovery) | Auto-discover Azure landing zone topology including management groups, platform vs. application subscriptions, policy assignments, hub-spoke networking, and shared services. Use when deploying into enterprise environments, checking policy conflicts, or connecting to shared infrastructure. | ✅ | | [Azure Stack Deploy](./azure-stack-deploy) | Run an Azure Deployment Stack create (subscription scope) for a prepared Git-Ape deployment artifact and write state.json (schemaVersion 1.0). Use locally so the result matches the CI deploy workflow. | ✅ | | [Azure Stack Destroy](./azure-stack-destroy) | Tear down a Git-Ape deployment by ID. Reads `state.json` under `.azure/deployments//` to delete the Azure Deployment Stack and purge soft-deleted Key Vault / Cognitive Services. Refuses to run without `state.json`. Use for any local CLI or VS Code Git-Ape teardown so the result matches the CI destroy workflow. | ✅ | diff --git a/website/docs/use-cases/landing-zone-aware-deployment.md b/website/docs/use-cases/landing-zone-aware-deployment.md new file mode 100644 index 0000000..d597fdf --- /dev/null +++ b/website/docs/use-cases/landing-zone-aware-deployment.md @@ -0,0 +1,181 @@ +--- +title: "Landing Zone-Aware Deployment" +sidebar_label: "Landing Zone Discovery" +sidebar_position: 9 +description: "Auto-discover enterprise Azure landing zone topology so deployments connect to shared services and respect tenant policies" +keywords: [landing zone, ALZ, CAF, management groups, hub-spoke, shared services, enterprise] +--- + +# Landing Zone-Aware Deployment + +> **TL;DR** — Run `/azure-landing-zone-discovery` once. Git-Ape reads `.azure/landing-zone-context.json` from every later stage and tailors deployments to your tenant's management groups, hub VNet, shared Log Analytics/ACR, allowed regions, required tags, and Deny-effect policies. + +## Why It Matters + +Enterprise Azure tenants follow the [Cloud Adoption Framework landing zone architecture](https://learn.microsoft.com/azure/cloud-adoption-framework/ready/landing-zone/) — workloads land in application subscriptions, peer to a connectivity hub VNet, ship diagnostics to a central Log Analytics workspace, pull container images from a shared ACR, and obey policies enforced at the management group level. + +Without landing zone awareness, Git-Ape would: + +- Recommend regions blocked by `Allowed-Locations` +- Create standalone Log Analytics workspaces alongside the central one +- Provision a fresh ACR per deployment +- Generate public IPs that get denied at deploy time +- Re-recommend policies the tenant already enforces + +Landing zone discovery fixes all of that. + +## How It Works + +```mermaid +graph TD + USER["User: /azure-landing-zone-discovery"] --> DISC["Discovery script
walks Azure CLI + Resource Graph"] + DISC --> MGS["Management groups
(Platform, Landing zones, Sandbox)"] + DISC --> SUBS["Subscriptions
classified by role"] + DISC --> NET["Hub VNet
+ Private DNS zones"] + DISC --> SHARED["Shared LAW / ACR / Key Vault"] + DISC --> POL["Policies
(Deny / Audit / ALZ-canonical)"] + MGS --> CTX[".azure/landing-zone-context.json"] + SUBS --> CTX + NET --> CTX + SHARED --> CTX + POL --> CTX + CTX --> SCORE["landingZoneDetection
confidence + score"] + SCORE --> GATHER["@azure-requirements-gatherer
(picks correct subscription,
warns on blocked regions)"] + SCORE --> TEMPLATE["@azure-template-generator
(wires shared services,
injects required tags)"] + SCORE --> POLICY["@azure-policy-advisor
(dedupes recommendations,
marks '✓ inherited')"] +``` + +## Run It + +### Auto-discovery (default) + +```text +/azure-landing-zone-discovery +``` + +The skill calls `az account management-group list`, `az policy assignment list`, Azure Resource Graph for shared services, and writes the result to `.azure/landing-zone-context.json`. + +### Inspect what was discovered + +```bash +jq '.landingZoneDetection | {isLandingZone, confidence, confidenceScore}' \ + .azure/landing-zone-context.json +``` + +```json +{ + "isLandingZone": true, + "confidence": "high", + "confidenceScore": 85 +} +``` + +See [Landing Zone Context](/docs/deployment/landing-zone-context) for the full schema. + +### Manual injection (cross-tenant / limited RBAC) + +When discovery cannot reach management groups (CSP, MCA, air-gapped tenants), inject context manually: + +```bash +.github/skills/azure-landing-zone-discovery/scripts/inject-lz.sh \ + --hub-vnet-id "/subscriptions/.../virtualNetworks/vnet-hub" \ + --log-analytics-id "/subscriptions/.../workspaces/log-central" \ + --acr-id "/subscriptions/.../registries/crshared" \ + --allowed-locations "eastus,westus2" \ + --required-tags "Environment,Project,CostCenter" +``` + +## Confidence Buckets + +Discovery scores every tenant against the canonical [ALZ accelerator](https://azure.github.io/Azure-Landing-Zones/accelerator/) reference. Each consumer gates its behavior on the bucket. + +| Confidence | Score | Git-Ape behavior | +|---|---:|---| +| **`high`** | â‰Ĩ 70 | Auto-apply context. Pick correct subscription, peer to hub, wire shared services, inject required tags. No prompts. | +| **`medium`** | 40–69 | Surface matched + missing signals; ask user to confirm before treating tenant as ALZ-managed. | +| **`low`** | 10–39 | Treat as standalone tenant. Use only `allowedLocations` / `requiredTags` if explicitly set. | +| **`none`** | < 10 | No ALZ signature. Default to flat-tenant assumptions. Recommend manual injection only if the user knows the tenant *is* ALZ-managed. | + +## How Each Stage Uses It + +| Stage | Consumer | What it reads | What changes | +|---|---|---|---| +| **0. Pre-flight** | `@git-ape` orchestrator | `landingZoneDetection`, `discoveredAt` | Surfaces confidence, warns on stale context (>7 days), propagates path to subagents | +| **1. Requirements** | `@azure-requirements-gatherer` | `subscriptions.landingZones[]`, `policies.allowedLocations[]` | Routes `dev`/`prod` to correct subscription, blocks deny-listed regions early | +| **2. Templates** | `@azure-template-generator` | `sharedServices.*`, `networking.hubs[]`, `policies.requiredTags[]` | Wires diagnostics to shared LAW, references shared ACR, generates hub peering, injects required tags | +| **2.5. Security** | Security gate | `policies.denyEffects[]` | Cross-checks template against deny-effect policies before deployment | +| **Policy review** | `@azure-policy-advisor` | `policies.alzCanonicalAssignments[]`, `denyEffects[]`, `auditEffects[]` | Marks already-enforced policies as "✓ inherited"; doesn't re-recommend them | + +## Walkthrough + +```text +You: /azure-landing-zone-discovery + +🔍 Discovering landing zone topology... + ✓ Management groups: 4 top-level (Platform, Landing zones, Sandbox, Decommissioned) + ✓ Platform children: Connectivity, Identity, Management + ✓ Subscriptions classified: 3 platform / 5 landing zones + ✓ Hub VNet: vnet-hub-eastus (sub-connectivity-prod) + ✓ Shared services: log-platform-prod-eastus, crplatformprod + ✓ Policies: 2 Deny, 1 Audit, 2 ALZ-canonical assignments + +✅ Confidence: high (score 85) + Reference: https://azure.github.io/Azure-Landing-Zones/accelerator/ + +Saved → .azure/landing-zone-context.json +Commit this file so the team shares the same topology view. +``` + +Now run a deployment: + +```text +You: @git-ape deploy a Python function app + +đŸ›Ģ Stage 0: Landing Zone Context + ✓ Context found (confidence: high, age: 1 day) + → Auto-apply mode + +đŸ›Ģ Stage 1: Requirements + ✓ Target subscription: sub-app-dev (auto-selected, environment=dev) + ⚠ Region "centralus" not in allowedLocations [eastus, westus2, westeurope] + → Defaulting to eastus + ✓ Required tags injected: Environment, Project, CostCenter + +đŸ›Ģ Stage 2: Templates + ✓ Diagnostics → log-platform-prod-eastus (shared) + ✓ Container Apps env peered to vnet-hub-eastus + ✓ Private endpoint linked to privatelink.azurewebsites.net + ⚠ Deny-Public-IP policy active — using private endpoint instead + +đŸ›Ģ Stage 2.5: Security Gate + ✓ Template compliant with 2 deny-effect policies + +đŸ›Ģ Stage 3: Deploy + ✓ Deployment succeeded +``` + +## When to Refresh + +Re-run `/azure-landing-zone-discovery` when: + +- The context is older than 7 days (Git-Ape warns automatically) +- A new shared service was provisioned (e.g., new central Log Analytics) +- A new management group policy was assigned +- A new application landing zone subscription was created +- You switched tenants or rotated credentials + +## Commit or Ignore? + +| Confidence | Recommendation | +|---|---| +| `high` / `medium` | **Commit** `.azure/landing-zone-context.json` so the team shares one topology view | +| `low` / `none` | Decide per team — the file is mostly empty; manual injection results should usually be committed | +| Personal sandbox | Ignore — add to `.gitignore` to avoid leaking tenant identifiers in shared repos | + +## Related + +- [Skills: Azure Landing Zone Discovery](/docs/skills/azure-landing-zone-discovery) — full procedure and CLI reference +- [Deployment: Landing Zone Context](/docs/deployment/landing-zone-context) — schema and field-level reference +- [Policy Compliance](/docs/use-cases/policy-compliance) — how policy recommendations dedupe against the tenant +- [Onboarding](/docs/getting-started/onboarding) — discovery runs as step 10 of the onboarding playbook +- [For Platform Engineering](/docs/personas/for-platform-engineering) — guardrails and policy enforcement diff --git a/website/docs/use-cases/policy-compliance.md b/website/docs/use-cases/policy-compliance.md index 167cc98..44f1da7 100644 --- a/website/docs/use-cases/policy-compliance.md +++ b/website/docs/use-cases/policy-compliance.md @@ -72,5 +72,6 @@ Git-Ape supports assessment against: - [Skills: Azure Policy Advisor](/docs/skills/azure-policy-advisor) - [Agents: Azure Policy Advisor](/docs/agents/azure-policy-advisor) +- [Landing Zone-Aware Deployment](/docs/use-cases/landing-zone-aware-deployment) — context that dedupes recommendations against tenant-enforced policies - [Security Analysis](/docs/use-cases/security-analysis) - [For Platform Engineering](/docs/personas/for-platform-engineering) diff --git a/website/sidebars.ts b/website/sidebars.ts index 62e0e9a..6b98c1f 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -37,6 +37,7 @@ const sidebars: SidebarsConfig = { 'use-cases/security-analysis', 'use-cases/waf-review', 'use-cases/policy-compliance', + 'use-cases/landing-zone-aware-deployment', 'use-cases/drift-detection', 'use-cases/import-existing-infra', 'use-cases/cicd-pipeline', @@ -85,6 +86,7 @@ const sidebars: SidebarsConfig = { items: [ 'deployment/state', 'deployment/drift-detection', + 'deployment/landing-zone-context', 'deployment/examples', ], }, From 33724065dafddcb00919bc460211d49bf35b5b03 Mon Sep 17 00:00:00 2001 From: Arnaud Lheureux Date: Mon, 15 Jun 2026 10:57:59 +0800 Subject: [PATCH 2/4] feat(skills): sharpen landing-zone discovery triggers and guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add a Do NOT use for block and tighten the description for routing - replace the Step 8 cat stub with assembly summary and report-back guidance - make manual injection prescriptive about inject-lz flags 🧭 - Generated by Copilot --- .../azure-landing-zone-discovery/SKILL.md | 24 ++++++++++++----- .../skills/azure-landing-zone-discovery.md | 26 +++++++++++++------ website/docs/skills/overview.md | 2 +- 3 files changed, 36 insertions(+), 16 deletions(-) diff --git a/.github/skills/azure-landing-zone-discovery/SKILL.md b/.github/skills/azure-landing-zone-discovery/SKILL.md index 004a9e7..4316c25 100644 --- a/.github/skills/azure-landing-zone-discovery/SKILL.md +++ b/.github/skills/azure-landing-zone-discovery/SKILL.md @@ -1,6 +1,6 @@ --- name: azure-landing-zone-discovery -description: "Auto-discover Azure landing zone topology including management groups, platform vs. application subscriptions, policy assignments, hub-spoke networking, and shared services. Use when deploying into enterprise environments, checking policy conflicts, or connecting to shared infrastructure." +description: "Auto-discover enterprise Azure landing zone topology — management groups, platform vs. application subscriptions, policy assignments, hub-spoke networking, and shared services — and write .azure/landing-zone-context.json. USE FOR: discovering the landing zone, mapping management groups, choosing a target subscription, connecting to a hub VNet or shared services, or manually injecting context for air-gapped/cross-tenant tenants. NOT FOR: deploying individual resources, CAF name lookups, or per-template policy compliance checks." argument-hint: "Discovery scope or manual injection mode (e.g. 'full discovery', 'inject context', 'check policies for eastus')" user-invocable: true last_updated: "2026-06-15" @@ -21,6 +21,16 @@ Enterprise Azure environments follow the [Cloud Adoption Framework landing zone - User asks: "connect to hub VNet", "use shared Log Analytics", "which subscription for prod?" - User provides manual landing zone context for air-gapped or cross-tenant environments +**Do NOT use for:** + +- Deploying or configuring individual resources → use `git-ape` / the deployment agents +- CAF abbreviation or resource-name lookups → use `/azure-naming-research` +- Policy compliance checks on a specific ARM template → use `/azure-policy-advisor` +- Security analysis of a template or resource → use `/azure-security-analyzer` +- Viewing or visualizing live resources in one resource group → use `/azure-resource-visualizer` + +This skill is about **tenant/landing-zone topology**, not single-resource actions. + **Output:** - `.azure/landing-zone-context.json` — Machine-readable landing zone topology @@ -249,13 +259,13 @@ KEY_VAULTS=$(az graph query -q " ### 8. Generate Landing Zone Context File -Assemble all discovery results into the context file: +The discovery script assembles every section above — management-group hierarchy, subscription classification, networking topology, policy partitions, shared services, and the `landingZoneDetection` confidence block — into a single `.azure/landing-zone-context.json`. No manual assembly is needed. -```bash -# The discover-lz.sh script outputs the full context -# See .azure/landing-zone-context.json for the output format +After discovery completes, **summarize the result back to the user**, explicitly covering: the management-group hierarchy, which subscriptions are platform vs. application landing zones, the network topology (hub-spoke vs. flat) and any hub VNet to peer, the policy constraints found (deny effects, allowed locations, required tags), the shared services available, and the detection `confidence`. If discovery was blocked by limited RBAC or a cross-tenant boundary, say so and point the user to the manual-injection path below. -cat .azure/landing-zone-context.json | jq '.' +```bash +# Inspect the assembled context +jq '.' .azure/landing-zone-context.json ``` **Output format (`landing-zone-context.json`):** @@ -435,7 +445,7 @@ jq '.landingZoneDetection' .azure/landing-zone-context.json ## Manual Injection -When discovery cannot reach the landing zone (cross-tenant, limited RBAC, air-gapped environments), users can inject context manually. +When discovery cannot reach the landing zone (cross-tenant, limited RBAC, air-gapped environments), users can inject context manually. **Always drive this through the `inject-lz.sh` (or `inject-lz.ps1`) script with its canonical flags — `--hub-vnet-id`, `--log-analytics-id`, `--acr-id`, `--allowed-locations`, `--required-tags` — rather than hand-writing the JSON**, so the schema and `discoveryMethod` stay correct (Option B). Fall back to editing the file directly (Option A) or the interactive questionnaire (Option C) only when the script cannot be run. ### Option A: Provide the Context File Directly diff --git a/website/docs/skills/azure-landing-zone-discovery.md b/website/docs/skills/azure-landing-zone-discovery.md index f236213..ddbbb5f 100644 --- a/website/docs/skills/azure-landing-zone-discovery.md +++ b/website/docs/skills/azure-landing-zone-discovery.md @@ -1,7 +1,7 @@ --- title: "Azure Landing Zone Discovery" sidebar_label: "Azure Landing Zone Discovery" -description: "Auto-discover Azure landing zone topology including management groups, platform vs. application subscriptions, policy assignments, hub-spoke networking, and shared services. Use when deploying into enterprise environments, checking policy conflicts, or connecting to shared infrastructure." +description: "Auto-discover enterprise Azure landing zone topology — management groups, platform vs. application subscriptions, policy assignments, hub-spoke networking, and shared services — and write .azure/landing-zone-context.json. USE FOR: discovering the landing zone, mapping management groups, choosing a target subscription, connecting to a hub VNet or shared services, or manually injecting context for air-gapped/cross-tenant tenants. NOT FOR: deploying individual resources, CAF name lookups, or per-template policy compliance checks." --- @@ -9,7 +9,7 @@ description: "Auto-discover Azure landing zone topology including management gro # Azure Landing Zone Discovery -> Auto-discover Azure landing zone topology including management groups, platform vs. application subscriptions, policy assignments, hub-spoke networking, and shared services. Use when deploying into enterprise environments, checking policy conflicts, or connecting to shared infrastructure. +> Auto-discover enterprise Azure landing zone topology — management groups, platform vs. application subscriptions, policy assignments, hub-spoke networking, and shared services — and write .azure/landing-zone-context.json. USE FOR: discovering the landing zone, mapping management groups, choosing a target subscription, connecting to a hub VNet or shared services, or manually injecting context for air-gapped/cross-tenant tenants. NOT FOR: deploying individual resources, CAF name lookups, or per-template policy compliance checks. ## Details @@ -38,6 +38,16 @@ Enterprise Azure environments follow the [Cloud Adoption Framework landing zone - User asks: "connect to hub VNet", "use shared Log Analytics", "which subscription for prod?" - User provides manual landing zone context for air-gapped or cross-tenant environments +**Do NOT use for:** + +- Deploying or configuring individual resources → use `git-ape` / the deployment agents +- CAF abbreviation or resource-name lookups → use `/azure-naming-research` +- Policy compliance checks on a specific ARM template → use `/azure-policy-advisor` +- Security analysis of a template or resource → use `/azure-security-analyzer` +- Viewing or visualizing live resources in one resource group → use `/azure-resource-visualizer` + +This skill is about **tenant/landing-zone topology**, not single-resource actions. + **Output:** - `.azure/landing-zone-context.json` — Machine-readable landing zone topology @@ -266,13 +276,13 @@ KEY_VAULTS=$(az graph query -q " ### 8. Generate Landing Zone Context File -Assemble all discovery results into the context file: +The discovery script assembles every section above — management-group hierarchy, subscription classification, networking topology, policy partitions, shared services, and the `landingZoneDetection` confidence block — into a single `.azure/landing-zone-context.json`. No manual assembly is needed. -```bash -# The discover-lz.sh script outputs the full context -# See .azure/landing-zone-context.json for the output format +After discovery completes, **summarize the result back to the user**, explicitly covering: the management-group hierarchy, which subscriptions are platform vs. application landing zones, the network topology (hub-spoke vs. flat) and any hub VNet to peer, the policy constraints found (deny effects, allowed locations, required tags), the shared services available, and the detection `confidence`. If discovery was blocked by limited RBAC or a cross-tenant boundary, say so and point the user to the manual-injection path below. -cat .azure/landing-zone-context.json | jq '.' +```bash +# Inspect the assembled context +jq '.' .azure/landing-zone-context.json ``` **Output format (`landing-zone-context.json`):** @@ -452,7 +462,7 @@ jq '.landingZoneDetection' .azure/landing-zone-context.json ## Manual Injection -When discovery cannot reach the landing zone (cross-tenant, limited RBAC, air-gapped environments), users can inject context manually. +When discovery cannot reach the landing zone (cross-tenant, limited RBAC, air-gapped environments), users can inject context manually. **Always drive this through the `inject-lz.sh` (or `inject-lz.ps1`) script with its canonical flags — `--hub-vnet-id`, `--log-analytics-id`, `--acr-id`, `--allowed-locations`, `--required-tags` — rather than hand-writing the JSON**, so the schema and `discoveryMethod` stay correct (Option B). Fall back to editing the file directly (Option A) or the interactive questionnaire (Option C) only when the script cannot be run. ### Option A: Provide the Context File Directly diff --git a/website/docs/skills/overview.md b/website/docs/skills/overview.md index bdefb2d..374f269 100644 --- a/website/docs/skills/overview.md +++ b/website/docs/skills/overview.md @@ -44,7 +44,7 @@ Skills are focused capabilities invoked by agents at specific stages of the depl | Skill | Description | Invocable | |-------|-------------|:---------:| -| [Azure Landing Zone Discovery](./azure-landing-zone-discovery) | Auto-discover Azure landing zone topology including management groups, platform vs. application subscriptions, policy assignments, hub-spoke networking, and shared services. Use when deploying into enterprise environments, checking policy conflicts, or connecting to shared infrastructure. | ✅ | +| [Azure Landing Zone Discovery](./azure-landing-zone-discovery) | Auto-discover enterprise Azure landing zone topology — management groups, platform vs. application subscriptions, policy assignments, hub-spoke networking, and shared services — and write .azure/landing-zone-context.json. USE FOR: discovering the landing zone, mapping management groups, choosing a target subscription, connecting to a hub VNet or shared services, or manually injecting context for air-gapped/cross-tenant tenants. NOT FOR: deploying individual resources, CAF name lookups, or per-template policy compliance checks. | ✅ | | [Azure Stack Deploy](./azure-stack-deploy) | Run an Azure Deployment Stack create (subscription scope) for a prepared Git-Ape deployment artifact and write state.json (schemaVersion 1.0). Use locally so the result matches the CI deploy workflow. | ✅ | | [Azure Stack Destroy](./azure-stack-destroy) | Tear down a Git-Ape deployment by ID. Reads `state.json` under `.azure/deployments//` to delete the Azure Deployment Stack and purge soft-deleted Key Vault / Cognitive Services. Refuses to run without `state.json`. Use for any local CLI or VS Code Git-Ape teardown so the result matches the CI destroy workflow. | ✅ | From 678376c0c8577b371d1b502892f4629f96a582c8 Mon Sep 17 00:00:00 2001 From: Arnaud Lheureux Date: Mon, 15 Jun 2026 11:05:04 +0800 Subject: [PATCH 3/4] fix(onboarding): sync Landing Zone Context into onboarding template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LZ feature added a Landing Zone Context section to the mirror .github/copilot-instructions.md but not its canonical onboarding template, tripping the template mirror sync CI gate. Port the section into the template so the two stay byte-identical. 🧭 - Generated by Copilot --- .../templates/copilot-instructions.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/skills/git-ape-onboarding/templates/copilot-instructions.md b/.github/skills/git-ape-onboarding/templates/copilot-instructions.md index a6fe66a..a6b6374 100644 --- a/.github/skills/git-ape-onboarding/templates/copilot-instructions.md +++ b/.github/skills/git-ape-onboarding/templates/copilot-instructions.md @@ -353,6 +353,19 @@ Always assess and recommend policies for: identity, networking, storage, compute - Policy gate is **advisory** (not blocking) — surfaces findings without halting deployment - During onboarding, ask the user about compliance framework and enforcement mode preferences and update this section accordingly +## Landing Zone Context + +If `.azure/landing-zone-context.json` is present in the workspace (produced by `/azure-landing-zone-discovery`), Git-Ape agents MUST read it before generating templates and respect its constraints: + +- **`policies.allowedLocations[]`** — Reject any region not in this list (or, when empty/missing, fall back to the default-region rules above). +- **`policies.requiredTags[]`** — Inject these tag keys into every resource's `tags` block; surface missing values to the user. +- **`policies.denyEffects[]`** / **`policies.alzCanonicalAssignments[]`** — Cross-check the template against these before the security gate. Findings already enforced at the tenant level are marked `✓ inherited` instead of re-recommended. +- **`sharedServices.logAnalytics`**, **`sharedServices.acr`**, **`sharedServices.keyVault`** — Prefer these over creating new platform resources; wire diagnostic settings to the shared Log Analytics workspace. +- **`networking.topology` = `hub-spoke`** — Generate hub-VNet peering and link private endpoints to `networking.privateDnsZones[]`. +- **`landingZoneDetection.confidence`** — `high` = auto-apply; `medium` = confirm with user before applying ALZ-specific behavior; `low`/`none` = use explicit policy fields only and treat the tenant as standalone; missing = skip LZ-aware logic. + +If the context file is missing, deploy in standalone mode. Do **not** force `/azure-landing-zone-discovery` — many users deploy into solo subscriptions. + ### Rules 1. **Cite evidence**: Every "✅ Applied" finding must reference the exact ARM property path and value from the template. No exceptions. From 368b4aff2105054bc9c039d40f3e3ffb5f96e06f Mon Sep 17 00:00:00 2001 From: Arnaud Lheureux Date: Thu, 25 Jun 2026 12:46:47 +0800 Subject: [PATCH 4/4] fix(landing-zone): detect canonical ALZ accelerator deployments QA against a live canonical Azure Landing Zone tenant showed discover-lz scored it low/30/isLandingZone=false. Four scorer/discovery bugs caused the miss; a fifth broke the documented manual-injection fallback. discover-lz (.sh + .ps1): - Classify Corp/Online MGs via substring fallback. The ALZ accelerator prefixes MG names (e.g. "alz-corp") so the exact-name checks never fire; the fallback had tests for every archetype except corp/online. - Query policy assignments at every discovered management-group scope, not just the subscription. Canonical ALZ policies live at MG scope (alz, alz-platform) and were invisible. Uses default atScope() per MG since --disable-scope-strict-match errors at MG scope. - Refresh the canonical policy-name pattern (Deploy-AzActivity-Log -> Deploy-AzActivityLog, add Deploy-VM-Monitoring, Deploy-VMSS-Monitoring, Deny-Classic-Resources, ...) and match against BOTH .name and .displayName (the canonical token lives in .name; displayName is a long human description). inject-lz (.sh + .ps1): - Emit a landingZoneDetection block (source=manual, confidence high by default) so the manual fallback actually flips LZ-aware behaviour. Adds --confidence/-Confidence and --not-landing-zone/-NotLandingZone. On --merge an explicit confidence overrides; otherwise injection only raises. Live re-run after fixes: medium/55/isLandingZone=true (signals: alz-top-level-mgs 30, alz-lz-archetypes 10, alz-canonical-policies 15). Docs (SKILL.md + generated mirror) document the new flags. shellcheck (severity=warning) and PSScriptAnalyzer (Error+Warning) pass; bash -n and the PowerShell parser pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure-landing-zone-discovery/SKILL.md | 13 +++ .../scripts/discover-lz.ps1 | 48 +++++++++-- .../scripts/discover-lz.sh | 43 ++++++++-- .../scripts/inject-lz.ps1 | 71 ++++++++++++++++- .../scripts/inject-lz.sh | 79 ++++++++++++++++++- .../skills/azure-landing-zone-discovery.md | 13 +++ 6 files changed, 250 insertions(+), 17 deletions(-) diff --git a/.github/skills/azure-landing-zone-discovery/SKILL.md b/.github/skills/azure-landing-zone-discovery/SKILL.md index 4316c25..8751236 100644 --- a/.github/skills/azure-landing-zone-discovery/SKILL.md +++ b/.github/skills/azure-landing-zone-discovery/SKILL.md @@ -447,6 +447,8 @@ jq '.landingZoneDetection' .azure/landing-zone-context.json When discovery cannot reach the landing zone (cross-tenant, limited RBAC, air-gapped environments), users can inject context manually. **Always drive this through the `inject-lz.sh` (or `inject-lz.ps1`) script with its canonical flags — `--hub-vnet-id`, `--log-analytics-id`, `--acr-id`, `--allowed-locations`, `--required-tags` — rather than hand-writing the JSON**, so the schema and `discoveryMethod` stay correct (Option B). Fall back to editing the file directly (Option A) or the interactive questionnaire (Option C) only when the script cannot be run. +Manual injection is an explicit assertion that the tenant *is* landing-zone managed, so the script writes a `landingZoneDetection` block with `source: "manual"` and `confidence: high` by default (the auto-scorer is bypassed — there is nothing to score when discovery couldn't run). Control this with `--confidence `, or use `--not-landing-zone` to assert the opposite. When combined with `--merge`, an explicit `--confidence` overrides the stored detection; without it, injection only ever *raises* confidence, never silently downgrades a real discovery result. + ### Option A: Provide the Context File Directly Create or edit `.azure/landing-zone-context.json` with your landing zone topology. Set `"discoveryMethod": "manual"`. @@ -475,6 +477,17 @@ The injection script ships in both shells (bash and PowerShell parity ports). Bo -RequiredTags "Environment,Project,CostCenter" ``` +To simply assert "I know my tenant is ALZ-managed" when discovery scored too low (or could not run) — without supplying topology — call the script with a confidence flag only: + +```bash +# Assert landing-zone managed (writes confidence: high, source: manual) +.github/skills/azure-landing-zone-discovery/scripts/inject-lz.sh --confidence high + +# Merge an asserted hub onto an existing low-confidence discovery and raise it +.github/skills/azure-landing-zone-discovery/scripts/inject-lz.sh --merge --confidence high \ + --hub-vnet-id "/subscriptions/.../providers/Microsoft.Network/virtualNetworks/vnet-hub" +``` + ### Option C: Interactive Questionnaire When invoked without arguments, the agent asks targeted questions: diff --git a/.github/skills/azure-landing-zone-discovery/scripts/discover-lz.ps1 b/.github/skills/azure-landing-zone-discovery/scripts/discover-lz.ps1 index 33843a2..80b206f 100644 --- a/.github/skills/azure-landing-zone-discovery/scripts/discover-lz.ps1 +++ b/.github/skills/azure-landing-zone-discovery/scripts/discover-lz.ps1 @@ -169,6 +169,12 @@ function Get-MgRole { if ($n -match 'landing.?zone|workload|application') { return "landing-zones" } if ($n -match 'sandbox|dev.?test') { return "sandbox" } if ($n -match 'decommission|deprecated|retired') { return "decommissioned" } + # corp/online matched last so the more specific archetypes win first. + # The ALZ accelerator prefixes MG names (e.g. "alz-corp"), so the + # exact-name checks above never fire — these substring tests are what + # actually classify Corp/Online landing-zone archetypes in practice. + if ($n -match 'corp') { return "corp" } + if ($n -match 'online') { return "online" } return "other" } @@ -304,7 +310,33 @@ if (-not $SkipPolicies) { Write-Host "[4/7] Discovering policy assignments..." -ForegroundColor Cyan $assignments = ConvertTo-Array (Invoke-AzJson @("policy", "assignment", "list", "--query", "[?enforcementMode=='Default']")) - Write-Host " Found " -NoNewline; Write-Host "$($assignments.Count)" -ForegroundColor Green -NoNewline; Write-Host " enforced policy assignments" + + # Canonical ALZ policies are assigned at management-group scope (e.g. the + # "alz" and "alz-platform" MGs), not the subscription — so a sub-scope-only + # query misses them entirely. Enumerate each discovered MG scope and merge, + # deduplicating by assignment id (a given assignment lives at one scope). + # NOTE: use the default atScope() filter (a bare --scope). The + # --disable-scope-strict-match (atScopeAndBelow) flag is unsupported at MG + # scope and errors out, so we query each MG explicitly instead. + if ($HasManagementGroups) { + $seenAssignmentIds = @{} + $combinedAssignments = [System.Collections.ArrayList]::new() + foreach ($a in $assignments) { + $aid = Get-Prop $a 'id' + if ($aid -and -not $seenAssignmentIds.ContainsKey($aid)) { $seenAssignmentIds[$aid] = $true; [void]$combinedAssignments.Add($a) } + } + foreach ($mg in $MgList) { + $mgScope = Get-Prop $mg 'id' + if (-not $mgScope) { continue } + $mgAssignments = ConvertTo-Array (Invoke-AzJson @("policy", "assignment", "list", "--scope", $mgScope, "--query", "[?enforcementMode=='Default']")) + foreach ($a in $mgAssignments) { + $aid = Get-Prop $a 'id' + if ($aid -and -not $seenAssignmentIds.ContainsKey($aid)) { $seenAssignmentIds[$aid] = $true; [void]$combinedAssignments.Add($a) } + } + } + $assignments = @($combinedAssignments) + } + Write-Host " Found " -NoNewline; Write-Host "$($assignments.Count)" -ForegroundColor Green -NoNewline; Write-Host " enforced policy assignments (subscription + management-group scopes)" $denyPolicies = [System.Collections.ArrayList]::new() $auditPolicies = [System.Collections.ArrayList]::new() @@ -312,7 +344,7 @@ if (-not $SkipPolicies) { $requiredTags = [System.Collections.ArrayList]::new() $alzCanonical = [System.Collections.ArrayList]::new() - $alzPattern = 'Deploy-MDFC-Config|Deploy-AzActivity-Log|Deploy-Diag-LogsCat-LAW|Deploy-Diagnostics-LogAnalytics|Enforce-Encryption-CMK|Enforce-EncryptTransit|Enforce-TLS-SSL|Deny-PublicIP|Deny-RDP-From-Internet|Deny-Subnet-Without-Nsg|Deny-Storage-http|Deploy-Resource-Diag|Deploy-VM-Backup|Deploy-Private-DNS-Zones|Audit-UnusedResources' + $alzPattern = 'Deploy-MDFC-Config|Deploy-MDEndpoints|Deploy-AzActivity-?Log|Deploy-Diag-LogsCat-LAW|Deploy-Diagnostics-LogAnalytics|Deploy-VM-Monitoring|Deploy-VMSS-Monitoring|Deploy-VM-Backup|Enforce-Encryption-CMK|Enforce-EncryptTransit|Enforce-TLS-SSL|Enforce-ACSB|Deny-Classic-Resources|Deny-PublicIP|Deny-Public-Endpoints|Deny-RDP-From-Internet|Deny-MgmtPorts-From-Internet|Deny-Subnet-Without-Nsg|Deny-Storage-http|Deploy-Resource-Diag|Deploy-Private-DNS-Zones|Audit-UnusedResources' foreach ($a in $assignments) { $displayName = Get-Prop $a 'displayName' @@ -366,10 +398,14 @@ if (-not $SkipPolicies) { } } - # Canonical ALZ accelerator policy assignment names - $matchName = if ($displayName) { $displayName } else { $name } - if ($matchName -and ($matchName -match "(?i)$alzPattern")) { - [void]$alzCanonical.Add($matchName) + # Canonical ALZ accelerator policy assignment names. The canonical token + # (e.g. "Deploy-VM-Monitoring") lives in the assignment .name; + # .displayName is a long human description that rarely contains it. Test + # BOTH fields and record the .name as the label. + $haystack = (("" + $name) + " " + ("" + $displayName)) + if ($haystack -match "(?i)$alzPattern") { + $label = if ($name) { $name } else { $displayName } + [void]$alzCanonical.Add($label) } } diff --git a/.github/skills/azure-landing-zone-discovery/scripts/discover-lz.sh b/.github/skills/azure-landing-zone-discovery/scripts/discover-lz.sh index 99ff93b..f5aefab 100755 --- a/.github/skills/azure-landing-zone-discovery/scripts/discover-lz.sh +++ b/.github/skills/azure-landing-zone-discovery/scripts/discover-lz.sh @@ -192,6 +192,12 @@ if [[ "$MG_LIST" != "[]" ]] && [[ -n "$MG_LIST" ]]; then elif ($n | test("landing.?zone|workload|application")) then "landing-zones" elif ($n | test("sandbox|dev.?test")) then "sandbox" elif ($n | test("decommission|deprecated|retired")) then "decommissioned" + # corp/online matched last so the more specific archetypes win first. + # The ALZ accelerator prefixes MG names (e.g. "alz-corp"), so the + # exact-name checks above never fire — these substring tests are what + # actually classify Corp/Online landing-zone archetypes in practice. + elif ($n | test("corp")) then "corp" + elif ($n | test("online")) then "online" else "other" end ), @@ -328,8 +334,29 @@ if [[ "$SKIP_POLICIES" != "true" ]]; then --query "[?enforcementMode=='Default']" \ --output json 2>/dev/null || echo "[]") + # Canonical ALZ policies are assigned at management-group scope (e.g. the + # "alz" and "alz-platform" MGs), not the subscription — so a sub-scope-only + # query misses them entirely. Enumerate each discovered MG scope and merge. + # NOTE: use the default atScope() filter (a bare --scope). The + # --disable-scope-strict-match (atScopeAndBelow) flag is unsupported at MG + # scope and errors out, so we query each MG explicitly instead. + if [[ "$HAS_MANAGEMENT_GROUPS" == "true" ]]; then + for MG_SCOPE in $(echo "$MG_LIST" | jq -r '.[].id'); do + MG_POLICY_ASSIGNMENTS=$(az policy assignment list \ + --scope "$MG_SCOPE" \ + --query "[?enforcementMode=='Default']" \ + --output json 2>/dev/null || echo "[]") + POLICY_ASSIGNMENTS=$(jq -n \ + --argjson a "$POLICY_ASSIGNMENTS" \ + --argjson b "$MG_POLICY_ASSIGNMENTS" \ + '$a + $b') + done + # Deduplicate by assignment id (a given assignment lives at exactly one scope) + POLICY_ASSIGNMENTS=$(echo "$POLICY_ASSIGNMENTS" | jq 'unique_by(.id)') + fi + POLICY_COUNT=$(echo "$POLICY_ASSIGNMENTS" | jq 'length') - echo -e " Found ${GREEN}$POLICY_COUNT${NC} enforced policy assignments" + echo -e " Found ${GREEN}$POLICY_COUNT${NC} enforced policy assignments (subscription + management-group scopes)" if [[ "$POLICY_COUNT" -gt 0 ]]; then # Resolve each assignment's effect from the parameters block. Initiatives @@ -403,13 +430,19 @@ if [[ "$SKIP_POLICIES" != "true" ]]; then # Match against canonical ALZ accelerator policy assignment names. # Reference: https://github.com/Azure/Enterprise-Scale/wiki/ALZ-Policies # These names are deployed by the ALZ accelerator and are a high-precision - # ALZ signature regardless of effect. + # ALZ signature regardless of effect. The accelerator periodically renames + # assignments (e.g. Deploy-AzActivity-Log -> Deploy-AzActivityLog), so the + # pattern tolerates both old and new spellings. ALZ_CANONICAL=$(echo "$POLICY_ASSIGNMENTS" | jq '[ .[] | select(.displayName != null or .name != null) | - ((.displayName // .name) | tostring) as $n | - select($n | test("Deploy-MDFC-Config|Deploy-AzActivity-Log|Deploy-Diag-LogsCat-LAW|Deploy-Diagnostics-LogAnalytics|Enforce-Encryption-CMK|Enforce-EncryptTransit|Enforce-TLS-SSL|Deny-PublicIP|Deny-RDP-From-Internet|Deny-Subnet-Without-Nsg|Deny-Storage-http|Deploy-Resource-Diag|Deploy-VM-Backup|Deploy-Private-DNS-Zones|Audit-UnusedResources"; "i")) | - $n + # The canonical token (e.g. "Deploy-VM-Monitoring") lives in the + # assignment .name; .displayName is a long human description that + # rarely contains it. Test BOTH fields, emit the .name as the label. + (((.name // "") + " " + (.displayName // "")) | tostring) as $hay | + ((.name // .displayName) | tostring) as $label | + select($hay | test("Deploy-MDFC-Config|Deploy-MDEndpoints|Deploy-AzActivity-?Log|Deploy-Diag-LogsCat-LAW|Deploy-Diagnostics-LogAnalytics|Deploy-VM-Monitoring|Deploy-VMSS-Monitoring|Deploy-VM-Backup|Enforce-Encryption-CMK|Enforce-EncryptTransit|Enforce-TLS-SSL|Enforce-ACSB|Deny-Classic-Resources|Deny-PublicIP|Deny-Public-Endpoints|Deny-RDP-From-Internet|Deny-MgmtPorts-From-Internet|Deny-Subnet-Without-Nsg|Deny-Storage-http|Deploy-Resource-Diag|Deploy-Private-DNS-Zones|Audit-UnusedResources"; "i")) | + $label ] | unique' 2>/dev/null || echo "[]") if [[ "$VERBOSE" == "true" ]] && [[ $(echo "$ALZ_CANONICAL" | jq 'length') -gt 0 ]]; then diff --git a/.github/skills/azure-landing-zone-discovery/scripts/inject-lz.ps1 b/.github/skills/azure-landing-zone-discovery/scripts/inject-lz.ps1 index 2d242e7..b361125 100644 --- a/.github/skills/azure-landing-zone-discovery/scripts/inject-lz.ps1 +++ b/.github/skills/azure-landing-zone-discovery/scripts/inject-lz.ps1 @@ -18,6 +18,9 @@ param( [switch]$DenyPublicStorage, [string]$OutputFile = ".azure/landing-zone-context.json", [switch]$Merge, + [ValidateSet("", "high", "medium", "low", "none")] + [string]$Confidence = "", + [switch]$NotLandingZone, [switch]$Help ) @@ -43,6 +46,10 @@ Options: -DenyPublicStorage Flag: public storage access is denied by policy -OutputFile Output file path (default: .azure/landing-zone-context.json) -Merge Merge with existing context file instead of replacing + -Confidence Assert landing zone confidence: high|medium|low|none + (default: high — manual injection is an explicit + assertion that this tenant is landing-zone managed) + -NotLandingZone Shorthand for -Confidence none (assert NOT a landing zone) -Help Show this help message Examples: @@ -54,6 +61,9 @@ Examples: ./inject-lz.ps1 -AllowedLocations "eastus,westus2,westeurope" ` -RequiredTags "Environment,Project,CostCenter" -DenyPublicIp + # Declare "I know my tenant is ALZ-managed" with no other data + ./inject-lz.ps1 -Confidence high + # Merge with existing discovery ./inject-lz.ps1 -Merge -AcrId "/subscriptions/.../providers/Microsoft.ContainerRegistry/registries/crshared" "@ | Write-Host @@ -62,15 +72,41 @@ Examples: if ($Help) { Show-Usage } +$ConfidenceExplicit = $PSBoundParameters.ContainsKey('Confidence') -or $NotLandingZone + # Check if at least one value was provided if (-not $HubVnetId -and -not $LogAnalyticsId -and -not $AcrId -and ` -not $KeyVaultId -and -not $AllowedLocations -and -not $RequiredTags -and ` - -not $DenyPublicIp -and -not $DenyPublicStorage) { + -not $DenyPublicIp -and -not $DenyPublicStorage -and -not $ConfidenceExplicit) { Write-Host "Error: At least one landing zone parameter must be provided" -ForegroundColor Red Write-Host "" Show-Usage } +# ───────────────────────────────────────────────────────────────────────────── +# Resolve landing zone detection. +# +# Manual injection is an explicit assertion that this tenant is landing-zone +# managed, so unless the caller says otherwise we record a high-confidence, +# source="manual" detection. This is what allows the manual fallback to flip +# LZ-aware behaviour in downstream agents (the auto-scorer is bypassed here). +# ───────────────────────────────────────────────────────────────────────────── +if ($NotLandingZone) { $Confidence = "none" } +if (-not $Confidence) { $Confidence = "high" } +$Confidence = $Confidence.ToLower() +switch ($Confidence) { + "high" { $ConfScore = 90 } + "medium" { $ConfScore = 50 } + "low" { $ConfScore = 20 } + "none" { $ConfScore = 0 } + default { + Write-Host "Error: -Confidence must be one of: high, medium, low, none" -ForegroundColor Red + exit 1 + } +} +# isLandingZone threshold matches discover-lz.ps1 (score >= 40) +$IsLandingZone = ($ConfScore -ge 40) + $InjectionTimestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") Write-Host "Injecting landing zone context..." -ForegroundColor Blue @@ -224,6 +260,21 @@ Write-Host "" $NewContext = [ordered]@{ discoveredAt = $InjectionTimestamp discoveryMethod = "manual" + landingZoneDetection = [ordered]@{ + isLandingZone = $IsLandingZone + confidence = $Confidence + confidenceScore = $ConfScore + source = "manual" + reference = "https://azure.github.io/Azure-Landing-Zones/accelerator/" + matchedSignals = @( + [ordered]@{ + signal = "manual-injection" + points = $ConfScore + evidence = "Landing zone context asserted via inject-lz.ps1 (-Confidence $Confidence)" + } + ) + missingSignals = @() + } managementGroups = [ordered]@{ root = ""; hasManagementGroups = $false; hierarchy = @() } subscriptions = [ordered]@{ platform = @(); landingZones = @() } sharedServices = $SharedServices @@ -286,9 +337,24 @@ if ($Merge -and (Test-Path $OutputFile)) { requiredTags = @($finalTags) } + # Reconcile detection: an explicit -Confidence forces the injected value + # (so the caller can raise OR lower it); otherwise injection can only raise + # confidence, never silently downgrade a real discovery result. + $existingDetection = Get-Prop $existing 'landingZoneDetection' + $newDetection = $NewContext.landingZoneDetection + if ($ConfidenceExplicit -or (-not $existingDetection)) { + $mergedDetection = $newDetection + } + else { + $existingScore = [int](Get-Prop $existingDetection 'confidenceScore' 0) + if ($ConfScore -ge $existingScore) { $mergedDetection = $newDetection } + else { $mergedDetection = $existingDetection } + } + $FinalContext = [ordered]@{ discoveredAt = $InjectionTimestamp discoveryMethod = "merged" + landingZoneDetection = $mergedDetection managementGroups = (Get-Prop $existing 'managementGroups' $NewContext.managementGroups) subscriptions = (Get-Prop $existing 'subscriptions' $NewContext.subscriptions) sharedServices = $mergedShared @@ -296,9 +362,6 @@ if ($Merge -and (Test-Path $OutputFile)) { policies = $mergedPolicies currentIdentity = (Get-Prop $existing 'currentIdentity' $NewContext.currentIdentity) } - # Preserve landingZoneDetection from an existing auto-discovery if present - $existingDetection = Get-Prop $existing 'landingZoneDetection' - if ($existingDetection) { $FinalContext['landingZoneDetection'] = $existingDetection } } # ───────────────────────────────────────────────────────────────────────────── diff --git a/.github/skills/azure-landing-zone-discovery/scripts/inject-lz.sh b/.github/skills/azure-landing-zone-discovery/scripts/inject-lz.sh index 59be6ec..6f829a1 100755 --- a/.github/skills/azure-landing-zone-discovery/scripts/inject-lz.sh +++ b/.github/skills/azure-landing-zone-discovery/scripts/inject-lz.sh @@ -23,6 +23,8 @@ DENY_PUBLIC_IP=false DENY_PUBLIC_STORAGE=false OUTPUT_FILE=".azure/landing-zone-context.json" MERGE_MODE=false +CONFIDENCE="" +CONFIDENCE_EXPLICIT=false usage() { cat < Output file path (default: .azure/landing-zone-context.json) --merge Merge with existing context file instead of replacing + --confidence Assert landing zone confidence: high|medium|low|none + (default: high — manual injection is an explicit + assertion that this tenant is landing-zone managed) + --not-landing-zone Shorthand for --confidence none (assert NOT a landing zone) -h, --help Show this help message Examples: @@ -56,6 +62,9 @@ Examples: --required-tags "Environment,Project,CostCenter" \\ --deny-public-ip + # Declare "I know my tenant is ALZ-managed" with no other data + $0 --confidence high + # Merge with existing discovery $0 --merge --acr-id "/subscriptions/.../providers/Microsoft.ContainerRegistry/registries/crshared" @@ -106,6 +115,16 @@ while [[ $# -gt 0 ]]; do MERGE_MODE=true shift ;; + --confidence) + CONFIDENCE="$2" + CONFIDENCE_EXPLICIT=true + shift 2 + ;; + --not-landing-zone) + CONFIDENCE="none" + CONFIDENCE_EXPLICIT=true + shift + ;; -h|--help) usage ;; @@ -119,12 +138,42 @@ done # Check if at least one value was provided if [[ -z "$HUB_VNET_ID" ]] && [[ -z "$LOG_ANALYTICS_ID" ]] && [[ -z "$ACR_ID" ]] && \ [[ -z "$KEY_VAULT_ID" ]] && [[ -z "$ALLOWED_LOCATIONS" ]] && [[ -z "$REQUIRED_TAGS" ]] && \ - [[ "$DENY_PUBLIC_IP" == "false" ]] && [[ "$DENY_PUBLIC_STORAGE" == "false" ]]; then + [[ "$DENY_PUBLIC_IP" == "false" ]] && [[ "$DENY_PUBLIC_STORAGE" == "false" ]] && \ + [[ "$CONFIDENCE_EXPLICIT" == "false" ]]; then echo -e "${RED}Error: At least one landing zone parameter must be provided${NC}" echo "" usage fi +# ───────────────────────────────────────────────────────────────────────────── +# Resolve landing zone detection. +# +# Manual injection is an explicit assertion that this tenant is landing-zone +# managed, so unless the caller says otherwise we record a high-confidence, +# source="manual" detection. This is what allows the manual fallback to flip +# LZ-aware behaviour in downstream agents (the auto-scorer is bypassed here). +# ───────────────────────────────────────────────────────────────────────────── +if [[ -z "$CONFIDENCE" ]]; then + CONFIDENCE="high" +fi +CONFIDENCE=$(echo "$CONFIDENCE" | tr '[:upper:]' '[:lower:]') +case "$CONFIDENCE" in + high) CONF_SCORE=90 ;; + medium) CONF_SCORE=50 ;; + low) CONF_SCORE=20 ;; + none) CONF_SCORE=0 ;; + *) + echo -e "${RED}Error: --confidence must be one of: high, medium, low, none${NC}" + exit 1 + ;; +esac +# isLandingZone threshold matches discover-lz.sh (score >= 40) +if [[ "$CONF_SCORE" -ge 40 ]]; then + IS_LANDING_ZONE=true +else + IS_LANDING_ZONE=false +fi + INJECTION_TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ) echo -e "${BLUE}Injecting landing zone context...${NC}" @@ -274,12 +323,27 @@ echo "" NEW_CONTEXT=$(jq -n \ --arg discoveredAt "$INJECTION_TIMESTAMP" \ --arg discoveryMethod "manual" \ + --argjson isLandingZone "$IS_LANDING_ZONE" \ + --arg confidence "$CONFIDENCE" \ + --argjson confidenceScore "$CONF_SCORE" \ --argjson sharedServices "$SHARED_SERVICES" \ --argjson networking "$NETWORKING" \ --argjson policies "$POLICIES_JSON" \ '{ discoveredAt: $discoveredAt, discoveryMethod: $discoveryMethod, + landingZoneDetection: { + isLandingZone: $isLandingZone, + confidence: $confidence, + confidenceScore: $confidenceScore, + source: "manual", + reference: "https://azure.github.io/Azure-Landing-Zones/accelerator/", + matchedSignals: [ + { signal: "manual-injection", points: $confidenceScore, + evidence: "Landing zone context asserted via inject-lz.sh (--confidence \($confidence))" } + ], + missingSignals: [] + }, managementGroups: { root: "", hasManagementGroups: false, hierarchy: [] }, subscriptions: { platform: [], landingZones: [] }, sharedServices: $sharedServices, @@ -294,12 +358,23 @@ if [[ "$MERGE_MODE" == "true" ]] && [[ -f "$OUTPUT_FILE" ]]; then EXISTING=$(cat "$OUTPUT_FILE") # Deep merge: new values override existing, arrays are replaced - MERGED=$(echo "$EXISTING" "$NEW_CONTEXT" | jq -s ' + MERGED=$(echo "$EXISTING" "$NEW_CONTEXT" | jq -s \ + --argjson confExplicit "$CONFIDENCE_EXPLICIT" ' .[0] as $existing | .[1] as $new | + ($existing.landingZoneDetection // null) as $existingLz | + ($new.landingZoneDetection) as $newLz | + # Reconcile detection: an explicit --confidence forces the injected value + # (so the caller can raise OR lower it); otherwise injection can only + # raise confidence, never silently downgrade a real discovery result. + (if $confExplicit then $newLz + elif ($existingLz == null) then $newLz + elif (($newLz.confidenceScore // 0) >= ($existingLz.confidenceScore // 0)) then $newLz + else $existingLz end) as $mergedLz | $existing * { discoveredAt: $new.discoveredAt, discoveryMethod: "merged", + landingZoneDetection: $mergedLz, sharedServices: ($existing.sharedServices * $new.sharedServices), networking: ( if ($new.networking.topology != "unknown") then $new.networking diff --git a/website/docs/skills/azure-landing-zone-discovery.md b/website/docs/skills/azure-landing-zone-discovery.md index ddbbb5f..be6ab23 100644 --- a/website/docs/skills/azure-landing-zone-discovery.md +++ b/website/docs/skills/azure-landing-zone-discovery.md @@ -464,6 +464,8 @@ jq '.landingZoneDetection' .azure/landing-zone-context.json When discovery cannot reach the landing zone (cross-tenant, limited RBAC, air-gapped environments), users can inject context manually. **Always drive this through the `inject-lz.sh` (or `inject-lz.ps1`) script with its canonical flags — `--hub-vnet-id`, `--log-analytics-id`, `--acr-id`, `--allowed-locations`, `--required-tags` — rather than hand-writing the JSON**, so the schema and `discoveryMethod` stay correct (Option B). Fall back to editing the file directly (Option A) or the interactive questionnaire (Option C) only when the script cannot be run. +Manual injection is an explicit assertion that the tenant *is* landing-zone managed, so the script writes a `landingZoneDetection` block with `source: "manual"` and `confidence: high` by default (the auto-scorer is bypassed — there is nothing to score when discovery couldn't run). Control this with `--confidence `, or use `--not-landing-zone` to assert the opposite. When combined with `--merge`, an explicit `--confidence` overrides the stored detection; without it, injection only ever *raises* confidence, never silently downgrades a real discovery result. + ### Option A: Provide the Context File Directly Create or edit `.azure/landing-zone-context.json` with your landing zone topology. Set `"discoveryMethod": "manual"`. @@ -492,6 +494,17 @@ The injection script ships in both shells (bash and PowerShell parity ports). Bo -RequiredTags "Environment,Project,CostCenter" ``` +To simply assert "I know my tenant is ALZ-managed" when discovery scored too low (or could not run) — without supplying topology — call the script with a confidence flag only: + +```bash +# Assert landing-zone managed (writes confidence: high, source: manual) +.github/skills/azure-landing-zone-discovery/scripts/inject-lz.sh --confidence high + +# Merge an asserted hub onto an existing low-confidence discovery and raise it +.github/skills/azure-landing-zone-discovery/scripts/inject-lz.sh --merge --confidence high \ + --hub-vnet-id "/subscriptions/.../providers/Microsoft.Network/virtualNetworks/vnet-hub" +``` + ### Option C: Interactive Questionnaire When invoked without arguments, the agent asks targeted questions: