@azure-samples/azuresandbox
Azure Sandbox — Copilot Instructions
Install
agr install @azure-samples/azuresandbox --target copilotWrites 1 file into .github/copilot-instructions.md, pinned to git-01e34806.
- .github/copilot-instructions.md
Document
Azure Sandbox — Copilot Instructions
Terraform IaC project that provisions a modular Azure sandbox environment. Not for production use.
Tech stack
- Terraform (version pinned in
terraform.tf— check there for the current required version) - Providers:
azurerm ~> 4.73,azapi ~> 2.9,azuread ~> 3.8, pluscloudinit,null,random,time,tls - Resource naming: the
Azure/naming/azurermmodule (module.naming) — never hand-write resource names; use e.g.module.naming.key_vault.name_unique - Scripts: PowerShell 7.x (Az modules) and Bash (Azure CLI). VM-side config uses PowerShell +
azurerm_virtual_machine_run_command(Windows) andcloud-init(Linux) - Active development branch is
vnext(notmain)
Build / validate / test
Run from repo root. Terraform state lives locally by default (sensitive — see README about adding backend.tf, and the "Terraform execution environments" section below for the jumplinux2/rg-devops-iac remote-backend case).
terraform init
terraform validate
terraform plan
terraform apply # 25–95 min depending on enabled modules
tflint # uses .tflint.hcl (recommended + azurerm ruleset)
The SPN password must come from the env var TF_VAR_arm_client_secret — never commit it.
MCAPS tenants — SecurityControl=Ignore tag (applies to this tenant): When deploying on an MCAPS tenant (such as the one in use here), Azure Policy requires resources to carry the SecurityControl=Ignore tag or the apply is denied. The root module supports merging extra tags over the base tags map via the additional_tags variable, so pass this tag on the command line rather than editing terraform.tfvars. Append the following to every terraform plan and terraform apply invocation in all workflows/scenarios below:
-var='additional_tags={SecurityControl="Ignore"}'
For example: terraform apply -var='additional_tags={SecurityControl="Ignore"}'. To add more MCAPS tags, include them in the same map, e.g. -var='additional_tags={SecurityControl="Ignore",owner="rob"}'. Keys in additional_tags win on collisions with the base tags map.
Terraform execution environments
Two Terraform execution environments are in active use for this repo — identify which one a session is running in before touching Terraform state:
-
WSL / local client (default): Terraform state is local (
terraform.tfstatein the repo root, git-ignored). Nobackend.tf. All auth (azurerm/azuread/azapiproviders inproviders.tf) is SPN-based viaTF_VAR_arm_client_secret. The genericbackend.tfexample in the root README applies here if a user opts into a remote backend manually. -
jumplinux2— the VM deployed byextras/configurations/rg-devops-iac, reached via VS Code Remote-SSH. Detect it withhostname(jumplinux2) orecho $ARM_USE_MSI(pre-settrueby cloud-init, along with$ARM_TENANT_ID). Provider auth inproviders.tfstays SPN-based and unchanged — only the Terraform state backend differs: it uses theazurermbackend with Azure AD / managed-identity auth against the rg-devops-iac storage account (tfstatecontainer), which grants jumplinux2's managed identityStorage Blob Data Contributor.If
backend.tfdoesn't already exist in the repo root, create it — never overwrite one that's already there. Look up the current values live (they vary per rg-devops-iac deployment, so never hardcode them in docs or configs): prefer the Azure MCP server tools available in this session —azure-subscription_listfor the subscription id,azure-group_list/azure-group_resource_listto find the rg-devops-iac resource group and its lone storage account,azure-storageto confirm the storage account name — falling back to Azure CLI only if the MCP server isn't wired up:SUBSCRIPTION_ID=$(az account show --query id -o tsv) RG=$(az group list --query "[?starts_with(name, 'rg-devops-')].name" -o tsv | head -1) STORAGE_ACCOUNT=$(az storage account list -g "$RG" --query "[0].name" -o tsv)Then write:
# backend.tf terraform { backend "azurerm" { use_azuread_auth = true use_msi = true tenant_id = "<$ARM_TENANT_ID>" subscription_id = "<$SUBSCRIPTION_ID>" storage_account_name = "<$STORAGE_ACCOUNT>" container_name = "tfstate" key = "azuresandbox.tfstate" } }backend.tfis git-ignored (values are environment-specific). Runterraform init(add-reconfigureif migrating from local state) after creating/changing it.
Preflight checklist (run attended, before enabling /allow-all mode for any apply or test work)
This project runs in /allow-all mode (the CLI's auto-approve mode, where tool calls are executed without per-action confirmation) for long operations (terraform apply is 25–95 min; unit tests can be 10+ min). The preflight checklist itself, however, must be completed with a human present — i.e. NOT in /allow-all mode — because every item may require an ask_user prompt (secrets, auth confirmations, module selection), and ask_user cannot be answered during unattended execution. To avoid failing mid-run because of a missing secret or expired auth, collect every human-gated input up front in a single attended preflight phase. Once preflight passes, the rest of the run should not require user intervention.
The intended workflow is a clean two-phase handoff:
- Attended preflight —
/allow-allmode off. Work through every preflight item below, batching allask_userprompts back-to-back while the user is present. - Unattended execution — once (and only once) every preflight item is satisfied, prompt the user to enable
/allow-allmode by running/allow-allso the long-running apply and tests can complete fully automated without further intervention. Do not beginterraform apply,terraform plan, orInvoke-UnitTests.ps1until the user has confirmed they have enabled/allow-allmode.
Hard rules — never violate:
- Do not run the preflight checklist in
/allow-allmode. Preflight requires human input viaask_user; complete it attended, then hand off to/allow-allmode only after every item passes. - Do not start
terraform apply,terraform plan, orInvoke-UnitTests.ps1until every preflight item below is satisfied AND the user has switched to/allow-allmode (/allow-all). A failed apply 40 minutes in because a secret was missing is the worst outcome — fail fast at preflight instead. - Never run concurrent
terraform applyoperations against the same sandbox environment (state file). One apply at a time, full stop. - Never inspect Terraform state (
terraform state ...,terraform output,terraform show,terraform plan, etc.) while aterraform applyis in flight — it will read/lock the same state file and cause errors or corruption. - Batch all
ask_userprompts back-to-back at the start of the session. Do not interleave human prompts with long-running tool calls.
Preflight items — check each one and resolve before proceeding:
- Service principal secret (
TF_VAR_arm_client_secret) — check withecho "${TF_VAR_arm_client_secret:+set}". If empty, useask_userto prompt for the SPN password, then export it in the shell session before any Terraform command. Required for everyterraform plan/apply. - Azure CLI auth (
az login) — verify withaz account show. If it fails, useask_userto instruct the user to runaz loginin their own terminal, and wait for confirmation before proceeding. Required forterraform applyand forenable-public-access.sh. - Azure PowerShell auth (
Connect-AzAccount) — verify withpwsh -Command 'Get-AzContext'. If empty, useask_userto instruct the user to runpwsh -Command 'Connect-AzAccount -UseDeviceAuthentication'(credentials persist to~/.Azure). Required only whenInvoke-UnitTests.ps1will be run — i.e. when the unit-testing decision in preflight item 7 is yes. terraform.tfvarsexists in the repo root. If missing, run./scripts/bootstrap.sh(Linux Terraform execution environment) or./scripts/bootstrap.ps1(Windows). The two scripts are equivalent — pick the one matching the host OS. Howbootstrap.shworks (so you don't have to re-read it each run):- It is interactive — it issues a series of
read -eprompts and therefore must be run attended (/allow-allmode off), batched with the other preflight prompts. It cannot complete unattended. Because all prompts except one are pre-filled with sane defaults, the only value you must actively collect from the user isarm_client_id(the service principal appId) — surface it viaask_user. - Preconditions it enforces (it exits with usage if any fail):
azCLI installed,python3installed, thePyJWTpython library importable (python3 -c "import jwt"),TF_VAR_arm_client_secretexported, and an activeaz login(it reads the default subscription fromaz account list). These overlap with preflight items 1–2, so satisfy those first. - Prompts and their defaults (default derived from the signed-in
azcontext unless noted):arm_client_id(no default — required input),aad_tenant_id(current tenant),user_name(signed-in UPN),user_object_id(decoded from the access-token JWToidclaim via PyJWT),subscription_id(default subscription),location(centralus),environment(dev),costcenter(mycostcenter),project(sand). It validates the SPN, subscription, and location against Azure before writing. - Known gotcha — empty
user_name: the script derives theuser_namedefault fromaz ad user show --id <user_object_id> --query userPrincipalName(whereuser_object_idis the JWToidclaim). When the signed-in identity lacks Microsoft Entra directory read permission (common for guest accounts and many service-principal-style admin logins), that lookup returns empty, so the prompt has no default and a non-interactive run (piping an empty line) writesuser_name = ""toterraform.tfvars. This fails theuser_nameUPN-format validation invariables.tfatterraform plan/apply. After runningbootstrap.sh, always verifyuser_nameis a non-empty valid UPN (grep '^user_name' terraform.tfvars); if it is blank, set it explicitly — get the UPN fromaz ad signed-in-user show --query userPrincipalName -o tsv(oraz account show --query user.name -o tsvas a fallback) and editterraform.tfvars. When driving the script non-interactively, prefer supplying the UPN explicitly for theuser_nameprompt rather than relying on the (possibly empty) default. - Output — it (over)writes
./terraform.tfvarsin the repo root containingaad_tenant_id,arm_client_id,location,subscription_id,user_name,user_object_id, atagsmap (project/costcenter/environment), and allenable_module_*toggles commented out (every module disabled by default). Module enablement is therefore handled separately in preflight item 6 by editingterraform.tfvarsafterward —bootstrap.shnever enables a module. Note it does not prompt for or write the SPN secret (that stays inTF_VAR_arm_client_secret). - To drive it non-interactively (e.g. when you already have every value), pipe the answers to stdin in prompt order —
arm_client_id,aad_tenant_id,user_name,user_object_id,subscription_id,location,environment,costcenter,project— e.g.printf '%s\n' "$appid" "" "" "" "" "" "" "" "" | ./scripts/bootstrap.sh(empty lines accept the defaults). Prefer the attended interactive flow unless the user has supplied all inputs.
- It is interactive — it issues a series of
- Terraform execution environment identified — determine whether this session is on WSL / local client or
jumplinux2(rg-devops-iac), per the "Terraform execution environments" section above. On jumplinux2 with nobackend.tfyet, look up the backend values and create it beforeterraform init; leave an existingbackend.tfuntouched. On WSL, no action needed (local state is the default). - Module enablement confirmed — use
ask_userto ask whether all base modules in./modulesshould be deployed (everyenable_module_*flagtrue). Extra modules in./extras/modules(ai-foundry,avd,petstore,vnet-onprem, etc.) are always excluded from this question and left disabled. If the answer is no, useask_useragain to have the user specify exactly which base modules to enable (e.g.vnet_apponly); all other base modules stay disabled. Confirm the selection before editingterraform.tfvars. - Automated unit testing decision — use
ask_userto ask whether automated unit tests (Invoke-UnitTests.ps1) should be run after a successfulterraform apply. Capture this decision now, batched with the other preflight prompts, because it determines whether preflight item 3 (Azure PowerShell auth) is required — that human-gated input must be collected up front, not after the/allow-all-mode handoff. If the answer is yes, also confirm scope: all installed modules (Invoke-UnitTests.ps1, which runs each installed module's unit and integration tests automatically) versus a single module's unit tests, optionally with its integration tests (-Module <name> [-Integration]—-Integrationapplies only to a single-module run). If the answer is no, record it and skip item 3 (unless otherwise needed). The recorded decision drives the post-apply test step in Scenarios 1 and 2 — do not re-prompt for it after the apply. - MCAPS tenant tagging — confirm whether the target subscription is on an MCAPS tenant (the default assumption for this environment; the tenant in use here is MCAPS). If yes, every
terraform planandterraform applycommand in every scenario below must include-var='additional_tags={SecurityControl="Ignore"}'so theSecurityControl=Ignoretag is merged over the basetagsmap and Azure Policy does not deny the apply. Record this decision now so the flag is applied consistently across the run without re-prompting. Do not editterraform.tfvarsfor this — pass it on the command line.
After all preflight items pass, the remaining attended setup depends on the scenario: for a fresh vnext sandbox there are additional human-gated prep steps (see Scenario 1) that must also be completed attended. Once all attended setup (preflight plus any scenario-specific prep) is done and nothing further requires ask_user, use ask_user to prompt the user to enable /allow-all mode by running /allow-all, and wait for their confirmation before proceeding. This is the handoff from the attended phase to fully automated execution. Once /allow-all mode is enabled, normal Terraform rules apply: terraform init before terraform apply; run terraform validate and terraform plan first.
Applying Terraform configurations
Progress reporting for long-running operations (apply, tests — /allow-all mode included)
Long-running commands (terraform apply is 25–95 min; terraform plan, terraform destroy, and Invoke-UnitTests.ps1 can each run 10+ min) must be run with the bash tool in mode="async" so they keep streaming output while you monitor them. Emit a progress report to the user every two minutes until the command completes. Each report should be short (1–2 sentences) and include:
- Elapsed wall-clock time since the command started (e.g. "≈12 min elapsed").
- The most recent meaningful line(s) of output — for
terraform apply, the resources currently being created/modified and anyStill creating... [Xm Ys elapsed]progress lines; for unit tests, the current module/test. - A one-line assessment of whether the operation is healthy, stalled, or hung (see detection logic below).
Implementation guidance:
- Start the command with
mode="async"(usedetach: trueonly for true background servers, not for apply/tests you are monitoring), then poll withread_bashon a roughly two-minute cadence to capture incremental output and drive each report. - Do not spam the user between reports. One concise update every ~2 minutes is the target — no per-tool-call narration in between.
- Keep reporting until the process exits, then give a final completion (or failure) summary.
Hung-process detection logic
A long-running operation is healthy as long as its output keeps advancing, even when an individual resource takes a long time. Some Azure resources legitimately provision slowly — e.g. a Point-to-Site (P2S) VPN Gateway, VPN/ExpressRoute gateway, AVD host pool, or AMPLS can take 30–45+ minutes — but during that time terraform apply still prints a Still creating... [Xm Ys elapsed] heartbeat for the in-flight resource roughly every 10 seconds to a minute. That steady heartbeat means the run is healthy, not hung, regardless of total elapsed time.
Classify the operation on each two-minute check:
- Healthy — new output has appeared since the last check, or the
Still creating...elapsed counter for the in-flight resource(s) is still incrementing. Report progress and keep waiting. Never cancel a healthy run just because a single resource has been provisioning for >30 min — that is expected for gateways and similar resources. - Possibly stalled — no new output for two consecutive checks (~4 minutes) and no advancing
Still creating...heartbeat. Note this in the report, keep waiting one more interval, and look more closely (the process may simply be between heartbeats). - Likely hung — no new output and no advancing heartbeat for ~10 minutes (roughly five consecutive two-minute checks), or the underlying shell/process is no longer running while the command has not returned an exit status. Stop reporting "in progress", surface it to the user as a suspected hang with the elapsed time and last captured output, and ask how to proceed. Do not silently kill the process or self-diagnose — follow the error-handling policy below if the user decides to abort.
When in doubt, prefer waiting over cancelling: the cost of a needlessly cancelled 40-minute apply is high, and a still-advancing heartbeat always means the run is fine.
Error-handling policy (applies to all apply/deploy work — /allow-all mode included)
When deploying or modifying a sandbox environment, do not attempt to automatically diagnose or fix any error you encounter — even in /allow-all mode. This applies to failures from terraform init, terraform validate, terraform plan, terraform apply, the vnext-testing prep steps, enable-public-access.sh, unit/integration tests, and any other step in these workflows.
On the first error, stop the workflow immediately and instead:
- Document the error. Capture the exact failing command, the full error output, the step/scenario it occurred in, the enabled modules, and any other relevant context (branch, Terraform/provider versions). Do not retry, re-run, or alter configuration in an attempt to work around it. Sanitize before posting — Azure error text frequently embeds internal policy names,
aka.mswiki links, and subscription/tenant/management-group IDs; genericize them per the Sanitizing public GitHub content rules below. - Open a GitHub issue against the repo describing the failure, using the documented details above:
gh issue create --title "<concise error summary>" --body "<command, full error output, step, context>" - Report back to the user with a brief summary of the error and a link to the issue you opened.
Do not resume the workflow until the user explicitly instructs you to. Never silently swallow, retry past, or self-patch a failure.
Scenario 1 — Fresh sandbox from scratch
When deploying a new sandbox environment while the working branch is vnext in the IDE, assume this is vnext testing and complete the following vnext-testing prep steps attended (/allow-all mode still off), after preflight passes but before the /allow-all-mode handoff and terraform init — these steps contain their own ask_user prompts (PR decisions):
After the vnext-testing prep steps complete (or for non-vnext branches, immediately after preflight), perform the /allow-all-mode handoff — prompt the user to run /allow-all and wait for confirmation — then run terraform init && terraform plan && terraform apply.
After a successful apply, act on the unit-testing decision captured in preflight item 7. If it was yes, run unit tests for all installed modules (per the scope confirmed in preflight) without re-prompting; if it was no, skip this step. First satisfy the VM-start gate (./scripts/manage-vms.sh start; see the Tests section) — a policy may have deallocated VMs since the apply:
./scripts/manage-vms.sh start
pwsh -File ./scripts/Invoke-UnitTests.ps1
Scenario 2 — Modifying an existing sandbox (enable / disable modules in terraform.tfvars)
The barrier pattern leaves Key Vault and Storage Account with public access disabled after a successful apply, which breaks subsequent plans. Before terraform plan / apply, you must re-enable public access:
./scripts/enable-public-access.sh
MCAPS precheck — verify the SecurityControl=Ignore tag on the resource group first (required before enable-public-access.sh can work on MCAPS tenants). On MCAPS tenants a modify-effect Azure Policy rewrites Key Vault (and similarly Storage) update requests to force publicNetworkAccess=Disabled. The SecurityControl=Ignore tag on the resource group (evaluated at RG or resource level) exempts resources from this policy. If that tag is missing from the RG — e.g. a prior apply/destroy ran without -var='additional_tags={SecurityControl="Ignore"}' and stripped it — then enable-public-access.sh will report success but the change is silently reverted to Disabled, and the next terraform plan/apply fails with 403 ForbiddenByConnection on Key Vault data-plane reads/writes. Therefore, before running enable-public-access.sh, confirm the tag exists on the RG:
az group show --name <rg> --query "tags.SecurityControl" -o tsv # must print: Ignore
If it prints empty/None, restore the tag before proceeding (either az tag update --resource-id <rg id> --operation Merge --tags SecurityControl=Ignore, or a terraform apply with the additional_tags var). Only once the tag is present will enable-public-access.sh persist and the barrier workflow succeed. Follow the error-handling policy if the tag cannot be restored.
Then perform the /allow-all-mode handoff — prompt the user to run /allow-all and wait for confirmation — then proceed with terraform init (if providers changed) → terraform plan → terraform apply. The barrier resources will re-disable public access at the end of the apply.
After a successful apply, act on the unit-testing decision captured in preflight item 7. If it was yes and a module was newly enabled, run that module's unit tests + integration tests (per the scope confirmed in preflight) without re-prompting; if it was no, skip this step. First satisfy the VM-start gate (./scripts/manage-vms.sh start; see the Tests section) — a policy may have deallocated VMs since the apply:
./scripts/manage-vms.sh start
pwsh -File ./scripts/Invoke-UnitTests.ps1 -Module <module_name> -Integration
Scenario 3 — Reproducing a reported test failure for a VM-containing module (re-provision repro)
Applicability gate — VM modules only. Use this repro only when the issue under investigation is a unit/integration test failure for a module that provisions one or more VMs (e.g. vm_jumpbox_linux, vm_mssql_win, avd, vnet_onprem, or the adds1/jumpwin1 VMs in vnet_shared). The pattern works by tearing down and re-creating the module's VM(s) so cloud-init / run-command VM-side configuration re-runs against the current environment. Do not use it for modules with no VM (e.g. mssql, mysql flexible servers, vnet_app on its own) — there is no VM-side config to re-trigger, so the re-provision proves nothing; investigate those differently. If unsure whether the target module provisions a VM, confirm before using this scenario.
Why this works (root-cause class it targets). Many VM-module test failures are environmental rather than code defects — most commonly a dependency VM (especially the domain controller adds1) was stopped/deallocated (e.g. by a cost-optimization auto-shutdown policy) when the original tests ran, breaking AD DNS resolution, Kerberos TGT acquisition, domain join, CIFS mounts, SQL auth, etc. Re-provisioning the failing module's VM with every VM confirmed running isolates that class: if the tests now pass, the failure was environmental (transient) → close the issue; if they still fail, it is a genuine, reproducible defect → update the issue with the evidence and leave it open.
This is a specialization of Scenario 2 (it disables then re-enables a single module), so all Scenario 2 rules apply — most importantly running ./scripts/enable-public-access.sh before each terraform plan/apply, and the /allow-all-mode handoff. It requires an already-applied sandbox in the current Terraform state.
Steps (this is exactly the repro plan validated against issue #447):
- Start all VMs so no dependency (DC, SQL host, etc.) is deallocated during re-provision and testing:
The script starts./scripts/manage-vms.sh startadds1(domain controller) first and waits for it; verify withaz vm list -g <rg> -d --query "[].{Name:name, PowerState:powerState}" -o tablethat the relevant VMs reportVM runningbefore continuing. The DC being up is the whole point — do not skip this. - Disable the target module's
enable_module_<name>flag interraform.tfvars. ./scripts/enable-public-access.sh(re-enable KV/Storage public access before the plan).terraform applyto de-provision the module's VM(s) (runterraform initfirst if needed;terraform validate+terraform plan -out=<name>.tfplanthen apply the saved plan).- Re-enable the same
enable_module_<name>flag interraform.tfvars. ./scripts/enable-public-access.shagain (the prior apply's barrier re-disabled public access).terraform applyto re-provision the module's VM(s); cloud-init / run-command VM-side config re-runs.- Run the module's unit and integration tests:
pwsh -File ./scripts/Invoke-UnitTests.ps1 -Module <module_name> -Integration
Interpreting the result and acting on the issue:
RESULT: PASS→ no repro. Post a comment to the issue documenting the full steps, the passing summary (per-check[PASS]lines + overallPassed=N Failed=0), and the conclusion that the original failure was environmental (e.g. deallocated DC). Close the issue (gh issue close <n> --reason "not planned").RESULT: FAIL→ repro confirmed. Post a comment with the failing[FAIL]lines and context, and leave the issue open for a fix. A reproduced test FAIL is the expected outcome of this scenario and is not itself a workflow error — do not open a second issue or invoke the error-handling policy for it. (The error-handling policy still applies to infrastructure/command failures — aterraform applyerror, a script crash, etc. — which are distinct from a test reporting FAIL.)
Cleanup. Always restore terraform.tfvars to its original module configuration (the target flag back to its starting value, normally true) and delete any temporary plan/log files created during the repro.
Tests (Pester-free, PowerShell-orchestrated)
Preflight item 3 (Azure PowerShell auth) must be satisfied before invoking any of these.
VM-start gate (mandatory — always run immediately before any unit/integration test invocation). In some subscriptions an Azure policy can arbitrarily stop/deallocate VMs (e.g. cost-optimization auto-shutdown), which makes VM-side tests fail spuriously — a deallocated domain controller (adds1) breaks AD DNS, Kerberos, domain join, CIFS, and SQL auth (this is the root cause confirmed in issue #447). Therefore, before every Invoke-UnitTests.ps1 run — in Scenario 1, Scenario 2, the Scenario 3 repro, and any ad-hoc test run — first start all VMs and confirm they are running:
./scripts/manage-vms.sh start
az vm list -g <rg> -d --query "[].{Name:name, PowerState:powerState}" -o table # confirm all 'VM running'
manage-vms.sh start starts adds1 (domain controller) first and waits for it, then starts the rest. Do not begin testing until the relevant VMs report VM running. This gate is non-negotiable because the policy can deallocate VMs at any time, including after a successful terraform apply but before tests run.
For everything else about running tests — invocation syntax, the -Module/-Integration scope rules, valid module names, prerequisites, and how the orchestrator dispatches integration tests — read the header comments at the top of scripts/Invoke-UnitTests.ps1 (they are the source of truth; do not duplicate them here). The two rules that most often trip up automation:
- Running all modules (no
-Module) auto-discovers what's deployed and runs each module's unit and integration tests automatically.-Integrationdoes not apply to an all-modules run and is ignored when-Moduleis omitted. -Integrationis meaningful only with-Module(a single-module run), e.g.pwsh -File ./scripts/Invoke-UnitTests.ps1 -Module vm_mssql_win -Integration.
Architecture
Root module (main.tf, variables.tf, outputs.tf, providers.tf, terraform.tf) wires together child modules in modules/. Each child module is conditionally enabled via a root variable.
Module enablement pattern
Every optional module follows the same shape — do not deviate when adding new ones:
module "vnet_app" {
source = "./modules/vnet-app"
count = var.enable_module_vnet_app ? 1 : 0
...
}
Required: vnet_shared (always on). Optional, with enable_module_* toggles: vnet_app, vm_jumpbox_linux, vm_mssql_win, mssql, mysql, vwan. vnet_app is the hub all other optional modules depend on.
Cross-module references must guard for the optional modules being disabled, e.g.:
var.enable_module_vnet_app ? module.vnet_app[0].resource_ids["storage_account"] : null.
Public-access barrier pattern (critical — read before touching main.tf)
Several shared resources (Key Vault, Storage Account, AMPLS, Log Analytics, App Insights) are provisioned with public_network_access_enabled = true because Terraform data-plane writes (secrets, blob uploads, diagnostic settings) need it. The root module flips them to private after every enabled module finishes its writes, using an implicit-dependency "barrier":
- Each module that performs public-access-requiring writes exposes a
*_operations_completeoutput (e.g.key_vault_operations_complete,storage_operations_complete,log_analytics_operations_complete) that depends on its last data-plane write. - Root
main.tfcollects these signals into aterraform_databarrier resource (one per shared resource:key_vault_access_barrier,storage_access_barrier,ampls_access_barrier). - An
azapi_update_resourcereferencesterraform_data.<barrier>.output.<id>, creating the implicit dependency chain. Nodepends_onneeded. - Each child resource that the barrier closes must declare
lifecycle { ignore_changes = [public_network_access_enabled] }so subsequent plans don't fight the barrier.
When adding a new module that touches one of these shared resources: emit the appropriate *_operations_complete output and add it as an input to the matching barrier in root main.tf. The AMPLS barrier is being rolled out incrementally — see comments in main.tf for which modules are wired in.
File layout convention inside modules
Larger modules split HCL by concern instead of one giant main.tf:
compute.tf, network.tf, storage.tf, locals.tf, main.tf, outputs.tf, terraform.tf, variables.tf. Within a .tf file, group resources with #region <name> / #endregion comments.
Outputs convention
Modules expose two map outputs used heavily by the root and other modules: resource_ids and resource_names (and sometimes fqdns, private_dns_zones). Look them up by key, e.g. module.vnet_shared.resource_ids["key_vault"]. Add new resources to these maps rather than creating new top-level outputs.
Secrets handling
- Secrets that should never be in state files are passed via env var (
TF_VAR_arm_client_secret). - Key Vault secrets use the write-only attribute pair:
value_wo = ...+value_wo_version = var.<name>_secret_version. Use this pattern (notvalue) for any new Key Vault secret so values don't land in state. .gitignoreexcludes*.tfvars,*.pem,*.pfx,*.tfstate*,terraform.log.
Documentation expectations
Every module has a README.md with the same sections: Architecture (drawio SVG in images/), Overview, Smoke testing, Documentation (variables / resources / outputs tables). When adding or changing inputs, resources, or outputs, update both the module README and the root README's relevant table.
Sanitizing public GitHub content
This is a public repo — issues, PRs, comments, and commit messages are world-readable. Before writing any (including auto-filed error issues), replace internal/environment specifics with generic wording: Azure Policy definition/assignment/initiative names → "a modify-effect Azure Policy"; internal program acronyms and aka.ms/wiki links → omit; subscription/tenant/management-group IDs, SPN appId/objectId, user object IDs → <subscription>, <tenant>, etc.; per-deployment resource names, execution-host names, private IPs → placeholders. The literal SecurityControl=Ignore tag may appear only in this file — elsewhere call it "an organization-specific exemption tag." Keep non-identifying technical content (resource types, file paths, public ARM schema, learn.microsoft.com links, error codes).
Branch / PR notes
- Day-to-day work happens on
vnext; PRs targetvnext. - A CLA bot runs on PRs (Microsoft Open Source CLA).
.vscode/tasks.jsonprovides a one-shot task to squash-merge all open PRs intovnext(gh pr list ... | xargs gh pr merge --squash).- Merge strategy: squash-merge topic/contributor PRs →
vnext; use a regular merge commit (no squash) for thevnext→mainrelease PR so individual commits and branch lineage are preserved onmain. See CONTRIBUTING.md "Merge strategy".
Standalone configurations under extras/configurations/
extras/configurations/rg-devops-iac/ is a standalone Terraform configuration (not a module of the root sandbox) that provisions a minimal Linux Terraform execution environment — VNet + NAT, storage account for remote state, Key Vault, and a reusable vm-jumpbox-linux child module configured with managed-identity-based azurerm provider auth. Use it as a starting point for DevOps/IaC pipelines, not as part of a sandbox apply. See extras/configurations/rg-devops-iac/README.md for its own preflight, apply, and teardown steps — it has independent state and lifecycle.
MCP servers configured for this workspace
These MCP servers are wired up for Copilot sessions in this repo; prefer them over web search / memory for the topics they cover:
- Microsoft Learn (
microsoft_docs_search,microsoft_docs_fetch,microsoft_code_sample_search) — first stop for Azure service docs, ARM/AzAPI resource reference, and Microsoft SDK code samples. Use before falling back to general web search for any Azure/Microsoft topic. - Azure Terraform (
azure-terraform-*,azure-azureterraform,azure-azureterraformbestpractices,azure-bicepschema) —azurerm/azapi/ AVM provider documentation, resource schemas, Azure Terraform best-practice guidance,aztfexportcommand generation, andconftestpolicy validation. Use these to look up resource arguments/attributes before guessing or grepping provider source. - GitHub (
ghCLI, plusgithub-mcp-server-*tools) — PR/issue/workflow/repo operations. Prefer theghCLI for routine operations (per the gh-cli preference); the MCP tools are useful for cross-repo code search (github-mcp-server-search_code) and Copilot Spaces lookup.
Trust
Not scanned yet. Artifacts are graded after they are crawled, so a recently discovered one may have no result for a while.
Versions
git-01e34806f26c2026-08-04