diff --git a/.agents/skills/AVM-Terraform-Development/AzAPI.md b/.agents/skills/AVM-Terraform-Development/AzAPI.md deleted file mode 100644 index ca151e4..0000000 --- a/.agents/skills/AVM-Terraform-Development/AzAPI.md +++ /dev/null @@ -1,125 +0,0 @@ -# AzAPI Provider - -All Azure resources in AVM modules MUST be deployed using the **AzAPI provider** (`Azure/azapi`). - -## Provider Configuration - -```hcl -terraform { - required_providers { - azapi = { - source = "Azure/azapi" - version = "~> 2.8" - } - } -} -``` - -## Resource Pattern - -AzAPI resources use ARM resource types with explicit API versions: - -```hcl -resource "azapi_resource" "example" { - type = "/@" - parent_id = "" - name = "" - location = "" - - body = { - properties = { - # Resource-specific properties (HCL object, NOT JSON string) - } - } - - # MUST include this, set to empty list if no exports are needed. - response_export_values = [ - "properties.", - ] -} -``` - -### Key attributes - -| Attribute | Description | -| ------------------------ | ----------------------------------------------------------------------------------------------------- | -| `type` | ARM resource type with API version (e.g., `Microsoft.Storage/storageAccounts@2023-01-01`) | -| `parent_id` | ID of the parent resource. For top-level resources: `/subscriptions/{sub}/resourceGroups/{rg}` | -| `name` | Resource name | -| `location` | Azure region | -| `body` | Resource properties as an **HCL object** (not a JSON string) | -| `response_export_values` | List of ARM property paths to export set to empty list if not used (e.g., `"properties.principalId"`) | -| `locks` | A mutex. List of resource IDs to lock on to prevent concurrent operations | - -### Accessing outputs - -Use `.output` to access exported values: - -```hcl -azapi_resource.example.output.properties.principalId -``` - -## Data sources - -```hcl -# Get current client context (subscription, tenant) -data "azapi_client_config" "current" {} - -# Use in expressions: -data.azapi_client_config.current.subscription_id -data.azapi_client_config.current.subscription_resource_id -data.azapi_client_config.current.tenant_id -``` - -## Unit test mocking - -```hcl -mock_provider "azapi" {} -``` - -## Azure Resource Schema Lookup - -Use the `azure-schema` CLI tool (bundled at `.agents/skills/AVM-Terraform-Development/azure-schema`) to look up resource type schemas, properties, constraints, and available API versions. This is essential for knowing the correct `type` and `body` structure for `azapi_resource`. - -### List available API versions - -```bash -.agents/skills/AVM-Terraform-Development/azure-schema versions Microsoft.Storage -``` - -### Get a resource schema (human-readable) - -```bash -.agents/skills/AVM-Terraform-Development/azure-schema get Microsoft.Storage/storageAccounts 2023-01-01 -``` - -### Get a resource schema (resolved JSON) - -```bash -.agents/skills/AVM-Terraform-Development/azure-schema get Microsoft.Storage/storageAccounts 2023-01-01 --json -``` - -### Control depth - -```bash -# Shallow view (top-level properties only) -.agents/skills/AVM-Terraform-Development/azure-schema get Microsoft.Storage/storageAccounts 2023-01-01 --depth 2 - -# Deep view (default is 5) -.agents/skills/AVM-Terraform-Development/azure-schema get Microsoft.Storage/storageAccounts 2023-01-01 --depth 8 -``` - -## Sensitive attributes - -- Passwords, keys, etc should be passed in using the `sensitive_body` attribute. This object is merged with the `body` at runtime. -- All sensitive values MUST be ephemeral. -- Use `sensitive_body_version` as a map to track the JSON properties that are set via `sensitive_body`. This allows Terraform to know when the sensitive value has changed, e.g. `sensitive_body_version = { "properties.key1" = "1" }`." -- Reference each sensitive body version as a variable. - -### Workflow - -1. Find the API version: `azure-schema versions ` -2. Get the schema: `azure-schema get ` -3. Map the schema properties into the `body` block of your `azapi_resource` -4. Properties marked `[READ-ONLY]` should not be set in `body` -- use `response_export_values` to read them if required -5. Properties marked `[REQUIRED]` must be included in `body` diff --git a/.agents/skills/AVM-Terraform-Development/SKILL.md b/.agents/skills/AVM-Terraform-Development/SKILL.md deleted file mode 100644 index c68bec3..0000000 --- a/.agents/skills/AVM-Terraform-Development/SKILL.md +++ /dev/null @@ -1,125 +0,0 @@ ---- -name: AVM-Terraform-Development -description: Azure Verified Modules (AVM) Terraform development workflow for fixing issues and adding features -glob: "**/*.terraform,**/*.tf,**/*.tfvars,**/*.tfstate,**/*.tflint.hcl,**/*.tf.json,**/*.tfvars.json" ---- - -# Azure Verified Modules (AVM) Terraform - -Azure Verified Modules (AVM) are pre-built, tested, and validated Terraform and Bicep modules that follow Azure best practices. Use these modules to create, update, or review Azure Infrastructure as Code (IaC) with confidence. - -## Development Workflow - -Follow these steps in order when fixing an issue or adding a feature. - -### Step 1: Start from a clean main branch - -```bash -git checkout main -git pull -``` - -### Step 2: Create and checkout a feature/issue branch - -```bash -git checkout -b feature/ -# or -git checkout -b fix/- -``` - -### Step 3: Implement the change - -All Azure resources MUST be deployed using the **AzAPI provider** (`Azure/azapi`). For AzAPI resource patterns, schema lookups, and the `azure-schema` CLI tool, read [AzAPI.md](AzAPI.md). - -To query Terraform provider schemas (resources, data sources, functions, ephemeral resources), use the `tfpluginschema` CLI. See [tfpluginschema.md](tfpluginschema.md). - -Make the necessary code changes to add the feature or fix the issue. - -### Step 4: Add unit tests (if justified) - -Unit tests use **provider mocking** and live in the `tests/unit` directory. Add or update unit tests when your change introduces new logic, variables, or outputs that can be validated without deploying real infrastructure. For test writing guidance, syntax, and patterns, read [terraform-test.md](terraform-test.md). - -```bash -PORCH_NO_TUI=1 ./avm tf-test-unit -``` - -### Step 5: Add integration tests (if justified) - -Integration tests do **not** use provider mocking and live in the `tests/integration` directory. Add or update integration tests when your change requires validation against real Azure infrastructure. For test writing guidance, syntax, and patterns, read [terraform-test.md](terraform-test.md). - -```bash -PORCH_NO_TUI=1 ./avm tf-test-integration -``` - -### Step 6: Add or update examples (if justified) - -If your change affects module usage or introduces new functionality, add or update examples in the `examples/` directory. Test only the pertinent example: - -```bash -PORCH_NO_TUI=1 AVM_EXAMPLE="" ./avm test-examples -``` - -### Step 7: Update documentation (if justified) - -If documentation changes are needed, edit `_header.md`. **NEVER edit README.md directly** -- it is auto-generated and will be overwritten. - -### Step 8: Run pre-commit checks (MANDATORY) - -This must **always** be run before committing: - -```bash -PORCH_NO_TUI=1 ./avm pre-commit -``` - -### Step 9: Commit changes - -```bash -git add . -git commit -m ": " -``` - -### Step 10: Run PR checks (MANDATORY) - -This must **always** be run after committing: - -```bash -PORCH_NO_TUI=1 ./avm pr-check -``` - -### Step 11: Push and open a PR - -Push the branch to remote and open a pull request with a meaningful description. Reference any issues that should be closed. - -```bash -git push -u origin HEAD -``` - -When creating the PR, include: - -- A clear description of what was changed and why -- References to related issues (e.g., `Closes #123`) - -## Troubleshooting Test Failures - -If any issues arise during testing or PR checks, refer to the official AVM testing documentation: - - - -## Reference - -### Code Quality - -- Always run `terraform fmt` after making changes -- Always run `terraform validate` after making changes -- Use meaningful variable names and descriptions -- Use snake_case -- Add proper tags and metadata -- Document complex configurations - -### Tool Integration - -- **AzAPI Provider & Schema Lookup**: See [AzAPI.md](AzAPI.md) for resource patterns and the `azure-schema` CLI tool -- **Terraform Provider Schemas**: See [tfpluginschema.md](tfpluginschema.md) for querying resource, data source, function, and ephemeral schemas from any provider -- **Terraform Tests**: See [terraform-test.md](terraform-test.md) for writing unit and integration tests -- **Deployment Guidance**: Use `azure_get_deployment_best_practices` tool -- **Service Documentation**: Use `microsoft.docs.mcp` tool for Azure service-specific guidance diff --git a/.agents/skills/AVM-Terraform-Development/azure-schema b/.agents/skills/AVM-Terraform-Development/azure-schema deleted file mode 100644 index 62aa911..0000000 --- a/.agents/skills/AVM-Terraform-Development/azure-schema +++ /dev/null @@ -1,397 +0,0 @@ -#!/usr/bin/env bash -# azure-schema - Query Azure resource type schemas from the command line. -# -# Data source: bicep-types-az (https://github.com/Azure/bicep-types-az) -# - index.json for resource type discovery and API version listing -# - types.json per resource type for schema definitions -# -# Dependencies: bash 4+, curl, jq - -set -euo pipefail - -readonly PROG="$(basename "$0")" -readonly CACHE_DIR="${XDG_CACHE_HOME:-${HOME}/.cache}/azure-schema" -readonly BICEP_TYPES_BASE="https://raw.githubusercontent.com/Azure/bicep-types-az/main/generated" -readonly INDEX_URL="${BICEP_TYPES_BASE}/index.json" -readonly INDEX_CACHE="${CACHE_DIR}/index.json" -readonly INDEX_MAX_AGE=86400 # 24 hours - -# Bicep type flags (bitfield) -# 1 = Required, 2 = ReadOnly, 4 = WriteOnly, 8 = DeployTimeConstant -readonly FLAG_REQUIRED=1 -readonly FLAG_READONLY=2 -readonly FLAG_WRITEONLY=4 - -# --------------------------------------------------------------------------- # -# Helpers -# --------------------------------------------------------------------------- # - -die() { printf "Error: %b\n" "$*" >&2; exit 1; } - -usage() { - cat < [--json] [--depth N] - ${PROG} versions - ${PROG} help - -Commands: - get Fetch the schema for a resource type at a given API version. - Default output is a human-readable summary. Pass --json for raw resolved JSON. - --depth N Resolve nested object properties to N levels deep (default: 5). - - versions List available API versions for all resource types under a provider. - -Examples: - ${PROG} get Microsoft.ContainerService/managedClusters 2025-10-01 - ${PROG} get Microsoft.Storage/storageAccounts 2023-01-01 --json - ${PROG} get Microsoft.Storage/storageAccounts 2023-01-01 --depth 3 - ${PROG} versions Microsoft.Storage -EOF -} - -ensure_cache_dir() { - [[ -d "${CACHE_DIR}" ]] || mkdir -p "${CACHE_DIR}" -} - -# Download the bicep-types-az index.json and cache it. -fetch_index() { - ensure_cache_dir - local needs_fetch=0 - - if [[ ! -f "${INDEX_CACHE}" ]]; then - needs_fetch=1 - else - local age - age=$(( $(date +%s) - $(stat -c %Y "${INDEX_CACHE}" 2>/dev/null || stat -f %m "${INDEX_CACHE}" 2>/dev/null || echo 0) )) - if (( age > INDEX_MAX_AGE )); then - needs_fetch=1 - fi - fi - - if (( needs_fetch )); then - echo "Fetching resource type index (cached for 24h)..." >&2 - curl -sSfL "${INDEX_URL}" -o "${INDEX_CACHE}" || die "Failed to download index from ${INDEX_URL}" - fi -} - -# Look up a resource type in the index and return "file_path type_index". -# e.g. "containerservice_0/microsoft.containerservice/2025-10-01/types.json 376" -resolve_index_ref() { - local resource_type="$1" - local api_version="$2" - - local lookup_key="${resource_type}@${api_version}" - - local ref - ref="$(jq -r --arg key "${lookup_key}" ' - .resources[$key]["$ref"] // - (.resources | to_entries[] | select(.key | ascii_downcase == ($key | ascii_downcase)) | .value["$ref"]) // - empty - ' "${INDEX_CACHE}" 2>/dev/null)" || return 1 - - [[ -n "${ref}" ]] || return 1 - - # ref format: "containerservice_0/microsoft.containerservice/2025-10-01/types.json#/376" - local file_path type_index - file_path="$(echo "${ref}" | cut -d'#' -f1)" - type_index="$(echo "${ref}" | cut -d'/' -f2- | cut -d'#' -f2 | tr -d '/')" - - echo "${file_path} ${type_index}" -} - -# Fetch a types.json file and cache it. -fetch_types_file() { - local file_path="$1" - ensure_cache_dir - - # Create a safe cache filename from the path - local cache_key - cache_key="$(echo "${file_path}" | tr '/' '_')" - local cache_file="${CACHE_DIR}/${cache_key}" - - if [[ ! -f "${cache_file}" ]]; then - local url="${BICEP_TYPES_BASE}/${file_path}" - echo "Fetching types from ${file_path}..." >&2 - curl -sSfL "${url}" -o "${cache_file}" 2>/dev/null \ - || { rm -f "${cache_file}"; die "Failed to fetch ${url}"; } - fi - - echo "${cache_file}" -} - -# --------------------------------------------------------------------------- # -# cmd: versions -# --------------------------------------------------------------------------- # - -cmd_versions() { - local provider="${1:-}" - [[ -n "${provider}" ]] || die "Usage: ${PROG} versions Example: ${PROG} versions Microsoft.Storage" - - fetch_index - - local pattern - pattern="$(echo "${provider}" | tr '[:upper:]' '[:lower:]')" - - jq -r --arg pat "${pattern}" ' - .resources | keys[] | - select((. | ascii_downcase) | startswith($pat)) | - split("@") | "\(.[0]) \(.[1])" - ' "${INDEX_CACHE}" | sort | column -t - - local count - count=$(jq -r --arg pat "${pattern}" ' - [.resources | keys[] | select((. | ascii_downcase) | startswith($pat))] | length - ' "${INDEX_CACHE}") - - echo "" >&2 - echo "${count} resource type/version(s) found for ${provider}" >&2 -} - -# --------------------------------------------------------------------------- # -# cmd: get -# --------------------------------------------------------------------------- # - -cmd_get() { - local resource_type="${1:-}" - local api_version="${2:-}" - local output_mode="summary" - local max_depth=5 - - shift 2 2>/dev/null || true - while [[ $# -gt 0 ]]; do - case "$1" in - --json) output_mode="json" ;; - --depth) - shift - max_depth="${1:-5}" - ;; - *) die "Unknown flag: $1" ;; - esac - shift - done - - [[ -n "${resource_type}" ]] || die "Usage: ${PROG} get [--json]" - [[ -n "${api_version}" ]] || die "Usage: ${PROG} get [--json]" - - fetch_index - - # Look up the resource in the index. - local ref_info - ref_info="$(resolve_index_ref "${resource_type}" "${api_version}")" \ - || die "Resource type '${resource_type}@${api_version}' not found in index.\nUse '${PROG} versions ${resource_type%%/*}' to list available versions." - - local file_path type_index - file_path="$(echo "${ref_info}" | cut -d' ' -f1)" - type_index="$(echo "${ref_info}" | cut -d' ' -f2)" - - # Fetch the types.json file. - local types_file - types_file="$(fetch_types_file "${file_path}")" - - if [[ "${output_mode}" == "json" ]]; then - render_json "${types_file}" "${type_index}" "${max_depth}" - else - render_summary "${types_file}" "${type_index}" "${resource_type}" "${api_version}" "${max_depth}" - fi -} - -# --------------------------------------------------------------------------- # -# JSON output - resolve types into a readable JSON structure -# --------------------------------------------------------------------------- # - -render_json() { - local types_file="$1" - local type_index="$2" - local max_depth="$3" - - jq --argjson idx "${type_index}" --argjson maxd "${max_depth}" ' - . as $types | - - # Resolve a type ref index to a type description, with depth limiting. - def resolve(depth): - . as $t | - if depth > $maxd then - if $t["$type"] == "ObjectType" then {"type": "object", "name": $t.name, "_truncated": "depth limit exceeded"} - else {"type": ($t["$type"] // "unknown"), "_truncated": "depth limit exceeded"} - end - else - if $t["$type"] == "StringType" then - { type: "string" } - + (if $t | has("minLength") then { minLength: $t.minLength } else {} end) - + (if $t | has("maxLength") then { maxLength: $t.maxLength } else {} end) - + (if $t | has("pattern") then { pattern: $t.pattern } else {} end) - elif $t["$type"] == "StringLiteralType" then - { type: "string", const: $t.value } - elif $t["$type"] == "IntegerType" then - { type: "integer" } - + (if $t | has("minValue") then { minimum: $t.minValue } else {} end) - + (if $t | has("maxValue") then { maximum: $t.maxValue } else {} end) - elif $t["$type"] == "BooleanType" then - { type: "boolean" } - elif $t["$type"] == "AnyType" then - { type: "any" } - elif $t["$type"] == "ArrayType" then - { type: "array" } - + (if $t.itemType["$ref"] then - { items: ($types[$t.itemType["$ref"] | split("/") | last | tonumber] | resolve(depth + 1)) } - else {} end) - elif $t["$type"] == "UnionType" then - { type: "union", - oneOf: [ $t.elements[] | - if .["$ref"] then - $types[.["$ref"] | split("/") | last | tonumber] | resolve(depth + 1) - else . - end - ] - } - elif $t["$type"] == "ObjectType" then - { type: "object", name: $t.name } - + (if $t.properties then - { properties: - ($t.properties | to_entries | map( - { key: .key, - value: ( - .value as $prop | - (if $prop.type["$ref"] then - $types[$prop.type["$ref"] | split("/") | last | tonumber] | resolve(depth + 1) - else {} - end) - + (if $prop.description then { description: $prop.description } else {} end) - + (if ($prop.flags // 0) | (. / 1 | floor) % 2 == 1 then { required: true } else {} end) - + (if ($prop.flags // 0) | (. / 2 | floor) % 2 == 1 then { readOnly: true } else {} end) - + (if ($prop.flags // 0) | (. / 4 | floor) % 2 == 1 then { writeOnly: true } else {} end) - ) - } - ) | from_entries) - } - else {} end) - else - { type: ($t["$type"] // "unknown") } - end - end; - - # Start from the ResourceType entry, follow its body ref. - .[$idx] as $rt | - ($rt.body["$ref"] | split("/") | last | tonumber) as $body_idx | - $types[$body_idx] | resolve(0) - ' "${types_file}" -} - -# --------------------------------------------------------------------------- # -# Human-readable summary renderer -# --------------------------------------------------------------------------- # - -render_summary() { - local types_file="$1" - local type_index="$2" - local resource_type="$3" - local api_version="$4" - local max_depth="$5" - - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo " ${resource_type} @ ${api_version}" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - jq -r --argjson idx "${type_index}" --argjson maxd "${max_depth}" ' - . as $types | - - # Resolve a type index to a short type string. - def type_str: - if .["$type"] == "StringType" then "string" - elif .["$type"] == "StringLiteralType" then "\"\(.value)\"" - elif .["$type"] == "IntegerType" then "integer" - elif .["$type"] == "BooleanType" then "boolean" - elif .["$type"] == "AnyType" then "any" - elif .["$type"] == "ArrayType" then - if .itemType["$ref"] then - "array<\($types[.itemType["$ref"] | split("/") | last | tonumber] | type_str)>" - else "array" - end - elif .["$type"] == "UnionType" then - "(" + ([.elements[] | - if .["$ref"] then $types[.["$ref"] | split("/") | last | tonumber] | type_str - else "?" end - ] | join(" | ")) + ")" - elif .["$type"] == "ObjectType" then .name // "object" - elif .["$type"] == "ResourceType" then .name // "resource" - else .["$type"] // "unknown" - end; - - # Print properties of an object type at a given indent level. - def print_props(indent; depth): - .properties // {} | to_entries[] | - .key as $name | - .value as $prop | - ($prop.flags // 0) as $flags | - - # Resolve the type - (if $prop.type["$ref"] then - $types[$prop.type["$ref"] | split("/") | last | tonumber] - else null end) as $resolved | - - # Type string - (if $resolved then $resolved | type_str else "unknown" end) as $tstr | - - # Flags - (if ($flags / 1 | floor) % 2 == 1 then " [REQUIRED]" else "" end) as $req | - (if ($flags / 2 | floor) % 2 == 1 then " [READ-ONLY]" else "" end) as $ro | - (if ($flags / 4 | floor) % 2 == 1 then " [WRITE-ONLY]" else "" end) as $wo | - - # Description (truncate at 120 chars) - (if $prop.description then - if ($prop.description | length) > 120 then - "\n\(indent) \($prop.description[:120])..." - else - "\n\(indent) \($prop.description)" - end - else "" end) as $desc | - - "\(indent) \($name): \($tstr)\($req)\($ro)\($wo)\($desc)", - - # Recurse into nested ObjectType properties, or show truncation indicator - (if $resolved and $resolved["$type"] == "ObjectType" and $resolved.properties then - if depth < $maxd then - $resolved | print_props("\(indent) "; depth + 1) - else - "\(indent) (...depth limit exceeded)" - end - else empty end); - - # Start from the ResourceType entry, follow its body ref. - .[$idx] as $rt | - ($rt.body["$ref"] | split("/") | last | tonumber) as $body_idx | - $types[$body_idx] as $body | - - # Collect required top-level properties - ([$body.properties // {} | to_entries[] | select((.value.flags // 0) as $f | ($f / 1 | floor) % 2 == 1) | .key]) as $required | - - "PROPERTIES:", - "───────────────────────────────────────────────────────────────────────────────", - ($body | print_props(""; 0)), - "", - "───────────────────────────────────────────────────────────────────────────────", - "Required: \($required | join(", "))" - ' "${types_file}" - - echo "" -} - -# --------------------------------------------------------------------------- # -# Main -# --------------------------------------------------------------------------- # - -main() { - local cmd="${1:-help}" - shift 2>/dev/null || true - - case "${cmd}" in - get) cmd_get "$@" ;; - versions) cmd_versions "$@" ;; - help|--help|-h) usage ;; - *) die "Unknown command: ${cmd}. Run '${PROG} help' for usage." ;; - esac -} - -main "$@" diff --git a/.agents/skills/avm-terraform-module-development/SKILL.md b/.agents/skills/avm-terraform-module-development/SKILL.md new file mode 100644 index 0000000..cbeba51 --- /dev/null +++ b/.agents/skills/avm-terraform-module-development/SKILL.md @@ -0,0 +1,213 @@ +--- +name: avm-terraform-module-development +description: Azure Verified Modules (AVM) Terraform development workflow for reviewing, fixing, and extending Resource Modules and Pattern Modules +glob: "**/*.terraform,**/*.tf,**/*.tfvars,**/*.tfstate,**/*.tflint.hcl,**/*.tf.json,**/*.tfvars.json" +--- + +# Azure Verified Modules (AVM) Terraform + +Azure Verified Modules (AVM) are pre-built, tested, and validated Terraform and Bicep modules that follow Azure best practices. Use this skill when reviewing, fixing, or extending an AVM Terraform module so the change stays aligned with the published AVM specifications. + +## Before you start + +### 1. Identify the module type + +Look at the repo name and `_header.md` to classify the module. The naming convention is `terraform--avm--`: + +| Type | Name token | Purpose | +|------|------------|---------| +| Resource Module | `res` | Deploys a single primary Azure resource plus its tightly-coupled children (e.g. `terraform-azurerm-avm-res-storage-storageaccount`). | +| Pattern Module | `ptn` | Composes multiple resource modules into an opinionated workload (e.g. `terraform-azurerm-avm-ptn-aks-production`). | +| Utility Module | `utl` | Helper module exposing shared inputs/outputs (e.g. `Azure/avm-utl-interfaces/azure`). | + +The composition rules differ slightly per type. The shared rules below apply to **resource and pattern modules** — if you are working on a utility module, fetch its dedicated spec. + +### 2. Read the right spec, on demand + +Every AVM rule has an ID (e.g. `TFRMFR1`, `TFFR4`, `SNFR3`). When you need the authoritative text: + +1. Fetch the AVM spec index once per session: + - `https://azure.github.io/Azure-Verified-Modules/llms.txt` +2. Look up the raw markdown URL for the spec ID you need. URLs follow the pattern: + - `https://raw.githubusercontent.com/Azure/Azure-Verified-Modules/refs/heads/main/docs/content/specs-defs/includes/terraform///.md` +3. Fetch and read the specific spec markdown. + +Never cite a spec ID without first confirming its current text from the index — wording and severity can change. + +### 3. Understand severity prefixes + +AVM uses RFC 2119 keywords: **MUST**, **SHOULD**, **MAY**. Treat `MUST` rules as blocking and `SHOULD` as defaults that need an explicit reason to skip. + +## Module composition checklist + +Before you claim a change is done, verify the module still satisfies these MUST-level gates. For full text, look up each ID via `llms.txt`. + +### Repository & file layout + +- `RMNFR1` — Module names follow `terraform--avm--[-]`. The approved name is the one in the module proposal / module-index CSV — don't construct it yourself. +- `TFNFR39` — Standard file layout: `main.tf`, `variables.tf`, `outputs.tf`, `terraform.tf` are MUST; `locals.tf` is required if any locals exist. Files MAY be split as `main..tf` / `variables..tf` / `outputs..tf` / `locals..tf` using the canonical prefix. The `terraform {}` block appears exactly once, in `terraform.tf`. No `providers.tf` at module root. +- `TFNFR2` / `SNFR15` — `README.md` is auto-generated. Edit `_header.md` (and `_footer.md`) only — they are required inputs to docs generation, in every submodule too. +- `TFNFR4` — `snake_case` everywhere in Terraform code. + +### Inputs & outputs + +- `TFNFR1` / `TFNFR17` / `TFNFR18` — Every variable and output has a `description` and a precise `type`. +- `TFNFR20` — Collection variables (`map`, `set`, `list`) default to `{}` / `[]` with `nullable = false` rather than `null`. +- `RMFR7` / `TFFR2` / `TFNFR16` — Outputs follow AVM minimum requirements and naming rules. For Terraform-specific additional outputs, prefer discrete computed attributes over whole resource object outputs. +- `TFRMFR1` — **Resource Module Parent ID**: expose the parent scope as a single `string` variable named `parent_id`, `nullable = false`, no default. Assign it to `parent_id` on every primary `azapi_resource`. Modules **MUST NOT** accept `resource_group_name`, `resource_group_resource_id`, or any other parent-scope-specific variable. Modules **MUST NOT** create the parent scope themselves (supersedes the Terraform clause of `RMFR3`). Submodules typically receive `parent_id = azapi_resource.this.id` from the parent. +- `TFNFR38` — Validate `parent_id` with `can(provider::azapi::parse_resource_id("", var.parent_id))`. The expected parent type **MUST** be a literal string (e.g. `"Microsoft.Resources/resourceGroups"` or `"Microsoft.Network/virtualNetworks"`). Hand-rolled `regex`/`startswith`/`length` checks are not allowed. Extension-resource modules (locks, role assignments, diagnostic settings, tags, etc.) are the only exception and use a generic `startswith` check on `/subscriptions/` or `/providers/`, with the reason documented in the README. + +### Resource implementation + +- `TFRMNFR2` — **Primary Resource Naming**: the primary `azapi_resource` (or equivalent AzAPI resource) **MUST** be named `this`. Every satellite resource (lock, role assignments, diagnostic settings, private endpoints, child resources required by the primary, etc.) **MUST NOT** be named `this` — it **MUST** be named after what it represents (e.g. `azapi_resource.lock`, `azapi_resource.role_assignments`, `azapi_resource.diagnostic_settings`). Each submodule has its own `this`. This is what lets consumers and the AVM interface utility module rely on `azapi_resource.this.id` and `azapi_resource.this.output`. +- `TFRMNFR1` — **Subresources as submodules**: every ARM subresource (a child resource type in the API spec) **MUST** be implemented as a Terraform submodule under `modules//`. Submodules are full AVM modules in their own right (same shared/RM/TF specs apply), each with their own `_header.md` and `_footer.md`. Submodules **MUST NOT** declare `count` / `for_each` on their primary `azapi_resource` — cardinality is the parent's responsibility. Parent modules **MUST** reference submodules by local relative path (`./modules/`), not via the registry or git. +- `TFFR3` — Resources are implemented with the **AzAPI provider** (`Azure/azapi` `>= 2.0, < 3.0`). Only fall back to `azurerm` (preferring data sources) when AzAPI genuinely lacks an equivalent; document the reason in code and in `README.md` per the exception requirements. +- `TFFR4` — Every `azapi_resource` **MUST** specify `response_export_values`, even if it is `[]`. Use it (list or map form) to surface read-only properties needed by the module's outputs. +- `TFFR5` — Every `azapi_resource` **MUST** specify `replace_triggers_refs`, listing the body paths that should force replacement when changed. `name` and `location` already trigger replacement and don't need to be listed. +- `TFFR6` — The `type` argument **MUST NOT** be hard-coded. Source it from a `resource_types` object variable with one `optional(string, "/@")` field per AzAPI resource the module declares. Defaults must be stable (non-preview) API versions. Parent modules **MUST** cascade the relevant subset of `resource_types` to each submodule. +- `TFFR7` — Expose `retry` and `timeouts` variables and apply them to every `azapi_resource`. `retry` is an attribute (assign directly); `timeouts` is a block (use `dynamic "timeouts"` so the `null` default works). Cascade unchanged into submodules. See [AzAPI.md](references/AzAPI.md). + +For full AzAPI patterns, the `parent_id` variable shape, the `Get-AzureSchema` lookup CLI, and provider configuration, read [AzAPI.md](references/AzAPI.md). + +### Telemetry, providers, and required versions + +- `SFR3` / `SFR4` — `main.telemetry.tf` must declare the `modtm` telemetry resource gated on `var.enable_telemetry`. Do not remove or rename it. +- `TFFR3` / `TFNFR26` — Pin `required_providers` versions (`Azure/azapi`, `Azure/modtm`, `hashicorp/random`, any other providers used) in the single `terraform {}` block in `terraform.tf`. AzAPI version policy is governed by `TFFR3`. +- `TFNFR27` — No provider configuration blocks in modules (only `required_providers`). Provider configuration belongs in the consumer's root module. + +The `mapotf` pre-commit config under [mapotf-configs/pre-commit](../../../../../mapotf-configs/pre-commit/) enforces the telemetry block and provider versions automatically — do not hand-edit those generated files. + +### Standard interfaces + +AVM defines a fixed set of standard interfaces that resource modules expose where the underlying Azure resource supports them. They standardise variable names, types, and behaviour across every module: + +- **Resource features** (apply only when the underlying resource supports them): diagnostic settings (v2 schema), role assignments, locks, managed identities, private endpoints, customer-managed keys, tags. +- **AzAPI mechanics** (apply to every module): `resource_types` (API-version pinning per `azapi_resource`, module-specific keys, cascaded to submodules), `retry` (assigned as an attribute), `timeouts` (emitted via a `dynamic "timeouts"` block). + +The resource-feature interfaces are backed by the shared utility module `Azure/avm-utl-interfaces/azure` — compose it rather than redefining variable shapes by hand. The diagnostic-settings interface MUST use the v2 shape (`diagnostic_settings_v2` input / `diagnostic_settings_azapi_v2` output on the utility module). + +For variable shapes, defaults, the v2 diagnostic-settings details, and which interfaces apply to which resource, read [interfaces.md](references/interfaces.md). For the `retry` / `timeouts` variable schemas and the required `dynamic "timeouts"` wiring on `azapi_resource`, read [AzAPI.md](references/AzAPI.md). + +### Module composition reference + +For a single concise summary of how a resource or pattern module fits together (file layout, parent-child resource splitting, sub-module rules, examples folder conventions), read [module-composition.md](references/module-composition.md). + +## Development Workflow + +Follow these steps in order when fixing an issue or adding a feature. + +### Step 1: Start from a clean main branch + +```bash +git checkout main +git pull +``` + +### Step 2: Create and checkout a feature/issue branch + +```bash +git checkout -b feature/ +# or +git checkout -b fix/- +``` + +### Step 3: Implement the change + +Make the necessary code changes, keeping the composition checklist above in mind. + +For AzAPI resource patterns, schema lookups, and the `Get-AzureSchema` CLI tool, read [AzAPI.md](references/AzAPI.md). To query Terraform provider schemas (resources, data sources, functions, ephemeral resources), use the `tfpluginschema` CLI — see [tfpluginschema.md](references/tfpluginschema.md). + +### Step 4: Add unit tests (if justified) + +Unit tests use **provider mocking** and live in the `tests/unit` directory. Add or update unit tests when your change introduces new logic, variables, or outputs that can be validated without deploying real infrastructure. For test writing guidance, syntax, and patterns, read [terraform-test.md](references/terraform-test.md). + +```bash +PORCH_NO_TUI=1 ./avm tf-test-unit +``` + +### Step 5: Add integration tests (if justified) + +Integration tests do **not** use provider mocking and live in the `tests/integration` directory. Add or update integration tests when your change requires validation against real Azure infrastructure. For test writing guidance, syntax, and patterns, read [terraform-test.md](references/terraform-test.md). + +```bash +PORCH_NO_TUI=1 ./avm tf-test-integration +``` + +### Step 6: Add or update examples (if justified) + +If your change affects module usage or introduces new functionality, add or update examples in the `examples/` directory. Test only the pertinent example: + +```bash +PORCH_NO_TUI=1 AVM_EXAMPLE="" ./avm test-examples +``` + +When running on Windows, distributing tests across multiple Azure subscriptions, or retaining deployed resources for manual validation, see [example-test.md](references/example-test.md) for manual local testing of examples (init, plan, apply, idempotency check, and optional destroy). + +### Step 7: Update documentation (if justified) + +If documentation changes are needed, edit `_header.md`. **NEVER edit README.md directly** -- it is auto-generated and will be overwritten. + +### Step 8: Run pre-commit checks (MANDATORY) + +This must **always** be run before committing: + +```bash +PORCH_NO_TUI=1 ./avm pre-commit +``` + +### Step 9: Commit changes + +```bash +git add . +git commit -m ": " +``` + +### Step 10: Run PR checks (MANDATORY) + +This must **always** be run after committing: + +```bash +PORCH_NO_TUI=1 ./avm pr-check +``` + +### Step 11: Push and open a PR + +Push the branch to remote and open a pull request with a meaningful description. Reference any issues that should be closed. + +```bash +git push -u origin HEAD +``` + +When creating the PR, include: + +- A summary of the change. +- The issue number(s) the PR closes. +- Any relevant context for reviewers. + +## Common mistakes to avoid + +- **Citing a spec from memory.** AVM specs change. Always fetch the current text via `llms.txt`. Several spec IDs are easy to mix up (e.g. `TFFR4` is `response_export_values`, `TFFR5` is `replace_triggers_refs`, `TFFR6` is `resource_types`, `TFFR7` is `retry`/`timeouts`). +- **Reaching for `azurerm`.** `TFFR3` requires AzAPI; only fall back to `azurerm` for genuinely missing capabilities, and document why. +- **Naming the primary resource anything other than `this`** (`TFRMNFR2`), or naming a satellite resource `this`. The primary `azapi_resource` MUST be `this`; satellites MUST be named after what they represent (`lock`, `role_assignments`, `diagnostic_settings`, ...). +- **Exposing `resource_group_name` (or any other parent-scope-specific variable) instead of `parent_id`** (`TFRMFR1`), or validating `parent_id` with hand-rolled regex/startswith instead of `can(provider::azapi::parse_resource_id("", var.parent_id))` (`TFNFR38`). +- **Creating the parent scope inside the module** (e.g. a `Microsoft.Resources/resourceGroups` `azapi_resource` for the resource group the module deploys into) — `TFRMFR1` forbids this; the consumer supplies an existing scope's ARM ID. +- **Hard-coding the `type` argument on an `azapi_resource`** instead of sourcing it from `var.resource_types` (`TFFR6`), or forgetting to cascade the relevant subset to each submodule. +- **Omitting `response_export_values` (`TFFR4`) or `replace_triggers_refs` (`TFFR5`)** — both are MUST on every `azapi_resource`, even when the value is `[]`. +- **Editing `README.md`, `main.telemetry.tf`, or `terraform.tf` provider versions by hand.** These are generated/enforced — edit `_header.md`, the `modtm` source via mapotf configs, and so on. +- **Defaulting collection variables to `null`** instead of `{}` / `[]` with `nullable = false` (`TFNFR20`). +- **Outputting whole resource objects by default** instead of discrete computed attributes (`TFFR2`), or missing required outputs (`RMFR7`). +- **Implementing an ARM subresource inline in the parent module** instead of as a submodule under `modules//` (`TFRMNFR1`), or declaring `count`/`for_each` on a submodule's primary resource. +- **Adding a new interface (locks, diagnostic settings, role assignments, etc.) without re-using `Azure/avm-utl-interfaces/azure`**. See [interfaces.md](references/interfaces.md). +- **Using the legacy `diagnostic_settings` shape** instead of the v2 schema. The utility module's `diagnostic_settings_v2` input is the required entry point. +- **Omitting `retry`, `timeouts`, or `resource_types` from an `azapi_resource`** — or failing to cascade them unchanged into submodules. All three are MUST-level AVM interfaces. +- **Treating `timeouts` as an attribute.** It is a block; use `dynamic "timeouts"` so the `null` default works. +- **Skipping `./avm pre-commit` before commit, or `./avm pr-check` after commit.** Both are mandatory. + +## Specifications + +The canonical source of every AVM rule is the spec index: + +- **Index of all specs and docs:** +- **Rendered docs site:** + +Fetch `llms.txt` first, locate the raw markdown URL for the spec ID you care about, then fetch that markdown. Do not hard-code spec URLs into module source. diff --git a/.agents/skills/avm-terraform-module-development/references/AzAPI.md b/.agents/skills/avm-terraform-module-development/references/AzAPI.md new file mode 100644 index 0000000..1bbf27b --- /dev/null +++ b/.agents/skills/avm-terraform-module-development/references/AzAPI.md @@ -0,0 +1,285 @@ +# AzAPI Provider + +AVM Terraform modules implement Azure resources with the **AzAPI provider** (`Azure/azapi`). This is required by `TFFR3`. The only legitimate reason to fall back to `azurerm` is when AzAPI has no equivalent resource or data source for the capability you need — when that happens, leave a short comment in the code explaining why, and prefer an `azurerm` *data source* over an `azurerm` *resource* where possible. + +Look up the current text of `TFFR3` (and the rationale for AzAPI-first) via `https://azure.github.io/Azure-Verified-Modules/llms.txt`. + +## Provider configuration + +```hcl +terraform { + required_providers { + azapi = { + source = "Azure/azapi" + version = ">= 2.0, < 3.0" + } + } +} +``` + +## Resource pattern + +AzAPI resources use ARM resource types with explicit API versions. The primary resource **MUST** be named `this` (TFRMNFR2): + +```hcl +resource "azapi_resource" "this" { + type = var.resource_types.this # TFFR6 — sourced from `resource_types`, never hard-coded + parent_id = var.parent_id # TFRMFR1 — single fully-qualified ARM ID, validated via parse_resource_id + name = var.name + location = var.location + + body = { + properties = { + # Resource-specific properties as an HCL object (not a JSON string). + } + } + + # TFFR5 — MUST be specified. List body paths that force replacement on change. + # `name` and `location` already trigger replacement and don't need to be listed. + replace_triggers_refs = [ + # "properties.", + ] + + # TFFR4 — MUST be specified, even if empty. + response_export_values = [ + # "properties.", + ] + + # TFFR7 — Standard AVM `retry` interface; assigned directly because `retry` is an attribute. + retry = var.retry + + # TFFR7 — Standard AVM `timeouts` interface. `timeouts` is a block, not an attribute, + # so a dynamic block is required to honour the variable's `null` default. + dynamic "timeouts" { + for_each = var.timeouts == null ? [] : [var.timeouts] + content { + create = timeouts.value.create + read = timeouts.value.read + update = timeouts.value.update + delete = timeouts.value.delete + } + } +} +``` + +Satellite resources (locks, role assignments, diagnostic settings, private endpoints, etc.) **MUST NOT** be named `this`; they are named after what they represent (`azapi_resource.lock`, `azapi_resource.role_assignments`, `azapi_resource.diagnostic_settings`, ...). See [interfaces.md](./interfaces.md) for the wiring via `Azure/avm-utl-interfaces/azure`. + +### Key attributes + +| Attribute | Description | +| --------------------------- | ---------------------------------------------------------------------------------------------------- | +| `type` | ARM resource type with API version (e.g. `Microsoft.Storage/storageAccounts@2023-01-01`). **MUST** be sourced from `var.resource_types.` — TFFR6 | +| `parent_id` | Fully-qualified ARM ID of the parent scope. Assigned from `var.parent_id` — TFRMFR1 | +| `name` | Resource name | +| `location` | Azure region | +| `body` | Resource properties as an **HCL object** (not a JSON string) | +| `replace_triggers_refs` | Body paths that should force replacement when changed. **MUST** be specified, even if `[]` — TFFR5 | +| `response_export_values` | List or map of ARM property paths to export. **MUST** be specified, even if `[]` — TFFR4 | +| `retry` | AVM `retry` interface attribute. MUST be wired from `var.retry` on every `azapi_resource` — TFFR7 | +| `timeouts` | AVM `timeouts` interface block. MUST be emitted via a `dynamic "timeouts"` block from `var.timeouts` — TFFR7 | +| `locks` | Mutex. List of resource IDs to lock on to prevent concurrent operations | + +### Child resources + +Implement subresources of the primary resource (ARM child resource types like `Microsoft.Example/widgets/parts`) as **submodules** under `modules//`, per `TFRMNFR1`. Do not nest them inside the parent `body` and do not declare them inline in the parent module. Each submodule is a full AVM module with its own `this` primary resource, `parent_id`, `variables.tf`, `outputs.tf`, `terraform.tf`, `_header.md`, `_footer.md`, and tests. + +The parent typically wires submodules like this (using `for_each` for cardinality — a submodule's own primary resource **MUST NOT** use `count` or `for_each`): + +```hcl +module "part" { + source = "./modules/part" + for_each = var.parts + + name = each.value.name + parent_id = azapi_resource.this.id + resource_types = { this = var.resource_types.part } + retry = var.retry + timeouts = var.timeouts +} +``` + +Satellite extension resources of the primary (locks, role assignments, diagnostic settings, private endpoints) are declared as separate `azapi_resource` blocks driven by the standard interface inputs and the `Azure/avm-utl-interfaces/azure` utility module — see [interfaces.md](./interfaces.md). + +### Accessing outputs + +Use `.output` to access exported values: + +```hcl +azapi_resource.this.output.properties.principalId +``` + +## Parent ID (`parent_id`) + +`TFRMFR1` requires every resource module to expose its parent scope as a single string variable named `parent_id`, and to assign it to the `parent_id` argument of every primary `azapi_resource` it manages. The same rule applies to every submodule. + +### Variable declaration + +```hcl +variable "parent_id" { + type = string + nullable = false + + validation { + # TFNFR38 — validate with the AzAPI provider's parse_resource_id function. + # Replace `Microsoft.Resources/resourceGroups` with the parent resource type + # expected by this module's primary resource (e.g. + # `Microsoft.Network/virtualNetworks` for a subnet module). MUST be a literal + # string, not a reference to another variable. + condition = can(provider::azapi::parse_resource_id("Microsoft.Resources/resourceGroups", var.parent_id)) + error_message = "`parent_id` must be a valid Azure resource group resource ID." + } + + description = < 0 && (startswith(var.parent_id, "/subscriptions/") || startswith(var.parent_id, "/providers/")) + error_message = "`parent_id` must be a fully-qualified ARM resource ID starting with `/subscriptions/` or `/providers/`." +} +``` + +## Data sources + +```hcl +# Get current client context (subscription, tenant) +data "azapi_client_config" "current" {} + +# Use in expressions: +data.azapi_client_config.current.subscription_id +data.azapi_client_config.current.subscription_resource_id +data.azapi_client_config.current.tenant_id +``` + +## Unit test mocking + +```hcl +mock_provider "azapi" {} +``` + +## Retry and timeouts + +`retry` and `timeouts` are standard AVM Terraform interfaces (`TFFR7`; see also [`interfaces.md`](./interfaces.md) and the [Terraform Interfaces spec](https://azure.github.io/Azure-Verified-Modules/specs/tf/interfaces/)). Both **MUST** be applied to every `azapi_resource` the module declares — root resource and submodules — and **MUST** be cascaded unchanged into every submodule the parent instantiates. + +### `retry` variable + +```hcl +variable "retry" { + type = object({ + error_message_regex = optional(list(string)) + interval_seconds = optional(number) + max_interval_seconds = optional(number) + }) + default = null + description = <. +DESCRIPTION +} +``` + +`retry` is an **attribute** on `azapi_resource`, so it is assigned directly: `retry = var.retry`. + +### `timeouts` variable + +```hcl +variable "timeouts" { + type = object({ + create = optional(string) + read = optional(string) + update = optional(string) + delete = optional(string) + }) + default = null + description = < **When to use this reference:** +> +> - You are running on **Windows** (on non-Windows systems, use the `avm` command instead). +> - You want to **distribute tests across multiple Azure subscriptions**. +> - You want to **retain deployed resources** after testing for manual validation (skip destroy). + +Each subfolder under `examples/` is a standalone Terraform root module. Test each one independently. + +## Testing Workflow + +For each example directory, run these steps in order. Stop and fix any errors before proceeding. + +1. Run Terraform init +2. Run Terraform plan +3. Run Terraform apply +4. Run Terraform plan again (idempotency check) + +The idempotency check (step 4) must show **"No changes"**. If it reports drift, that is a bug - fix it. Common causes: + +- **Server-side defaults**: A property not set in config gets a default from Azure. Set it explicitly. Use `ignore_changes` only as a last resort. +- **Computed attributes**: An output or reference that changes on every read. +- **Provider bugs**: Check for known issues in the provider repository. + +### Destroy + +**Ask the user before destroying.** They may want to inspect resources in the Azure portal or keep them for debugging. + +```powershell +terraform destroy +``` + +Some resources (e.g., soft-delete enabled Key Vaults) may require manual purging. + +## Distributing Examples Across Subscriptions + +To avoid quota limits or reduce blast radius, distribute examples across multiple subscriptions. + +**Always ask the user before changing the subscription.** + +Set `ARM_SUBSCRIPTION_ID` before running each example: + +```powershell +$env:ARM_SUBSCRIPTION_ID = "" +``` + +### Round-Robin Example + +```powershell +$subscriptions = @( + "00000000-0000-0000-0000-000000000001" + "00000000-0000-0000-0000-000000000002" + "00000000-0000-0000-0000-000000000003" +) + +$i = 0 +foreach ($dir in Get-ChildItem -Path examples -Directory) { + $env:ARM_SUBSCRIPTION_ID = $subscriptions[$i % $subscriptions.Count] + Write-Host "=== Testing $($dir.Name) on subscription $env:ARM_SUBSCRIPTION_ID ===" + Push-Location $dir.FullName + terraform init -upgrade + terraform plan -out=tfplan + terraform apply tfplan + terraform plan # idempotency check + Pop-Location + $i++ +} +``` diff --git a/.agents/skills/avm-terraform-module-development/references/interfaces.md b/.agents/skills/avm-terraform-module-development/references/interfaces.md new file mode 100644 index 0000000..12059cd --- /dev/null +++ b/.agents/skills/avm-terraform-module-development/references/interfaces.md @@ -0,0 +1,91 @@ +# AVM Standard Interfaces + +AVM defines a small, fixed set of **interfaces** that resource modules expose where the underlying Azure resource supports them. They standardise variable names, types, and behaviour across every module so consumers learn one shape and reuse it everywhere. + +All interfaces are implemented in the canonical utility module **`Azure/avm-utl-interfaces/azure`** (version `~> 0.6`). Resource modules should compose that utility module rather than redefining variable shapes by hand. For the authoritative interface text, fetch each interface page via `https://azure.github.io/Azure-Verified-Modules/llms.txt`. + +## Interfaces + +### Diagnostic settings + +Exposes `diagnostic_settings` (map of objects) so consumers can route resource logs and metrics to Log Analytics, Storage, Event Hub, or partner solutions. Apply on every resource that produces diagnostic logs or metrics. + +**Modules MUST use the v2 schema.** The v2 shape models `logs` and `metrics` as sets of objects with `category` / `category_group`, `enabled`, and optional `retention_policy`, instead of the legacy flat sets of category strings. Do not specify allowed values for category names — the module must stay evergreen as resource providers add new categories. + +Wire it through the utility module via its `diagnostic_settings_v2` input and consume the `diagnostic_settings_azapi_v2` output — the legacy `diagnostic_settings` input/output on the utility module is the old shape and **must not** be used in new code. + +### Role assignments + +Exposes `role_assignments` (map of objects) so consumers can attach Azure RBAC role assignments scoped to the resource. The key becomes a stable map key for plan-time stability; the object specifies `role_definition_id_or_name`, `principal_id`, `description`, etc. + +### Locks + +Exposes a `lock` (single object: `kind` = `None | CanNotDelete | ReadOnly`, `name` optional). Apply to top-level resources that support management locks. + +### Managed identities + +Exposes `managed_identities` (object with `system_assigned` bool and `user_assigned_resource_ids` set of strings). Apply on every resource that supports managed identity. + +### Private endpoints + +Exposes `private_endpoints` (map of objects) so consumers can deploy private endpoints into a target subnet, register them in private DNS zones, and configure custom IP addressing. Apply on every resource that supports Private Link. + +### Customer-managed keys + +Exposes `customer_managed_key` (single object: `key_vault_resource_id`, `key_name`, optional `key_version`, optional `user_assigned_identity` reference). Apply on every resource that supports CMK encryption. + +### Tags + +Exposes `tags` (map of strings). Required on every resource that supports tags. The pattern-module equivalent typically forwards a shared `tags` input to every composed module. + +### AzAPI resource types + +Exposes `resource_types` (object) so consumers can pin the API version used for each `azapi_resource` the module owns. The keys are **module-specific** — declare one `optional(string, "")` field per `azapi_resource` (or equivalent) the module declares, defaulting each to the latest tested API version. Defaults **MUST** be a stable (non-preview) API version unless the resource only ships preview. + +Parent modules **MUST** cascade the relevant subset of `resource_types` to each submodule (see TFFR6 and TFRMNFR1). Submodules **MUST** declare their own `resource_types` variable using the same pattern. + +### AzAPI retry + +Exposes `retry` (single object: `error_message_regex`, `interval_seconds`, `max_interval_seconds`, all optional). MUST be applied to every `azapi_resource` (and equivalent AzAPI resources) declared by the module and cascaded unchanged into every submodule. `retry` is an attribute on `azapi_resource`, so it is assigned directly. + +### AzAPI timeouts + +Exposes `timeouts` (single object: `create`, `read`, `update`, `delete`, all optional Go duration strings). MUST be applied to every `azapi_resource` (and equivalent AzAPI resources) declared by the module and cascaded unchanged into every submodule. `timeouts` is a **block** on `azapi_resource` (not an attribute), so a `dynamic "timeouts"` block is required to honour the variable's `null` default. + +For the full `retry` and `timeouts` variable schemas, the resource-side wiring (including the `dynamic` block), module-level defaults guidance, and submodule cascade pattern, read [AzAPI.md](./AzAPI.md#retry-and-timeouts). + +## Implementation pattern + +1. Take a dependency on `Azure/avm-utl-interfaces/azure` in `terraform.tf`. +2. Call the utility module in `main.tf` to translate the standard inputs into the AzAPI body shape. Note the diagnostic-settings input field is `diagnostic_settings_v2` (the legacy `diagnostic_settings` field on the utility module is the old shape): + + ```hcl + module "avm_interfaces" { + source = "Azure/avm-utl-interfaces/azure" + version = "~> 0.6" + + diagnostic_settings_v2 = var.diagnostic_settings + diagnostic_settings_scope = azapi_resource.this.id + managed_identities = var.managed_identities + # … + } + ``` + +3. Reference the utility module's outputs in the relevant `azapi_resource` blocks. For diagnostic settings iterate `module.avm_interfaces.diagnostic_settings_azapi_v2`: + + ```hcl + resource "azapi_resource" "diagnostic_settings" { + for_each = module.avm_interfaces.diagnostic_settings_azapi_v2 + + type = each.value.type + name = each.value.name + parent_id = each.value.parent_id + body = each.value.body + } + ``` + +4. Implement supporting child resources (private endpoints, diagnostic settings, role assignments, locks) as separate `azapi_resource` blocks driven by the same input maps — never collapse them into the parent `body` (TFRMNFR1). + +## When NOT to expose an interface + +If the underlying Azure resource does not support a capability (e.g. no private endpoints, no managed identity, no diagnostic settings), do **not** expose the corresponding variable. Adding a no-op variable creates a misleading contract. diff --git a/.agents/skills/avm-terraform-module-development/references/module-composition.md b/.agents/skills/avm-terraform-module-development/references/module-composition.md new file mode 100644 index 0000000..f97e99f --- /dev/null +++ b/.agents/skills/avm-terraform-module-development/references/module-composition.md @@ -0,0 +1,75 @@ +# AVM Terraform Module Composition + +Concise summary of how an AVM Terraform Resource or Pattern Module fits together. For the authoritative text, fetch the relevant spec via `https://azure.github.io/Azure-Verified-Modules/llms.txt`. + +## File layout + +The root of every AVM Terraform module looks like this (TFNFR39): + +``` +. +├── _header.md # Source of README intro; edit this, NOT README.md +├── _footer.md # Source of README footer +├── README.md # Auto-generated by terraform-docs; do not edit +├── main.tf # Primary resource(s) +├── main..tf # Feature-grouped sub-files (optional) +├── main.telemetry.tf # modtm telemetry block (managed by mapotf) +├── variables.tf # All inputs +├── outputs.tf # All outputs +├── locals.tf # Local values (if used) +├── terraform.tf # required_version + required_providers +├── examples/ # One folder per example, each runnable on its own +│ └── / +│ ├── main.tf +│ ├── variables.tf +│ ├── outputs.tf +│ └── README.md +├── modules/ # Internal sub-modules called only by this module +│ └── / +└── tests/ + ├── unit/ # mock_provider tests + └── integration/ # real-deploy tests +``` + +Rules of thumb: + +- One `azapi_resource` block per logical resource — never collapse parent + child into a single `body`. +- Group related resources into `main..tf` (e.g. `main.privateendpoint.tf`, `main.diagnostics.tf`) once `main.tf` exceeds ~200 lines. +- Sub-modules in `modules/` are private to the module and must follow the same file layout in miniature. +- Examples are independent root modules; each must `terraform init && terraform plan && terraform apply && terraform plan` cleanly (idempotency check). + +## Resource modules (`res`) + +A resource module deploys one **primary** Azure resource (RMFR1). Child resources that have no useful lifecycle outside the primary (e.g. blob services for a storage account, network security rules for an NSG) belong in the same module. Anything you would reasonably manage independently belongs in its own resource module and is consumed via `module "..."`. + +Key obligations: + +- Implement the resource with the AzAPI provider (TFFR3). +- Expose standard interfaces where applicable (diagnostic settings, locks, role assignments, private endpoints, managed identities, customer-managed keys, tags) — see [interfaces.md](interfaces.md). +- Outputs include the required AVM outputs (RMFR7). For Terraform-specific additional outputs, prefer discrete computed attributes over whole resource object outputs (TFFR2). +- Provide at least a `default` example demonstrating the simplest valid usage. + +## Pattern modules (`ptn`) + +A pattern module composes multiple AVM resource modules (and optionally other pattern modules) into an opinionated workload — for example, "AKS production baseline" or "regulated landing zone hub". It generally does **not** declare `azapi_resource` blocks directly; it wires resource modules together. + +Key obligations: + +- Re-use existing AVM resource modules instead of re-implementing resources. +- Expose only the inputs the workload needs; opinionated defaults are encouraged. +- Telemetry, naming, and tagging interfaces still apply to the pattern module itself. +- Examples demonstrate the workload end-to-end, not just the wiring. + +## Telemetry + +`main.telemetry.tf` declares a `modtm_telemetry` resource so AVM can measure module usage (SFR3 / SFR4). It is generated/maintained by the mapotf configuration in this governance repo — do not hand-edit it. The user can opt out with `enable_telemetry = false`. + +## Required providers & versions + +`terraform.tf` pins `required_version` and `required_providers` (TFNFR26 / TFNFR27 / TFNFR39). At minimum: + +- `azapi` within the TFFR3-permitted major range (`>= 2.0, < 3.0`) +- `modtm` if telemetry is implemented +- `random` when the module directly uses it + +Provider version bounds are enforced by the mapotf `required_provider_versions.mptf.hcl` config in this governance repo. diff --git a/.agents/skills/AVM-Terraform-Development/terraform-test.md b/.agents/skills/avm-terraform-module-development/references/terraform-test.md similarity index 96% rename from .agents/skills/AVM-Terraform-Development/terraform-test.md rename to .agents/skills/avm-terraform-module-development/references/terraform-test.md index a28c7e3..bbbd087 100644 --- a/.agents/skills/AVM-Terraform-Development/terraform-test.md +++ b/.agents/skills/avm-terraform-module-development/references/terraform-test.md @@ -34,7 +34,7 @@ Submodules under `./modules/` follow the same pattern — each can have its own ## Unit Test Template -Every AVM module uses the AzAPI provider. Unit tests MUST mock **all** providers declared in `terraform.tf` `required_providers`. AVM modules always include `azapi`, `modtm`, and `random`. +Unit tests MUST mock **all** providers declared in `terraform.tf` `required_providers` for the module under test. ```hcl # tests/unit/unit.tftest.hcl @@ -387,10 +387,10 @@ For debugging, the `terraform test -no-cleanup` flag prevents automatic destruct ## Best Practices -1. **Always mock all providers** in unit tests — check `terraform.tf` `required_providers` for the full list. AVM modules always have at least `azapi`, `modtm`, and `random`. +1. **Always mock all providers** in unit tests — check `terraform.tf` `required_providers` for the full list for the module under test. 2. **Use `command = apply`** for unit tests (not `plan`) — mocked providers make apply safe and allow testing resource creation. 3. **Write clear error messages** — assertion messages should describe the expected behavior, not restate the condition. -4. **Set `location`** in the `variables` block — it is a required variable in all AVM modules with no default. +4. **Set all required inputs** in the `variables` block — use the module's `variables.tf` to determine which variables are required and have no default. 5. **Test validation rules** — use `expect_failures` to verify that invalid inputs are rejected. 6. **Test conditional logic** — verify that optional features create resources when enabled and skip them when disabled. 7. **Keep tests focused** — each run block should test one scenario or behavior. diff --git a/.agents/skills/AVM-Terraform-Development/tfpluginschema.md b/.agents/skills/avm-terraform-module-development/references/tfpluginschema.md similarity index 83% rename from .agents/skills/AVM-Terraform-Development/tfpluginschema.md rename to .agents/skills/avm-terraform-module-development/references/tfpluginschema.md index c76d70c..cb84a2c 100644 --- a/.agents/skills/AVM-Terraform-Development/tfpluginschema.md +++ b/.agents/skills/avm-terraform-module-development/references/tfpluginschema.md @@ -19,6 +19,18 @@ curl -sSfL https://github.com/matt-FFFFFF/tfpluginschema/releases/latest/downloa curl -sSfL https://github.com/matt-FFFFFF/tfpluginschema/releases/latest/download/tfpluginschema_0.8.0_darwin_amd64.tar.gz | tar -xz -C /usr/local/bin tfpluginschema ``` +```powershell +# Windows (amd64) +$url = "https://github.com/matt-FFFFFF/tfpluginschema/releases/latest/download/tfpluginschema_0.8.0_windows_amd64.zip" +$dest = Join-Path $HOME ".tfpluginschema" +New-Item -ItemType Directory -Path $dest -Force | Out-Null +Invoke-WebRequest -Uri $url -OutFile (Join-Path $dest "tfpluginschema.zip") +Expand-Archive -Path (Join-Path $dest "tfpluginschema.zip") -DestinationPath $dest -Force +Remove-Item (Join-Path $dest "tfpluginschema.zip") +# Add to PATH if not already present +if ($env:PATH -notlike "*$dest*") { $env:PATH += ";$dest" } +``` + Check latest version at: ## Global Options @@ -34,13 +46,13 @@ Check latest version at: 4.0" resource list ``` diff --git a/.agents/skills/avm-terraform-module-development/scripts/Get-AzureSchema.ps1 b/.agents/skills/avm-terraform-module-development/scripts/Get-AzureSchema.ps1 new file mode 100644 index 0000000..1198c35 --- /dev/null +++ b/.agents/skills/avm-terraform-module-development/scripts/Get-AzureSchema.ps1 @@ -0,0 +1,291 @@ +#!/usr/bin/env pwsh +# Get-AzureSchema.ps1 - Query Azure resource type schemas from the command line. +# +# Data source: bicep-types-az (https://github.com/Azure/bicep-types-az) +# - index.json for resource type discovery and API version listing +# - types.json per resource type for schema definitions +# +# Output: JSON on stdout. Progress / errors on stderr. +# Dependencies: PowerShell 7+ + +[CmdletBinding()] +param( + [Parameter(Position = 0)] + [ValidateSet('get', 'versions', 'help')] + [string]$Command = 'help', + + [Parameter(Position = 1)] + [string]$ResourceType, + + [Parameter(Position = 2)] + [string]$ApiVersion, + + [int]$Depth = 5 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$script:PROG = 'Get-AzureSchema.ps1' +$script:CACHE_DIR = if ($env:XDG_CACHE_HOME) { Join-Path $env:XDG_CACHE_HOME 'azure-schema' } else { Join-Path $HOME '.cache' 'azure-schema' } +$script:BICEP_TYPES_BASE = 'https://raw.githubusercontent.com/Azure/bicep-types-az/main/generated' +$script:INDEX_URL = "$script:BICEP_TYPES_BASE/index.json" +$script:INDEX_CACHE = Join-Path $script:CACHE_DIR 'index.json' +$script:INDEX_MAX_AGE = [TimeSpan]::FromHours(24) + +# Bicep type flags (bitfield): 1=Required, 2=ReadOnly, 4=WriteOnly, 8=DeployTimeConstant +$script:FLAG_REQUIRED = 1 +$script:FLAG_READONLY = 2 +$script:FLAG_WRITEONLY = 4 + +function Show-Usage { + @" +Usage: + $script:PROG get [-Depth N] + $script:PROG versions + $script:PROG help + +Commands: + get Fetch the schema for a resource type at a given API version and emit + resolved JSON on stdout. -Depth N caps nested object resolution + (default 5). + + versions List '@' entries for all types under a + provider, one per line on stdout. + +Examples: + $script:PROG get Microsoft.ContainerService/managedClusters 2025-10-01 + $script:PROG get Microsoft.Storage/storageAccounts 2023-01-01 -Depth 3 + $script:PROG versions Microsoft.Storage +"@ +} + +function Ensure-CacheDir { + if (-not (Test-Path $script:CACHE_DIR)) { + New-Item -ItemType Directory -Path $script:CACHE_DIR -Force | Out-Null + } +} + +function Fetch-Index { + Ensure-CacheDir + $needsFetch = -not (Test-Path $script:INDEX_CACHE) + if (-not $needsFetch) { + $age = (Get-Date) - (Get-Item $script:INDEX_CACHE).LastWriteTime + $needsFetch = $age -gt $script:INDEX_MAX_AGE + } + if ($needsFetch) { + [Console]::Error.WriteLine('Fetching resource type index (cached for 24h)...') + Invoke-WebRequest -Uri $script:INDEX_URL -OutFile $script:INDEX_CACHE -UseBasicParsing | Out-Null + } +} + +$script:IndexData = $null +function Get-IndexData { + if ($null -eq $script:IndexData) { + $script:IndexData = Get-Content $script:INDEX_CACHE -Raw | ConvertFrom-Json + } + return $script:IndexData +} + +function Resolve-IndexRef { + param([string]$ResourceType, [string]$ApiVersion) + + $index = Get-IndexData + $lookupKey = "$ResourceType@$ApiVersion" + + $prop = $index.resources.PSObject.Properties | Where-Object { $_.Name -eq $lookupKey } | Select-Object -First 1 + if (-not $prop) { + $prop = $index.resources.PSObject.Properties | Where-Object { $_.Name -ieq $lookupKey } | Select-Object -First 1 + } + if (-not $prop) { return $null } + + $ref = $prop.Value.PSObject.Properties['$ref'].Value + if (-not $ref) { return $null } + + # ref format: "containerservice_0/microsoft.containerservice/2025-10-01/types.json#/376" + $parts = $ref -split '#' + return @{ + FilePath = $parts[0] + TypeIndex = [int]($parts[1].TrimStart('/')) + } +} + +function Fetch-TypesFile { + param([string]$FilePath) + + Ensure-CacheDir + $cacheKey = $FilePath -replace '[/\\]', '_' + $cacheFile = Join-Path $script:CACHE_DIR $cacheKey + + if (-not (Test-Path $cacheFile)) { + $url = "$script:BICEP_TYPES_BASE/$FilePath" + [Console]::Error.WriteLine("Fetching types from $FilePath...") + try { + Invoke-WebRequest -Uri $url -OutFile $cacheFile -UseBasicParsing | Out-Null + } + catch { + if (Test-Path $cacheFile) { Remove-Item $cacheFile -Force } + throw "Failed to fetch $url : $_" + } + } + return $cacheFile +} + +function Get-TypesData { + param([string]$CacheFile) + return Get-Content $CacheFile -Raw | ConvertFrom-Json +} + +# Strict-mode-safe property accessor. Returns the property value or $null +# if the property does not exist on the object. +function Get-Prop { + param($Object, [string]$Name) + if ($null -eq $Object) { return $null } + $p = $Object.PSObject.Properties[$Name] + if ($null -eq $p) { return $null } + return $p.Value +} + +function Resolve-TypeToJson { + param($TypeDef, $Types, [int]$CurrentDepth, [int]$MaxDepth) + + if ($CurrentDepth -gt $MaxDepth) { + return [ordered]@{ type = ((Get-Prop $TypeDef '$type') ?? 'unknown'); _truncated = 'depth limit exceeded' } + } + + switch ((Get-Prop $TypeDef '$type')) { + 'StringType' { + $result = [ordered]@{ type = 'string' } + $minLength = Get-Prop $TypeDef 'minLength' + $maxLength = Get-Prop $TypeDef 'maxLength' + $pattern = Get-Prop $TypeDef 'pattern' + if ($null -ne $minLength) { $result.minLength = $minLength } + if ($null -ne $maxLength) { $result.maxLength = $maxLength } + if ($null -ne $pattern) { $result.pattern = $pattern } + return $result + } + 'StringLiteralType' { return [ordered]@{ type = 'string'; const = (Get-Prop $TypeDef 'value') } } + 'IntegerType' { + $result = [ordered]@{ type = 'integer' } + $minValue = Get-Prop $TypeDef 'minValue' + $maxValue = Get-Prop $TypeDef 'maxValue' + if ($null -ne $minValue) { $result.minimum = $minValue } + if ($null -ne $maxValue) { $result.maximum = $maxValue } + return $result + } + 'BooleanType' { return [ordered]@{ type = 'boolean' } } + 'AnyType' { return [ordered]@{ type = 'any' } } + 'ArrayType' { + $result = [ordered]@{ type = 'array' } + $itemType = Get-Prop $TypeDef 'itemType' + $itemRef = Get-Prop $itemType '$ref' + if ($itemRef) { + $refIdx = [int]($itemRef -split '/' | Select-Object -Last 1) + $result.items = Resolve-TypeToJson -TypeDef $Types[$refIdx] -Types $Types -CurrentDepth ($CurrentDepth + 1) -MaxDepth $MaxDepth + } + return $result + } + 'UnionType' { + $oneOf = @() + $elements = (Get-Prop $TypeDef 'elements') ?? @() + foreach ($elem in $elements) { + $elemRef = Get-Prop $elem '$ref' + if ($elemRef) { + $refIdx = [int]($elemRef -split '/' | Select-Object -Last 1) + $oneOf += , (Resolve-TypeToJson -TypeDef $Types[$refIdx] -Types $Types -CurrentDepth ($CurrentDepth + 1) -MaxDepth $MaxDepth) + } + else { + $oneOf += , $elem + } + } + return [ordered]@{ type = 'union'; oneOf = $oneOf } + } + 'ObjectType' { + $result = [ordered]@{ type = 'object'; name = (Get-Prop $TypeDef 'name') } + $properties = Get-Prop $TypeDef 'properties' + if ($properties) { + $props = [ordered]@{} + foreach ($propEntry in $properties.PSObject.Properties) { + $propVal = $propEntry.Value + $propResult = [ordered]@{} + + $propType = Get-Prop $propVal 'type' + $propRef = Get-Prop $propType '$ref' + if ($propRef) { + $refIdx = [int]($propRef -split '/' | Select-Object -Last 1) + $resolved = Resolve-TypeToJson -TypeDef $Types[$refIdx] -Types $Types -CurrentDepth ($CurrentDepth + 1) -MaxDepth $MaxDepth + foreach ($k in $resolved.Keys) { $propResult[$k] = $resolved[$k] } + } + + $description = Get-Prop $propVal 'description' + if ($description) { $propResult.description = $description } + + $flags = [int]((Get-Prop $propVal 'flags') ?? 0) + if (($flags -band $script:FLAG_REQUIRED) -ne 0) { $propResult.required = $true } + if (($flags -band $script:FLAG_READONLY) -ne 0) { $propResult.readOnly = $true } + if (($flags -band $script:FLAG_WRITEONLY) -ne 0) { $propResult.writeOnly = $true } + + $props[$propEntry.Name] = $propResult + } + $result.properties = $props + } + return $result + } + default { return [ordered]@{ type = ((Get-Prop $TypeDef '$type') ?? 'unknown') } } + } +} + +function Invoke-Versions { + param([string]$Provider) + + if (-not $Provider) { + throw "Usage: $script:PROG versions Example: $script:PROG versions Microsoft.Storage" + } + + Fetch-Index + $index = Get-IndexData + $pattern = $Provider.ToLower() + + $entries = foreach ($prop in $index.resources.PSObject.Properties) { + if ($prop.Name.ToLower().StartsWith($pattern)) { $prop.Name } + } + + $entries | Sort-Object | ForEach-Object { Write-Output $_ } +} + +function Invoke-Get { + param([string]$ResourceType, [string]$ApiVersion, [int]$MaxDepth) + + if (-not $ResourceType -or -not $ApiVersion) { + throw "Usage: $script:PROG get [-Depth N]" + } + + Fetch-Index + + $refInfo = Resolve-IndexRef -ResourceType $ResourceType -ApiVersion $ApiVersion + if (-not $refInfo) { + $provider = ($ResourceType -split '/')[0] + throw "Resource type '$ResourceType@$ApiVersion' not found in index.`nUse '$script:PROG versions $provider' to list available versions." + } + + $typesFile = Fetch-TypesFile -FilePath $refInfo.FilePath + $types = Get-TypesData -CacheFile $typesFile + + $rt = $types[$refInfo.TypeIndex] + $bodyRef = Get-Prop (Get-Prop $rt 'body') '$ref' + if (-not $bodyRef) { + throw "Resource type '$ResourceType@$ApiVersion' has no resolvable body reference in the types file." + } + $bodyRefIdx = [int]($bodyRef -split '/' | Select-Object -Last 1) + $body = $types[$bodyRefIdx] + + $resolved = Resolve-TypeToJson -TypeDef $body -Types $types -CurrentDepth 0 -MaxDepth $MaxDepth + $resolved | ConvertTo-Json -Depth 100 +} + +switch ($Command) { + 'get' { Invoke-Get -ResourceType $ResourceType -ApiVersion $ApiVersion -MaxDepth $Depth } + 'versions' { Invoke-Versions -Provider $ResourceType } + 'help' { Show-Usage } + default { Show-Usage } +} diff --git a/AGENTS.md b/AGENTS.md index 7281ca8..19ab9b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,16 @@ applyTo: "**/*.terraform, **/*.tf, **/*.tfvars, **/*.tfstate, **/*.tflint.hcl, * # Azure Verified Modules (AVM) Terraform This repository uses Azure Verified Modules (AVM) for Terraform. -For detailed guidance on module development, refer to the [AVM-Terraform-Development skill](.agents/skills/AVM-Terraform-Development/SKILL.md). +For detailed guidance on module development, refer to the [avm-terraform-module-development skill](.agents/skills/avm-terraform-module-development/SKILL.md). + +## AVM Specifications + +The authoritative source for every AVM rule (Bicep, Terraform, shared) is the spec index: + +- **Index of all specs and docs (raw markdown URLs):** `https://azure.github.io/Azure-Verified-Modules/llms.txt` +- **Rendered docs site:** `https://azure.github.io/Azure-Verified-Modules/` + +When a spec ID is mentioned (e.g. `TFFR3`, `RMFR4`, `SNFR1`), fetch `llms.txt` once, look up the raw markdown URL for that ID, and read the current text. Do not cite a spec from memory. ## Module Discovery diff --git a/README.md b/README.md index 00f2066..492eee4 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ The following requirements are needed by this module: - [modtm](#requirement\_modtm) (~> 0.3) -- [random](#requirement\_random) (~> 3.7) +- [random](#requirement\_random) (>= 3.6.2, < 4.0.0) - [tls](#requirement\_tls) (~> 4.0) @@ -81,171 +81,6 @@ Description: The name to use when creating the virtual machine. Type: `string` -### [network\_interfaces](#input\_network\_interfaces) - -Description: A map of objects representing each network virtual machine network interface - -- `` - Use a custom map key to define each network interface - - `name` = (Required) The name of the Network Interface. Changing this forces a new resource to be created. - - `ip_configurations` - A required map of objects defining each interfaces IP configurations - - `` - Use a custom map key to define each ip configuration - - `name` = (Required) - A name used for this IP Configuration. - - `app_gateway_backend_pools` = (Optional) - A map defining app gateway backend pool(s) this IP configuration should be associated to. - - `` - Use a custom map key to define each app gateway backend pool association. This is done to handle issues with certain details not being known until after apply. - - `app_gateway_backend_pool_resource_id` = (Required) - An application gateway backend pool Azure Resource ID can be entered to join this ip configuration to the backend pool of an Application Gateway. - - `create_public_ip_address` = (Optional) - Select true here to have the module create the public IP address for this IP Configuration - - `gateway_load_balancer_frontend_ip_configuration_resource_id` = (Optional) - The Frontend IP Configuration Azure Resource ID of a Gateway SKU Load Balancer.) - - `is_primary_ipconfiguration` = (Optional) - Is this the Primary IP Configuration? Must be true for the first ip\_configuration when multiple are specified. - - `load_balancer_backend_pools` = (Optional) - A map defining load balancer backend pool(s) this IP configuration should be associated to. - - `` - Use a custom map key to define each load balancer backend pool association. This is done to handle issues with certain details not being known until after apply. - - `load_balancer_backend_pool_resource_id` = (Required) - A Load Balancer backend pool Azure Resource ID can be entered to join this ip configuration to a load balancer backend pool. - - `load_balancer_nat_rules` = (Optional) - A map defining load balancer NAT rule(s) that this IP Configuration should be associated to. - - `` - Use a custom map key to define each load balancer NAT Rule association. This is done to handle issues with certain details not being known until after apply. - - `load_balancer_nat_rule_resource_id` = (Optional) - A Load Balancer Nat Rule Azure Resource ID can be entered to associate this ip configuration to a load balancer NAT rule. - - `private_ip_address` = (Optional) - The Static IP Address which should be used. Configured when private\_ip\_address\_allocation is set to Static - - `private_ip_address_allocation` = (Optional) - The allocation method used for the Private IP Address. Possible values are Dynamic and Static. Dynamic means "An IP is automatically assigned during creation of this Network Interface" and is the default; Static means "User supplied IP address will be used" - - `private_ip_address_version` = (Optional) - The IP Version to use. Possible values are IPv4 or IPv6. Defaults to IPv4. - - `private_ip_subnet_resource_id` = (Optional) - The Azure Resource ID of the Subnet where this Network Interface should be located in. - - `public_ip_address_resource_id` = (Optional) - Reference to a Public IP Address resource ID to associate with this NIC - - `accelerated_networking_enabled` = (Optional) - Should Accelerated Networking be enabled? Defaults to false. Only certain Virtual Machine sizes are supported for Accelerated Networking. To use Accelerated Networking in an Availability Set, the Availability Set must be deployed onto an Accelerated Networking enabled cluster. - - `application_security_groups` = (Optional) - A map defining the Application Security Group(s) that this network interface should be a part of. - - `` - Use a custom map key to define each Application Security Group association. This is done to handle issues with certain details not being known until after apply. - - `application_security_group_resource_id` = (Required) - The Application Security Group (ASG) Azure Resource ID for this Network Interface to be associated to. - - `diagnostic_settings` = (Optional) - A map of objects defining the network interface resource diagnostic settings - - `` - Use a custom map key to define each diagnostic setting configuration - - `name` = (required) - Name to use for the Diagnostic setting configuration. Changing this creates a new resource - - `event_hub_authorization_rule_resource_id` = (Optional) - The Event Hub Namespace Authorization Rule Resource ID when sending logs or metrics to an Event Hub Namespace - - `event_hub_name` = (Optional) - The Event Hub name when sending logs or metrics to an Event Hub - - `log_analytics_destination_type` = (Optional) - Valid values are null, AzureDiagnostics, and Dedicated. Defaults to null - - `log_categories_and_groups` = (Optional) - List of strings used to define log categories and groups. Currently not valid for the VM resource - - `marketplace_partner_resource_id` = (Optional) - The marketplace partner solution Azure Resource ID when sending logs or metrics to a partner integration - - `metric_categories` = (Optional) - List of strings used to define metric categories. Currently only AllMetrics is valid - - `storage_account_resource_id` = (Optional) - The Storage Account Azure Resource ID when sending logs or metrics to a Storage Account - - `workspace_resource_id` = (Optional) - The Log Analytics Workspace Azure Resource ID when sending logs or metrics to a Log Analytics Workspace - - `dns_servers` = (Optional) - A list of IP Addresses defining the DNS Servers which should be used for this Network Interface. - - `inherit_tags` = (Optional) - Defaults to true. Set this to false if only the tags defined on this resource should be applied. This is potential future functionality and is currently ignored. - - `internal_dns_name_label` = (Optional) - The (relative) DNS Name used for internal communications between Virtual Machines in the same Virtual Network. - - `ip_forwarding_enabled` = (Optional) - Should IP Forwarding be enabled? Defaults to false - - `lock_level` = (Optional) - Set this value to override the resource level lock value. Possible values are `None`, `CanNotDelete`, and `ReadOnly`. - - `lock_name` = (Optional) - The name for the lock on this nic - - `network_security_groups` = (Optional) - A map describing Network Security Group(s) that this Network Interface should be associated to. - - `` - Use a custom map key to define each network security group association. This is done to handle issues with certain details not being known until after apply. - - `network_security_group_resource_id` = (Optional) - The Network Security Group (NSG) Azure Resource ID used to associate this Network Interface to the NSG. - - `resource_group_name` (Optional) - Specify a resource group name if the network interface should be created in a separate resource group from the virtual machine - - `role_assignments` = An optional map of objects defining role assignments on the individual network configuration resource - - `` - Use a custom map key to define each role assignment configuration - - `assign_to_child_public_ip_addresses` = (Optional) - Set this to true if the assignment should also apply to any children public IP addresses. - - `condition` = (Optional) - The condition that limits the resources that the role can be assigned to. Changing this forces a new resource to be created. - - `condition_version` = (Optional) - The version of the condition. Possible values are 1.0 or 2.0. Changing this forces a new resource to be created. - - `delegated_managed_identity_resource_id` = (Optional) - The delegated Azure Resource Id which contains a Managed Identity. Changing this forces a new resource to be created. - - `description` = (Optional) - The description for this Role Assignment. Changing this forces a new resource to be created. - - `principal_id` = (optional) - The ID of the Principal (User, Group or Service Principal) to assign the Role Definition to. Changing this forces a new resource to be created. - - `role_definition_id_or_name` = (Optional) - The Scoped-ID of the Role Definition or the built-in role name. Changing this forces a new resource to be created. Conflicts with role\_definition\_name - - `skip_service_principal_aad_check` = (Optional) - If the principal\_id is a newly provisioned Service Principal set this value to true to skip the Azure Active Directory check which may fail due to replication lag. This argument is only valid if the principal\_id is a Service Principal identity. Defaults to true. - - `principal_type` = (Optional) - The type of the `principal_id`. Possible values are `User`, `Group` and `ServicePrincipal`. It is necessary to explicitly set this attribute when creating role assignments if the principal creating the assignment is constrained by ABAC rules that filters on the PrincipalType attribute. - - `tags` = (Optional) - A mapping of tags to assign to the resource. - -Example Inputs: - -```hcl -#Simple private IP single NIC with IPV4 private address -network_interfaces = { - network_interface_1 = { - name = "testnic1" - ip_configurations = { - ip_configuration_1 = { - name = "testnic1-ipconfig1" - private_ip_subnet_resource_id = azurerm_subnet.this_subnet_1.id - } - } - } -} - -#Simple NIC with private and public IP address -network_interfaces = { - network_interface_1 = { - name = "testnic1" - ip_configurations = { - ip_configuration_1 = { - name = "testnic1-ipconfig1" - private_ip_subnet_resource_id = azurerm_subnet.this_subnet_1.id - create_public_ip_address = true - public_ip_address_name = "vm1-testnic1-publicip1" - } - } - } -} -``` - -Type: - -```hcl -map(object({ - name = string - ip_configurations = map(object({ - name = string - app_gateway_backend_pools = optional(map(object({ - app_gateway_backend_pool_resource_id = string - })), {}) - create_public_ip_address = optional(bool, false) - gateway_load_balancer_frontend_ip_configuration_resource_id = optional(string) - is_primary_ipconfiguration = optional(bool, true) - load_balancer_backend_pools = optional(map(object({ - load_balancer_backend_pool_resource_id = string - })), {}) - load_balancer_nat_rules = optional(map(object({ - load_balancer_nat_rule_resource_id = string - })), {}) - private_ip_address = optional(string) - private_ip_address_allocation = optional(string, "Dynamic") - private_ip_address_version = optional(string, "IPv4") - private_ip_subnet_resource_id = optional(string) - public_ip_address_lock_name = optional(string) - public_ip_address_name = optional(string) - public_ip_address_resource_id = optional(string) - })) - accelerated_networking_enabled = optional(bool, false) - application_security_groups = optional(map(object({ - application_security_group_resource_id = string - })), {}) - diagnostic_settings = optional(map(object({ - name = optional(string, null) - log_categories = optional(set(string), []) - log_groups = optional(set(string), []) - metric_categories = optional(set(string), ["AllMetrics"]) - log_analytics_destination_type = optional(string, null) - workspace_resource_id = optional(string, null) - storage_account_resource_id = optional(string, null) - event_hub_authorization_rule_resource_id = optional(string, null) - event_hub_name = optional(string, null) - marketplace_partner_resource_id = optional(string, null) - })), {}) - dns_servers = optional(list(string)) - inherit_tags = optional(bool, true) - internal_dns_name_label = optional(string) - ip_forwarding_enabled = optional(bool, false) - is_primary = optional(bool, false) - lock_level = optional(string) - lock_name = optional(string) - network_security_groups = optional(map(object({ - network_security_group_resource_id = string - })), {}) - resource_group_name = optional(string) - role_assignments = optional(map(object({ - principal_id = string - role_definition_id_or_name = string - assign_to_child_public_ip_addresses = optional(bool, true) - condition = optional(string, null) - condition_version = optional(string, null) - delegated_managed_identity_resource_id = optional(string, null) - description = optional(string, null) - skip_service_principal_aad_check = optional(bool, false) - principal_type = optional(string, null) - })), {}) - tags = optional(map(string), null) - })) -``` - ### [resource\_group\_name](#input\_resource\_group\_name) Description: The resource group name of the resource group where the vm resources will be deployed. @@ -1077,6 +912,198 @@ Type: `number` Default: `-1` +### [network\_interfaces](#input\_network\_interfaces) + +Description: A map of objects representing each network virtual machine network interface + +- `` - Use a custom map key to define each network interface + - `name` = (Required) The name of the Network Interface. Changing this forces a new resource to be created. + - `ip_configurations` - A required map of objects defining each interfaces IP configurations + - `` - Use a custom map key to define each ip configuration + - `name` = (Required) - A name used for this IP Configuration. + - `app_gateway_backend_pools` = (Optional) - A map defining app gateway backend pool(s) this IP configuration should be associated to. + - `` - Use a custom map key to define each app gateway backend pool association. This is done to handle issues with certain details not being known until after apply. + - `app_gateway_backend_pool_resource_id` = (Required) - An application gateway backend pool Azure Resource ID can be entered to join this ip configuration to the backend pool of an Application Gateway. + - `create_public_ip_address` = (Optional) - Select true here to have the module create the public IP address for this IP Configuration + - `gateway_load_balancer_frontend_ip_configuration_resource_id` = (Optional) - The Frontend IP Configuration Azure Resource ID of a Gateway SKU Load Balancer.) + - `is_primary_ipconfiguration` = (Optional) - Is this the Primary IP Configuration? Must be true for the first ip\_configuration when multiple are specified. + - `load_balancer_backend_pools` = (Optional) - A map defining load balancer backend pool(s) this IP configuration should be associated to. + - `` - Use a custom map key to define each load balancer backend pool association. This is done to handle issues with certain details not being known until after apply. + - `load_balancer_backend_pool_resource_id` = (Required) - A Load Balancer backend pool Azure Resource ID can be entered to join this ip configuration to a load balancer backend pool. + - `load_balancer_nat_rules` = (Optional) - A map defining load balancer NAT rule(s) that this IP Configuration should be associated to. + - `` - Use a custom map key to define each load balancer NAT Rule association. This is done to handle issues with certain details not being known until after apply. + - `load_balancer_nat_rule_resource_id` = (Optional) - A Load Balancer Nat Rule Azure Resource ID can be entered to associate this ip configuration to a load balancer NAT rule. + - `private_ip_address` = (Optional) - The Static IP Address which should be used. Configured when private\_ip\_address\_allocation is set to Static + - `private_ip_address_allocation` = (Optional) - The allocation method used for the Private IP Address. Possible values are Dynamic and Static. Dynamic means "An IP is automatically assigned during creation of this Network Interface" and is the default; Static means "User supplied IP address will be used" + - `private_ip_address_version` = (Optional) - The IP Version to use. Possible values are IPv4 or IPv6. Defaults to IPv4. + - `private_ip_subnet_resource_id` = (Optional) - The Azure Resource ID of the Subnet where this Network Interface should be located in. + - `public_ip_address_resource_id` = (Optional) - Reference to a Public IP Address resource ID to associate with this NIC + - `accelerated_networking_enabled` = (Optional) - Should Accelerated Networking be enabled? Defaults to false. Only certain Virtual Machine sizes are supported for Accelerated Networking. To use Accelerated Networking in an Availability Set, the Availability Set must be deployed onto an Accelerated Networking enabled cluster. + - `application_security_groups` = (Optional) - A map defining the Application Security Group(s) that this network interface should be a part of. + - `` - Use a custom map key to define each Application Security Group association. This is done to handle issues with certain details not being known until after apply. + - `application_security_group_resource_id` = (Required) - The Application Security Group (ASG) Azure Resource ID for this Network Interface to be associated to. + - `diagnostic_settings` = (Optional) - A map of objects defining the network interface resource diagnostic settings + - `` - Use a custom map key to define each diagnostic setting configuration + - `name` = (required) - Name to use for the Diagnostic setting configuration. Changing this creates a new resource + - `event_hub_authorization_rule_resource_id` = (Optional) - The Event Hub Namespace Authorization Rule Resource ID when sending logs or metrics to an Event Hub Namespace + - `event_hub_name` = (Optional) - The Event Hub name when sending logs or metrics to an Event Hub + - `log_analytics_destination_type` = (Optional) - Valid values are null, AzureDiagnostics, and Dedicated. Defaults to null + - `log_categories_and_groups` = (Optional) - List of strings used to define log categories and groups. Currently not valid for the VM resource + - `marketplace_partner_resource_id` = (Optional) - The marketplace partner solution Azure Resource ID when sending logs or metrics to a partner integration + - `metric_categories` = (Optional) - List of strings used to define metric categories. Currently only AllMetrics is valid + - `storage_account_resource_id` = (Optional) - The Storage Account Azure Resource ID when sending logs or metrics to a Storage Account + - `workspace_resource_id` = (Optional) - The Log Analytics Workspace Azure Resource ID when sending logs or metrics to a Log Analytics Workspace + - `dns_servers` = (Optional) - A list of IP Addresses defining the DNS Servers which should be used for this Network Interface. + - `inherit_tags` = (Optional) - Defaults to true. Set this to false if only the tags defined on this resource should be applied. This is potential future functionality and is currently ignored. + - `internal_dns_name_label` = (Optional) - The (relative) DNS Name used for internal communications between Virtual Machines in the same Virtual Network. + - `ip_forwarding_enabled` = (Optional) - Should IP Forwarding be enabled? Defaults to false + - `lock_level` = (Optional) - Set this value to override the resource level lock value. Possible values are `None`, `CanNotDelete`, and `ReadOnly`. + - `lock_name` = (Optional) - The name for the lock on this nic + - `network_security_groups` = (Optional) - A map describing Network Security Group(s) that this Network Interface should be associated to. + - `` - Use a custom map key to define each network security group association. This is done to handle issues with certain details not being known until after apply. + - `network_security_group_resource_id` = (Optional) - The Network Security Group (NSG) Azure Resource ID used to associate this Network Interface to the NSG. + - `resource_group_name` (Optional) - Specify a resource group name if the network interface should be created in a separate resource group from the virtual machine + - `role_assignments` = An optional map of objects defining role assignments on the individual network configuration resource + - `` - Use a custom map key to define each role assignment configuration + - `assign_to_child_public_ip_addresses` = (Optional) - Set this to true if the assignment should also apply to any children public IP addresses. + - `condition` = (Optional) - The condition that limits the resources that the role can be assigned to. Changing this forces a new resource to be created. + - `condition_version` = (Optional) - The version of the condition. Possible values are 1.0 or 2.0. Changing this forces a new resource to be created. + - `delegated_managed_identity_resource_id` = (Optional) - The delegated Azure Resource Id which contains a Managed Identity. Changing this forces a new resource to be created. + - `description` = (Optional) - The description for this Role Assignment. Changing this forces a new resource to be created. + - `principal_id` = (optional) - The ID of the Principal (User, Group or Service Principal) to assign the Role Definition to. Changing this forces a new resource to be created. + - `role_definition_id_or_name` = (Optional) - The Scoped-ID of the Role Definition or the built-in role name. Changing this forces a new resource to be created. Conflicts with role\_definition\_name + - `skip_service_principal_aad_check` = (Optional) - If the principal\_id is a newly provisioned Service Principal set this value to true to skip the Azure Active Directory check which may fail due to replication lag. This argument is only valid if the principal\_id is a Service Principal identity. Defaults to true. + - `principal_type` = (Optional) - The type of the `principal_id`. Possible values are `User`, `Group` and `ServicePrincipal`. It is necessary to explicitly set this attribute when creating role assignments if the principal creating the assignment is constrained by ABAC rules that filters on the PrincipalType attribute. + - `tags` = (Optional) - A mapping of tags to assign to the resource. + +Example Inputs: + +```hcl +#Simple private IP single NIC with IPV4 private address +network_interfaces = { + network_interface_1 = { + name = "testnic1" + ip_configurations = { + ip_configuration_1 = { + name = "testnic1-ipconfig1" + private_ip_subnet_resource_id = azurerm_subnet.this_subnet_1.id + } + } + } +} + +#Simple NIC with private and public IP address +network_interfaces = { + network_interface_1 = { + name = "testnic1" + ip_configurations = { + ip_configuration_1 = { + name = "testnic1-ipconfig1" + private_ip_subnet_resource_id = azurerm_subnet.this_subnet_1.id + create_public_ip_address = true + public_ip_address_name = "vm1-testnic1-publicip1" + } + } + } +} +``` + +Type: + +```hcl +map(object({ + name = string + ip_configurations = map(object({ + name = string + app_gateway_backend_pools = optional(map(object({ + app_gateway_backend_pool_resource_id = string + })), {}) + create_public_ip_address = optional(bool, false) + gateway_load_balancer_frontend_ip_configuration_resource_id = optional(string) + is_primary_ipconfiguration = optional(bool, true) + load_balancer_backend_pools = optional(map(object({ + load_balancer_backend_pool_resource_id = string + })), {}) + load_balancer_nat_rules = optional(map(object({ + load_balancer_nat_rule_resource_id = string + })), {}) + private_ip_address = optional(string) + private_ip_address_allocation = optional(string, "Dynamic") + private_ip_address_version = optional(string, "IPv4") + private_ip_subnet_resource_id = optional(string) + public_ip_address_lock_name = optional(string) + public_ip_address_name = optional(string) + public_ip_address_resource_id = optional(string) + })) + accelerated_networking_enabled = optional(bool, false) + application_security_groups = optional(map(object({ + application_security_group_resource_id = string + })), {}) + diagnostic_settings = optional(map(object({ + name = optional(string, null) + log_categories = optional(set(string), []) + log_groups = optional(set(string), []) + metric_categories = optional(set(string), ["AllMetrics"]) + log_analytics_destination_type = optional(string, null) + workspace_resource_id = optional(string, null) + storage_account_resource_id = optional(string, null) + event_hub_authorization_rule_resource_id = optional(string, null) + event_hub_name = optional(string, null) + marketplace_partner_resource_id = optional(string, null) + })), {}) + dns_servers = optional(list(string)) + inherit_tags = optional(bool, true) + internal_dns_name_label = optional(string) + ip_forwarding_enabled = optional(bool, false) + is_primary = optional(bool, false) + lock_level = optional(string) + lock_name = optional(string) + network_security_groups = optional(map(object({ + network_security_group_resource_id = string + })), {}) + resource_group_name = optional(string) + role_assignments = optional(map(object({ + principal_id = string + role_definition_id_or_name = string + assign_to_child_public_ip_addresses = optional(bool, true) + condition = optional(string, null) + condition_version = optional(string, null) + delegated_managed_identity_resource_id = optional(string, null) + description = optional(string, null) + skip_service_principal_aad_check = optional(bool, false) + principal_type = optional(string, null) + })), {}) + tags = optional(map(string), null) + })) +``` + +Default: + +```json +{ + "ipconfig_1": { + "accelerated_networking_enabled": true, + "dns_servers": null, + "internal_dns_name_label": null, + "ip_configurations": { + "ip_config_1": { + "gateway_load_balancer_frontend_ip_configuration_resource_id": null, + "is_primary_ipconfiguration": true, + "name": "ipv4-ipconfig", + "private_ip_address": null, + "private_ip_address_allocation": "Dynamic", + "private_ip_address_version": "IPv4", + "private_ip_subnet_resource_id": null, + "public_ip_address_resource_id": null + } + }, + "ip_forwarding_enabled": false, + "name": "default-ipv4-ipconfig", + "tags": null + } +} +``` + ### [os\_disk](#input\_os\_disk) Description: Required configuration values for the OS disk on the virtual machine. @@ -1140,6 +1167,48 @@ Default: } ``` +### [os\_disk\_attach\_mode](#input\_os\_disk\_attach\_mode) + +Description: (Optional) Set to `true` when using `os_managed_disk_id` to attach an existing managed disk as the OS disk (Attach mode). + +This variable must be set explicitly because Terraform cannot determine `os_managed_disk_id != null` at plan time when the +disk ID comes from a computed resource attribute (e.g., `azurerm_managed_disk.example.id`). Setting this to `true` ensures +that credential generation, Key Vault secret creation, and other OS profile settings are correctly skipped during planning. + +> Note: Always set `os_disk_attach_mode = true` when setting `os_managed_disk_id`. + +Example Inputs: + +```hcl +os_disk_attach_mode = true +os_managed_disk_id = azurerm_managed_disk.restored_os_disk.id +``` + +Type: `bool` + +Default: `false` + +### [os\_managed\_disk\_id](#input\_os\_managed\_disk\_id) + +Description: (Optional) The ID of an existing Managed Disk which should be attached as the OS Disk of this Virtual Machine. Changing this forces a new resource to be created. + +When set, `source_image_resource_id` and `source_image_reference` must not be used, and the module will not manage OS profile settings +(admin credentials, computer name, custom data, patching configuration, etc.) since the OS is pre-configured on the existing disk. + +> Note: This is mutually exclusive with `source_image_resource_id` and `source_image_reference`. Only one source for the OS disk can be specified. +> Note: Always set `os_disk_attach_mode = true` when using this variable. + +Example Inputs: + +```hcl +os_disk_attach_mode = true +os_managed_disk_id = "/subscriptions/{subscription_id}/resourceGroups/{rg_name}/providers/Microsoft.Compute/disks/{disk_name}" +``` + +Type: `string` + +Default: `null` + ### [os\_type](#input\_os\_type) Description: The base OS type of the vm to be built. Valid answers are Windows or Linux @@ -1610,7 +1679,7 @@ Default: `"Standard_D2ds_v5"` ### [source\_image\_reference](#input\_source\_image\_reference) -Description: The source image to use when building the virtual machine. Either `source_image_resource_id` or `source_image_reference` must be set and both can not be null at the same time. +Description: The source image to use when building the virtual machine. Either `source_image_resource_id` or `source_image_reference` must be set and both can not be null at the same time. Not used when `os_managed_disk_id` is set. - `publisher` = (Required) Specifies the publisher of the image this virtual machine should be created from. Changing this forces a new virtual machine to be created. - `offer` = (Required) Specifies the offer of the image used to create this virtual machine. Changing this forces a new virtual machine to be created. @@ -1661,7 +1730,7 @@ Default: ### [source\_image\_resource\_id](#input\_source\_image\_resource\_id) -Description: The Azure resource ID of the source image used to create the VM. Either `source_image_resource_id` or `source_image_reference` must be set and both can not be null at the same time. +Description: The Azure resource ID of the source image used to create the VM. Either `source_image_resource_id` or `source_image_reference` must be set and both can not be null at the same time. Not used when `os_managed_disk_id` is set. Type: `string` diff --git a/avm b/avm index 567d8df..c45fe28 100755 --- a/avm +++ b/avm @@ -7,7 +7,7 @@ usage () { } # We need to do this because bash doesn't like it when a script is updated in place. -if [ -z ${AVM_SCRIPT_FORKED} ]; then +if [ -z "${AVM_SCRIPT_FORKED}" ]; then # If AVM_SCRIPT_FORKED is not set, we are running the script from the original repository # Set AVM_SCRIPT_FORKED to true to avoid running this block again export AVM_SCRIPT_FORKED=true @@ -62,12 +62,76 @@ if [ -S /var/run/docker.sock ]; then DOCKER_SOCK_MOUNT="-v /var/run/docker.sock:/var/run/docker.sock" fi -# If we are in GitHub Copilot Coding Agent, we need to mount the SSL certificates from the host +# SSL certificate handling: +# The script builds a combined CA bundle and mounts it into the container when any of the +# following are detected: +# +# 1. Customer-provided certificates (AVM_SSL_CERT_FILE): +# Set AVM_SSL_CERT_FILE to the path of a PEM-encoded CA certificate or bundle on the host. +# This is intended for private TLS inspection environments where outbound traffic is +# intercepted by a corporate proxy using a custom CA. The file will be appended to the +# system CA bundle and mounted into the container. +# +# 2. GitHub Copilot Coding Agent (automatic detection): +# The coding agent installs a TLS-inspecting firewall that uses mkcert-generated certificates. +# These are trusted on the host via NODE_EXTRA_CA_CERTS / CAROOT, but not inside the Docker +# container. Detected via COPILOT_AGENT_ACTION or CAROOT/NODE_EXTRA_CA_CERTS in GITHUB_ACTIONS. SSL_CERT_MOUNTS="" + +# Detect coding agent environment: +# - COPILOT_AGENT_ACTION is set when running inside GitHub Copilot Coding Agent +# - CAROOT or NODE_EXTRA_CA_CERTS being set alongside GITHUB_ACTIONS indicates mkcert is in use +IN_CODING_AGENT=false if [ -n "${COPILOT_AGENT_ACTION}" ]; then - # Mount host's CA bundle to container's expected paths - SSL_CERT_MOUNTS="${SSL_CERT_MOUNTS} -v /etc/ssl/certs/ca-certificates.crt:/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem:ro" - SSL_CERT_MOUNTS="${SSL_CERT_MOUNTS} -v /etc/ssl/certs/ca-certificates.crt:/etc/pki/ca-trust/extracted/openssl/ca-bundle.trust.crt:ro" + IN_CODING_AGENT=true +elif [ -n "${GITHUB_ACTIONS}" ] && ([ -n "${CAROOT}" ] || [ -n "${NODE_EXTRA_CA_CERTS}" ]); then + IN_CODING_AGENT=true +fi + +# Build a combined cert bundle if we need to inject any additional CA certificates. +# This happens when customer certs are provided (AVM_SSL_CERT_FILE) or we are in the coding agent. +if [ -n "${AVM_SSL_CERT_FILE}" ] || [ "${IN_CODING_AGENT}" = "true" ]; then + # Validate customer-provided cert file if set + if [ -n "${AVM_SSL_CERT_FILE}" ] && [ ! -f "${AVM_SSL_CERT_FILE}" ]; then + echo "Warning: AVM_SSL_CERT_FILE is set but the file does not exist: ${AVM_SSL_CERT_FILE}" + fi + + # Create a temporary directory for the combined cert bundle. + # This directory is cleaned up automatically when the script exits. + AVM_CERT_TMP_DIR=$(mktemp -d) + trap 'rm -rf "${AVM_CERT_TMP_DIR}"' EXIT + + AVM_CERT_BUNDLE="${AVM_CERT_TMP_DIR}/ca-bundle.crt" + # Ensure the bundle file exists even if the system CA bundle is missing + touch "${AVM_CERT_BUNDLE}" + + # Start with the system CA bundle + if [ -f "/etc/ssl/certs/ca-certificates.crt" ]; then + cat "/etc/ssl/certs/ca-certificates.crt" > "${AVM_CERT_BUNDLE}" + fi + + # Append the mkcert root CA if in the coding agent environment (used for TLS inspection firewall). + # NODE_EXTRA_CA_CERTS takes priority over CAROOT/rootCA.pem. + if [ "${IN_CODING_AGENT}" = "true" ]; then + if [ -n "${NODE_EXTRA_CA_CERTS}" ] && [ -f "${NODE_EXTRA_CA_CERTS}" ]; then + echo "" >> "${AVM_CERT_BUNDLE}" + cat "${NODE_EXTRA_CA_CERTS}" >> "${AVM_CERT_BUNDLE}" + elif [ -n "${CAROOT}" ] && [ -f "${CAROOT}/rootCA.pem" ]; then + echo "" >> "${AVM_CERT_BUNDLE}" + cat "${CAROOT}/rootCA.pem" >> "${AVM_CERT_BUNDLE}" + fi + fi + + # Append customer-provided CA certificate(s) for private TLS inspection environments. + if [ -n "${AVM_SSL_CERT_FILE}" ] && [ -f "${AVM_SSL_CERT_FILE}" ]; then + echo "" >> "${AVM_CERT_BUNDLE}" + cat "${AVM_SSL_CERT_FILE}" >> "${AVM_CERT_BUNDLE}" + fi + + # Mount the combined cert bundle to the container's expected CA certificate paths + SSL_CERT_MOUNTS="-v ${AVM_CERT_BUNDLE}:/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem:ro" + SSL_CERT_MOUNTS="${SSL_CERT_MOUNTS} -v ${AVM_CERT_BUNDLE}:/etc/pki/ca-trust/extracted/openssl/ca-bundle.trust.crt:ro" + SSL_CERT_MOUNTS="${SSL_CERT_MOUNTS} -v ${AVM_CERT_BUNDLE}:/etc/ssl/certs/ca-certificates.crt:ro" fi # New: allow overriding TUI behavior with PORCH_FORCE_TUI and PORCH_NO_TUI environment variables. @@ -109,6 +173,15 @@ if [ -f "avm.config.json" ]; then done fi +# Forward AVM_BARE_* environment variables to the container with the prefix stripped +while IFS='=' read -r bareKey bareValue; do + [ -z "$bareKey" ] && continue + strippedKey="${bareKey#AVM_BARE_}" + export "$strippedKey"="$bareValue" + LOCAL_ENVIRONMENT_VARIABLES="${LOCAL_ENVIRONMENT_VARIABLES}-e $strippedKey " + echo "Forwarding AVM bare variable to container as: $strippedKey" +done < <(env | grep '^AVM_BARE_' || true) + # Check if we are running in a container # If we are then just run make directly if [ -z "${AVM_IN_CONTAINER}" ]; then @@ -135,7 +208,7 @@ if [ -z "${AVM_IN_CONTAINER}" ]; then -e TF_IN_AUTOMATION=1 \ ${LOCAL_ENVIRONMENT_VARIABLES} \ --env-file <(env | grep '^TF_VAR_') \ - --env-file <(env | grep '^AVM_') \ + --env-file <(env | grep '^AVM_' | grep -v '^AVM_BARE_') \ "${CONTAINER_IMAGE}" \ make \ TUI="${TUI}" \ diff --git a/avm.ps1 b/avm.ps1 index f51ae27..d9de118 100644 --- a/avm.ps1 +++ b/avm.ps1 @@ -150,11 +150,18 @@ if (-not $env:AVM_IN_CONTAINER) { $dockerArgs += @("-e", "$($_.Name)=$($_.Value)") } - # Add AVM_ environment variables - Get-ChildItem env: | Where-Object { $_.Name -like "AVM_*" } | ForEach-Object { + # Add AVM_ environment variables (excluding AVM_BARE_* which are forwarded with the prefix stripped) + Get-ChildItem env: | Where-Object { $_.Name -like "AVM_*" -and $_.Name -notlike "AVM_BARE_*" } | ForEach-Object { $dockerArgs += @("-e", "$($_.Name)=$($_.Value)") } + # Forward AVM_BARE_* environment variables to the container with the prefix stripped + Get-ChildItem env: | Where-Object { $_.Name -like "AVM_BARE_*" } | ForEach-Object { + $strippedName = $_.Name -replace '^AVM_BARE_', '' + $dockerArgs += @("-e", "$strippedName=$($_.Value)") + Write-Host "Forwarding AVM bare variable to container as: $strippedName" + } + # Add local environment variables from avm.config.json if (Test-Path "avm.config.json") { $jsonContent = Get-Content "avm.config.json" -Raw | ConvertFrom-Json -AsHashtable diff --git a/examples/linux_custom_data_gzip/README.md b/examples/linux_custom_data_gzip/README.md new file mode 100644 index 0000000..30d8d0d --- /dev/null +++ b/examples/linux_custom_data_gzip/README.md @@ -0,0 +1,380 @@ + + +# linux custom data gzip example + +This example demonstrates the cloudinit with gzip on a linux vm + +```hcl +terraform { + required_version = ">= 1.9, < 2.0" + + required_providers { + azapi = { + source = "azure/azapi" + version = "~> 2.0" + } + azurerm = { + source = "hashicorp/azurerm" + version = ">= 3.116, < 5.0" + } + cloudinit = { + source = "hashicorp/cloudinit" + version = "~> 2.0" + } + random = { + source = "hashicorp/random" + version = "~> 3.7" + } + } +} + +# tflint-ignore: terraform_module_provider_declaration, terraform_output_separate, terraform_variable_separate +provider "azurerm" { + features { + resource_group { + prevent_deletion_if_contains_resources = false + } + key_vault { + purge_soft_delete_on_destroy = true + } + } +} + +module "naming" { + source = "Azure/naming/azurerm" + version = "0.4.2" +} + +module "regions" { + source = "Azure/avm-utl-regions/azurerm" + version = "0.5.0" + + availability_zones_filter = true +} + +locals { + deployment_region = "canadacentral" + tags = { + scenario = "linux_custom_data_gzip" + } +} + +resource "random_integer" "region_index" { + max = length(module.regions.regions_by_name) - 1 + min = 0 +} + +resource "random_integer" "zone_index" { + max = length(module.regions.regions_by_name[local.deployment_region].zones) + min = 1 +} + +resource "azurerm_resource_group" "this_rg" { + location = local.deployment_region + name = module.naming.resource_group.name_unique + tags = local.tags +} + +module "vm_sku" { + source = "Azure/avm-utl-sku-finder/azapi" + version = "0.3.0" + + location = azurerm_resource_group.this_rg.location + cache_results = true + vm_filters = { + min_vcpus = 2 + max_vcpus = 2 + encryption_at_host_supported = true + accelerated_networking_enabled = true + premium_io_supported = true + location_zone = random_integer.zone_index.result + } + + depends_on = [random_integer.zone_index] +} + +module "natgateway" { + source = "Azure/avm-res-network-natgateway/azurerm" + version = "0.2.1" + + location = azurerm_resource_group.this_rg.location + name = module.naming.nat_gateway.name_unique + resource_group_name = azurerm_resource_group.this_rg.name + enable_telemetry = true + public_ips = { + public_ip_1 = { + name = "nat_gw_pip1" + } + } + tags = local.tags +} + +module "vnet" { + source = "Azure/avm-res-network-virtualnetwork/azurerm" + version = "=0.8.1" + + address_space = ["10.0.0.0/16"] + location = azurerm_resource_group.this_rg.location + resource_group_name = azurerm_resource_group.this_rg.name + name = module.naming.virtual_network.name_unique + subnets = { + vm_subnet_1 = { + name = "${module.naming.subnet.name_unique}-1" + address_prefixes = ["10.0.1.0/24"] + nat_gateway = { + id = module.natgateway.resource_id + } + } + } + tags = local.tags +} + +data "azurerm_client_config" "current" {} + +module "avm_res_keyvault_vault" { + source = "Azure/avm-res-keyvault-vault/azurerm" + version = "=0.10.0" + + location = azurerm_resource_group.this_rg.location + name = "${module.naming.key_vault.name_unique}-cd-gzip" + resource_group_name = azurerm_resource_group.this_rg.name + tenant_id = data.azurerm_client_config.current.tenant_id + network_acls = { + default_action = "Allow" + } + role_assignments = { + deployment_user_secrets = { + role_definition_id_or_name = "Key Vault Secrets Officer" + principal_id = data.azurerm_client_config.current.object_id + } + } + tags = local.tags + wait_for_rbac_before_secret_operations = { + create = "60s" + } +} + +# Plain text cloud-init (no gzip) — base64 only +data "cloudinit_config" "plaintext" { + gzip = false + base64_encode = true + + part { + content_type = "text/cloud-config" + content = <<-CLOUDINIT + #cloud-config + write_files: + - path: /tmp/hello-plaintext.txt + content: "Hello from plaintext cloud-init" + CLOUDINIT + } +} + +# Gzipped cloud-init — this is the scenario that was broken (Issue #207) +data "cloudinit_config" "gzipped" { + gzip = true + base64_encode = true + + part { + content_type = "text/cloud-config" + content = <<-CLOUDINIT + #cloud-config + write_files: + - path: /tmp/hello-gzipped.txt + content: "Hello from gzipped cloud-init" + CLOUDINIT + } +} + +# VM with plain text (non-gzipped) custom_data +module "vm_plaintext_custom_data" { + source = "../../" + + location = azurerm_resource_group.this_rg.location + name = "${module.naming.virtual_machine.name_unique}-plain" + resource_group_name = azurerm_resource_group.this_rg.name + zone = random_integer.zone_index.result + account_credentials = { + key_vault_configuration = { + resource_id = module.avm_res_keyvault_vault.resource_id + } + } + custom_data = data.cloudinit_config.plaintext.rendered + enable_telemetry = var.enable_telemetry + encryption_at_host_enabled = false + network_interfaces = { + network_interface_1 = { + name = "${module.naming.network_interface.name_unique}-plain" + ip_configurations = { + ip_configuration_1 = { + name = "${module.naming.network_interface.name_unique}-plain-ipconfig1" + private_ip_subnet_resource_id = module.vnet.subnets["vm_subnet_1"].resource_id + } + } + } + } + os_type = "Linux" + sku_size = module.vm_sku.sku + source_image_reference = { + publisher = "Canonical" + offer = "0001-com-ubuntu-server-focal" + sku = "20_04-lts-gen2" + version = "latest" + } + tags = local.tags + + depends_on = [ + module.avm_res_keyvault_vault + ] +} + +# VM with gzipped custom_data — previously rejected by validation (Issue #207) +module "vm_gzipped_custom_data" { + source = "../../" + + location = azurerm_resource_group.this_rg.location + name = "${module.naming.virtual_machine.name_unique}-gzip" + resource_group_name = azurerm_resource_group.this_rg.name + zone = random_integer.zone_index.result + account_credentials = { + key_vault_configuration = { + resource_id = module.avm_res_keyvault_vault.resource_id + } + } + custom_data = data.cloudinit_config.gzipped.rendered + enable_telemetry = var.enable_telemetry + encryption_at_host_enabled = false + network_interfaces = { + network_interface_1 = { + name = "${module.naming.network_interface.name_unique}-gzip" + ip_configurations = { + ip_configuration_1 = { + name = "${module.naming.network_interface.name_unique}-gzip-ipconfig1" + private_ip_subnet_resource_id = module.vnet.subnets["vm_subnet_1"].resource_id + } + } + } + } + os_type = "Linux" + sku_size = module.vm_sku.sku + source_image_reference = { + publisher = "Canonical" + offer = "0001-com-ubuntu-server-focal" + sku = "20_04-lts-gen2" + version = "latest" + } + tags = local.tags + + depends_on = [ + module.avm_res_keyvault_vault + ] +} +``` + + +## Requirements + +The following requirements are needed by this module: + +- [terraform](#requirement\_terraform) (>= 1.9, < 2.0) + +- [azapi](#requirement\_azapi) (~> 2.0) + +- [azurerm](#requirement\_azurerm) (>= 3.116, < 5.0) + +- [cloudinit](#requirement\_cloudinit) (~> 2.0) + +- [random](#requirement\_random) (~> 3.7) + +## Resources + +The following resources are used by this module: + +- [azapi_update_resource.allow_drop_unencrypted_vnet](https://registry.terraform.io/providers/azure/azapi/latest/docs/resources/update_resource) (resource) +- [azurerm_resource_group.this_rg](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/resource_group) (resource) +- [random_integer.region_index](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/integer) (resource) +- [random_integer.zone_index](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/integer) (resource) +- [azurerm_client_config.current](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/client_config) (data source) +- [cloudinit_config.gzipped](https://registry.terraform.io/providers/hashicorp/cloudinit/latest/docs/data-sources/config) (data source) +- [cloudinit_config.plaintext](https://registry.terraform.io/providers/hashicorp/cloudinit/latest/docs/data-sources/config) (data source) + + +## Required Inputs + +No required inputs. + +## Optional Inputs + +The following input variables are optional (have default values): + +### [enable\_telemetry](#input\_enable\_telemetry) + +Description: This variable controls whether or not telemetry is enabled for the module. +For more information see https://aka.ms/avm/telemetryinfo. +If it is set to false, then no telemetry will be collected. + +Type: `bool` + +Default: `true` + +## Outputs + +No outputs. + +## Modules + +The following Modules are called: + +### [avm\_res\_keyvault\_vault](#module\_avm\_res\_keyvault\_vault) + +Source: Azure/avm-res-keyvault-vault/azurerm + +Version: =0.10.0 + +### [naming](#module\_naming) + +Source: Azure/naming/azurerm + +Version: 0.4.2 + +### [natgateway](#module\_natgateway) + +Source: Azure/avm-res-network-natgateway/azurerm + +Version: 0.2.1 + +### [regions](#module\_regions) + +Source: Azure/avm-utl-regions/azurerm + +Version: 0.5.0 + +### [vm\_gzipped\_custom\_data](#module\_vm\_gzipped\_custom\_data) + +Source: ../../ + +Version: + +### [vm\_plaintext\_custom\_data](#module\_vm\_plaintext\_custom\_data) + +Source: ../../ + +Version: + +### [vm\_sku](#module\_vm\_sku) + +Source: Azure/avm-utl-sku-finder/azapi + +Version: 0.3.0 + +### [vnet](#module\_vnet) + +Source: Azure/avm-res-network-virtualnetwork/azurerm + +Version: =0.8.1 + + +## Data Collection + +The software may collect information about you and your use of the software and send it to Microsoft. Microsoft may use this information to provide services and improve our products and services. You may turn off the telemetry as described in the repository. There are also some features in the software that may enable you and Microsoft to collect data from users of your applications. If you use these features, you must comply with applicable law, including providing appropriate notices to users of your applications together with a copy of Microsoft’s privacy statement. Our privacy statement is located at . You can learn more about data collection and use in the help documentation and our privacy statement. Your use of the software operates as your consent to these practices. + \ No newline at end of file diff --git a/examples/linux_custom_data_gzip/_footer.md b/examples/linux_custom_data_gzip/_footer.md new file mode 100644 index 0000000..bc56bcb --- /dev/null +++ b/examples/linux_custom_data_gzip/_footer.md @@ -0,0 +1,4 @@ + +## Data Collection + +The software may collect information about you and your use of the software and send it to Microsoft. Microsoft may use this information to provide services and improve our products and services. You may turn off the telemetry as described in the repository. There are also some features in the software that may enable you and Microsoft to collect data from users of your applications. If you use these features, you must comply with applicable law, including providing appropriate notices to users of your applications together with a copy of Microsoft’s privacy statement. Our privacy statement is located at . You can learn more about data collection and use in the help documentation and our privacy statement. Your use of the software operates as your consent to these practices. diff --git a/examples/linux_custom_data_gzip/_header.md b/examples/linux_custom_data_gzip/_header.md new file mode 100644 index 0000000..484cd07 --- /dev/null +++ b/examples/linux_custom_data_gzip/_header.md @@ -0,0 +1,3 @@ +# linux custom data gzip example + +This example demonstrates the cloudinit with gzip on a linux vm diff --git a/examples/linux_custom_data_gzip/features.tf b/examples/linux_custom_data_gzip/features.tf new file mode 100644 index 0000000..6caa43e --- /dev/null +++ b/examples/linux_custom_data_gzip/features.tf @@ -0,0 +1,7 @@ +resource "azapi_update_resource" "allow_drop_unencrypted_vnet" { + resource_id = "/subscriptions/${data.azurerm_client_config.current.subscription_id}/providers/Microsoft.Features/featureProviders/Microsoft.Compute/subscriptionFeatureRegistrations/EncryptionAtHost" + type = "Microsoft.Features/featureProviders/subscriptionFeatureRegistrations@2021-07-01" + body = { + properties = {} + } +} diff --git a/examples/linux_custom_data_gzip/main.tf b/examples/linux_custom_data_gzip/main.tf new file mode 100644 index 0000000..e6f5c8e --- /dev/null +++ b/examples/linux_custom_data_gzip/main.tf @@ -0,0 +1,264 @@ +terraform { + required_version = ">= 1.9, < 2.0" + + required_providers { + azapi = { + source = "azure/azapi" + version = "~> 2.0" + } + azurerm = { + source = "hashicorp/azurerm" + version = ">= 3.116, < 5.0" + } + cloudinit = { + source = "hashicorp/cloudinit" + version = "~> 2.0" + } + random = { + source = "hashicorp/random" + version = "~> 3.7" + } + } +} + +# tflint-ignore: terraform_module_provider_declaration, terraform_output_separate, terraform_variable_separate +provider "azurerm" { + features { + resource_group { + prevent_deletion_if_contains_resources = false + } + key_vault { + purge_soft_delete_on_destroy = true + } + } +} + +module "naming" { + source = "Azure/naming/azurerm" + version = "0.4.2" +} + +module "regions" { + source = "Azure/avm-utl-regions/azurerm" + version = "0.5.0" + + availability_zones_filter = true +} + +locals { + deployment_region = "canadacentral" + tags = { + scenario = "linux_custom_data_gzip" + } +} + +resource "random_integer" "region_index" { + max = length(module.regions.regions_by_name) - 1 + min = 0 +} + +resource "random_integer" "zone_index" { + max = length(module.regions.regions_by_name[local.deployment_region].zones) + min = 1 +} + +resource "azurerm_resource_group" "this_rg" { + location = local.deployment_region + name = module.naming.resource_group.name_unique + tags = local.tags +} + +module "vm_sku" { + source = "Azure/avm-utl-sku-finder/azapi" + version = "0.3.0" + + location = azurerm_resource_group.this_rg.location + cache_results = true + vm_filters = { + min_vcpus = 2 + max_vcpus = 2 + encryption_at_host_supported = true + accelerated_networking_enabled = true + premium_io_supported = true + location_zone = random_integer.zone_index.result + } + + depends_on = [random_integer.zone_index] +} + +module "natgateway" { + source = "Azure/avm-res-network-natgateway/azurerm" + version = "0.2.1" + + location = azurerm_resource_group.this_rg.location + name = module.naming.nat_gateway.name_unique + resource_group_name = azurerm_resource_group.this_rg.name + enable_telemetry = true + public_ips = { + public_ip_1 = { + name = "nat_gw_pip1" + } + } + tags = local.tags +} + +module "vnet" { + source = "Azure/avm-res-network-virtualnetwork/azurerm" + version = "=0.8.1" + + address_space = ["10.0.0.0/16"] + location = azurerm_resource_group.this_rg.location + resource_group_name = azurerm_resource_group.this_rg.name + name = module.naming.virtual_network.name_unique + subnets = { + vm_subnet_1 = { + name = "${module.naming.subnet.name_unique}-1" + address_prefixes = ["10.0.1.0/24"] + nat_gateway = { + id = module.natgateway.resource_id + } + } + } + tags = local.tags +} + +data "azurerm_client_config" "current" {} + +module "avm_res_keyvault_vault" { + source = "Azure/avm-res-keyvault-vault/azurerm" + version = "=0.10.0" + + location = azurerm_resource_group.this_rg.location + name = "${module.naming.key_vault.name_unique}-cd-gzip" + resource_group_name = azurerm_resource_group.this_rg.name + tenant_id = data.azurerm_client_config.current.tenant_id + network_acls = { + default_action = "Allow" + } + role_assignments = { + deployment_user_secrets = { + role_definition_id_or_name = "Key Vault Secrets Officer" + principal_id = data.azurerm_client_config.current.object_id + } + } + tags = local.tags + wait_for_rbac_before_secret_operations = { + create = "60s" + } +} + +# Plain text cloud-init (no gzip) — base64 only +data "cloudinit_config" "plaintext" { + gzip = false + base64_encode = true + + part { + content_type = "text/cloud-config" + content = <<-CLOUDINIT + #cloud-config + write_files: + - path: /tmp/hello-plaintext.txt + content: "Hello from plaintext cloud-init" + CLOUDINIT + } +} + +# Gzipped cloud-init — this is the scenario that was broken (Issue #207) +data "cloudinit_config" "gzipped" { + gzip = true + base64_encode = true + + part { + content_type = "text/cloud-config" + content = <<-CLOUDINIT + #cloud-config + write_files: + - path: /tmp/hello-gzipped.txt + content: "Hello from gzipped cloud-init" + CLOUDINIT + } +} + +# VM with plain text (non-gzipped) custom_data +module "vm_plaintext_custom_data" { + source = "../../" + + location = azurerm_resource_group.this_rg.location + name = "${module.naming.virtual_machine.name_unique}-plain" + resource_group_name = azurerm_resource_group.this_rg.name + zone = random_integer.zone_index.result + account_credentials = { + key_vault_configuration = { + resource_id = module.avm_res_keyvault_vault.resource_id + } + } + custom_data = data.cloudinit_config.plaintext.rendered + enable_telemetry = var.enable_telemetry + encryption_at_host_enabled = false + network_interfaces = { + network_interface_1 = { + name = "${module.naming.network_interface.name_unique}-plain" + ip_configurations = { + ip_configuration_1 = { + name = "${module.naming.network_interface.name_unique}-plain-ipconfig1" + private_ip_subnet_resource_id = module.vnet.subnets["vm_subnet_1"].resource_id + } + } + } + } + os_type = "Linux" + sku_size = module.vm_sku.sku + source_image_reference = { + publisher = "Canonical" + offer = "0001-com-ubuntu-server-focal" + sku = "20_04-lts-gen2" + version = "latest" + } + tags = local.tags + + depends_on = [ + module.avm_res_keyvault_vault + ] +} + +# VM with gzipped custom_data — previously rejected by validation (Issue #207) +module "vm_gzipped_custom_data" { + source = "../../" + + location = azurerm_resource_group.this_rg.location + name = "${module.naming.virtual_machine.name_unique}-gzip" + resource_group_name = azurerm_resource_group.this_rg.name + zone = random_integer.zone_index.result + account_credentials = { + key_vault_configuration = { + resource_id = module.avm_res_keyvault_vault.resource_id + } + } + custom_data = data.cloudinit_config.gzipped.rendered + enable_telemetry = var.enable_telemetry + encryption_at_host_enabled = false + network_interfaces = { + network_interface_1 = { + name = "${module.naming.network_interface.name_unique}-gzip" + ip_configurations = { + ip_configuration_1 = { + name = "${module.naming.network_interface.name_unique}-gzip-ipconfig1" + private_ip_subnet_resource_id = module.vnet.subnets["vm_subnet_1"].resource_id + } + } + } + } + os_type = "Linux" + sku_size = module.vm_sku.sku + source_image_reference = { + publisher = "Canonical" + offer = "0001-com-ubuntu-server-focal" + sku = "20_04-lts-gen2" + version = "latest" + } + tags = local.tags + + depends_on = [ + module.avm_res_keyvault_vault + ] +} diff --git a/examples/linux_custom_data_gzip/variables.tf b/examples/linux_custom_data_gzip/variables.tf new file mode 100644 index 0000000..cd711a5 --- /dev/null +++ b/examples/linux_custom_data_gzip/variables.tf @@ -0,0 +1,10 @@ +# tflint-ignore: terraform_variable_separate, terraform_standard_module_structure +variable "enable_telemetry" { + type = bool + default = true + description = < + +# Linux VM from Existing Managed Disk (Attach Mode) + +This example demonstrates creating a Linux VM by attaching an existing managed disk as the OS disk, using the `os_managed_disk_id` parameter. + +This is useful for scenarios such as: + + - Restoring a VM from a backup (attaching a restored OS disk) + - Disaster recovery (rebuilding a VM around preserved disks) + - Migration from vendor-supplied VHD images (converting VHD to managed disk, then attaching) + +It includes the following resources: + + - A managed disk created from a platform image (simulating a pre-existing OS disk) + - A Linux VM that attaches the managed disk as its OS disk + - A VNet with a subnet and NAT gateway for outbound connectivity + +> **Note:** When using `os_managed_disk_id`, the module does not manage OS profile settings (admin credentials, computer name, custom data, patching configuration, etc.) since the OS is pre-configured on the existing disk. + +```hcl +terraform { + required_version = ">= 1.9, < 2.0" + + required_providers { + azapi = { + source = "azure/azapi" + version = "~> 2.0" + } + azurerm = { + source = "hashicorp/azurerm" + version = ">= 3.116, < 5.0" + } + random = { + source = "hashicorp/random" + version = "~> 3.7" + } + } +} + +# tflint-ignore: terraform_module_provider_declaration, terraform_output_separate, terraform_variable_separate +provider "azurerm" { + features { + resource_group { + prevent_deletion_if_contains_resources = false + } + } +} + +module "naming" { + source = "Azure/naming/azurerm" + version = "0.4.2" +} + +module "regions" { + source = "Azure/avm-utl-regions/azurerm" + version = "0.5.0" + + availability_zones_filter = true +} + +locals { + deployment_region = "canadacentral" #temporarily pinning on single region + tags = { + scenario = "linux_os_managed_disk" + } +} + +resource "random_integer" "region_index" { + max = length(module.regions.regions_by_name) - 1 + min = 0 +} + +resource "random_integer" "zone_index" { + max = length(module.regions.regions_by_name[local.deployment_region].zones) + min = 1 +} + +resource "azurerm_resource_group" "this_rg" { + location = local.deployment_region + name = module.naming.resource_group.name_unique + tags = local.tags +} + +module "vm_sku" { + source = "Azure/avm-utl-sku-finder/azapi" + version = "0.3.0" + + location = azurerm_resource_group.this_rg.location + cache_results = true + vm_filters = { + min_vcpus = 2 + max_vcpus = 2 + encryption_at_host_supported = true + accelerated_networking_enabled = true + premium_io_supported = true + location_zone = random_integer.zone_index.result + } + + depends_on = [random_integer.zone_index] +} + +module "natgateway" { + source = "Azure/avm-res-network-natgateway/azurerm" + version = "0.2.1" + + location = azurerm_resource_group.this_rg.location + name = module.naming.nat_gateway.name_unique + resource_group_name = azurerm_resource_group.this_rg.name + enable_telemetry = true + public_ips = { + public_ip_1 = { + name = "nat_gw_pip1" + } + } +} + +module "vnet" { + source = "Azure/avm-res-network-virtualnetwork/azurerm" + version = "=0.8.1" + + address_space = ["10.0.0.0/16"] + location = azurerm_resource_group.this_rg.location + resource_group_name = azurerm_resource_group.this_rg.name + name = module.naming.virtual_network.name_unique + subnets = { + vm_subnet_1 = { + name = "${module.naming.subnet.name_unique}-1" + address_prefixes = ["10.0.1.0/24"] + nat_gateway = { + id = module.natgateway.resource_id + } + } + } +} + +data "azurerm_client_config" "current" {} + +# Look up the latest Ubuntu 20.04 Gen2 image version for creating a managed disk +data "azurerm_platform_image" "ubuntu" { + location = azurerm_resource_group.this_rg.location + offer = "0001-com-ubuntu-server-focal" + publisher = "Canonical" + sku = "20_04-lts-gen2" +} + +# Create a managed disk from a platform image to simulate a pre-existing OS disk +# In real-world scenarios, this disk would come from a backup restore, VHD import, or disk copy +resource "azurerm_managed_disk" "os_disk" { + create_option = "FromImage" + location = azurerm_resource_group.this_rg.location + name = "${module.naming.managed_disk.name_unique}-os" + resource_group_name = azurerm_resource_group.this_rg.name + storage_account_type = "Premium_LRS" + hyper_v_generation = "V2" + image_reference_id = "/Subscriptions/${data.azurerm_client_config.current.subscription_id}/Providers/Microsoft.Compute/Locations/${azurerm_resource_group.this_rg.location}/Publishers/${data.azurerm_platform_image.ubuntu.publisher}/ArtifactTypes/VMImage/Offers/${data.azurerm_platform_image.ubuntu.offer}/Skus/${data.azurerm_platform_image.ubuntu.sku}/Versions/${data.azurerm_platform_image.ubuntu.version}" + os_type = "Linux" + tags = local.tags + zone = random_integer.zone_index.result +} + +# Create a Linux VM by attaching the pre-existing managed disk as the OS disk +module "testvm" { + source = "../../" + + location = azurerm_resource_group.this_rg.location + name = module.naming.virtual_machine.name_unique + resource_group_name = azurerm_resource_group.this_rg.name + zone = random_integer.zone_index.result + enable_telemetry = var.enable_telemetry + network_interfaces = { + network_interface_1 = { + name = module.naming.network_interface.name_unique + ip_configurations = { + ip_configuration_1 = { + name = "${module.naming.network_interface.name_unique}-ipconfig1" + private_ip_subnet_resource_id = module.vnet.subnets["vm_subnet_1"].resource_id + } + } + } + } + os_disk_attach_mode = true + os_managed_disk_id = azurerm_managed_disk.os_disk.id + os_type = "Linux" + sku_size = module.vm_sku.sku + tags = local.tags +} +``` + + +## Requirements + +The following requirements are needed by this module: + +- [terraform](#requirement\_terraform) (>= 1.9, < 2.0) + +- [azapi](#requirement\_azapi) (~> 2.0) + +- [azurerm](#requirement\_azurerm) (>= 3.116, < 5.0) + +- [random](#requirement\_random) (~> 3.7) + +## Resources + +The following resources are used by this module: + +- [azapi_update_resource.allow_drop_unencrypted_vnet](https://registry.terraform.io/providers/azure/azapi/latest/docs/resources/update_resource) (resource) +- [azurerm_managed_disk.os_disk](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/managed_disk) (resource) +- [azurerm_resource_group.this_rg](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/resource_group) (resource) +- [random_integer.region_index](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/integer) (resource) +- [random_integer.zone_index](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/integer) (resource) +- [azurerm_client_config.current](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/client_config) (data source) +- [azurerm_platform_image.ubuntu](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/platform_image) (data source) + + +## Required Inputs + +No required inputs. + +## Optional Inputs + +The following input variables are optional (have default values): + +### [enable\_telemetry](#input\_enable\_telemetry) + +Description: This variable controls whether or not telemetry is enabled for the module. +For more information see https://aka.ms/avm/telemetryinfo. +If it is set to false, then no telemetry will be collected. + +Type: `bool` + +Default: `true` + +## Outputs + +No outputs. + +## Modules + +The following Modules are called: + +### [naming](#module\_naming) + +Source: Azure/naming/azurerm + +Version: 0.4.2 + +### [natgateway](#module\_natgateway) + +Source: Azure/avm-res-network-natgateway/azurerm + +Version: 0.2.1 + +### [regions](#module\_regions) + +Source: Azure/avm-utl-regions/azurerm + +Version: 0.5.0 + +### [testvm](#module\_testvm) + +Source: ../../ + +Version: + +### [vm\_sku](#module\_vm\_sku) + +Source: Azure/avm-utl-sku-finder/azapi + +Version: 0.3.0 + +### [vnet](#module\_vnet) + +Source: Azure/avm-res-network-virtualnetwork/azurerm + +Version: =0.8.1 + + +## Data Collection + +The software may collect information about you and your use of the software and send it to Microsoft. Microsoft may use this information to provide services and improve our products and services. You may turn off the telemetry as described in the repository. There are also some features in the software that may enable you and Microsoft to collect data from users of your applications. If you use these features, you must comply with applicable law, including providing appropriate notices to users of your applications together with a copy of Microsoft's privacy statement. Our privacy statement is located at . You can learn more about data collection and use in the help documentation and our privacy statement. Your use of the software operates as your consent to these practices. + \ No newline at end of file diff --git a/examples/linux_os_managed_disk/_footer.md b/examples/linux_os_managed_disk/_footer.md new file mode 100644 index 0000000..a916b2f --- /dev/null +++ b/examples/linux_os_managed_disk/_footer.md @@ -0,0 +1,4 @@ + +## Data Collection + +The software may collect information about you and your use of the software and send it to Microsoft. Microsoft may use this information to provide services and improve our products and services. You may turn off the telemetry as described in the repository. There are also some features in the software that may enable you and Microsoft to collect data from users of your applications. If you use these features, you must comply with applicable law, including providing appropriate notices to users of your applications together with a copy of Microsoft's privacy statement. Our privacy statement is located at . You can learn more about data collection and use in the help documentation and our privacy statement. Your use of the software operates as your consent to these practices. diff --git a/examples/linux_os_managed_disk/_header.md b/examples/linux_os_managed_disk/_header.md new file mode 100644 index 0000000..c4320fb --- /dev/null +++ b/examples/linux_os_managed_disk/_header.md @@ -0,0 +1,17 @@ +# Linux VM from Existing Managed Disk (Attach Mode) + +This example demonstrates creating a Linux VM by attaching an existing managed disk as the OS disk, using the `os_managed_disk_id` parameter. + +This is useful for scenarios such as: + + - Restoring a VM from a backup (attaching a restored OS disk) + - Disaster recovery (rebuilding a VM around preserved disks) + - Migration from vendor-supplied VHD images (converting VHD to managed disk, then attaching) + +It includes the following resources: + + - A managed disk created from a platform image (simulating a pre-existing OS disk) + - A Linux VM that attaches the managed disk as its OS disk + - A VNet with a subnet and NAT gateway for outbound connectivity + +> **Note:** When using `os_managed_disk_id`, the module does not manage OS profile settings (admin credentials, computer name, custom data, patching configuration, etc.) since the OS is pre-configured on the existing disk. diff --git a/examples/linux_os_managed_disk/exceptions/aprl.rego b/examples/linux_os_managed_disk/exceptions/aprl.rego new file mode 100644 index 0000000..db16c95 --- /dev/null +++ b/examples/linux_os_managed_disk/exceptions/aprl.rego @@ -0,0 +1,5 @@ +package Azure_Proactive_Resiliency_Library_v2 +import rego.v1 +exception contains rules if { + rules = ["mission_critical_virtual_machine_should_use_premium_or_ultra_disks"] +} diff --git a/examples/linux_os_managed_disk/exceptions/avmsec.rego b/examples/linux_os_managed_disk/exceptions/avmsec.rego new file mode 100644 index 0000000..f19854f --- /dev/null +++ b/examples/linux_os_managed_disk/exceptions/avmsec.rego @@ -0,0 +1,5 @@ +package avmsec +import rego.v1 +exception contains rules if { + rules = ["AVM_SEC_178"] +} diff --git a/examples/linux_os_managed_disk/features.tf b/examples/linux_os_managed_disk/features.tf new file mode 100644 index 0000000..6caa43e --- /dev/null +++ b/examples/linux_os_managed_disk/features.tf @@ -0,0 +1,7 @@ +resource "azapi_update_resource" "allow_drop_unencrypted_vnet" { + resource_id = "/subscriptions/${data.azurerm_client_config.current.subscription_id}/providers/Microsoft.Features/featureProviders/Microsoft.Compute/subscriptionFeatureRegistrations/EncryptionAtHost" + type = "Microsoft.Features/featureProviders/subscriptionFeatureRegistrations@2021-07-01" + body = { + properties = {} + } +} diff --git a/examples/linux_os_managed_disk/main.tf b/examples/linux_os_managed_disk/main.tf new file mode 100644 index 0000000..78283da --- /dev/null +++ b/examples/linux_os_managed_disk/main.tf @@ -0,0 +1,166 @@ +terraform { + required_version = ">= 1.9, < 2.0" + + required_providers { + azapi = { + source = "azure/azapi" + version = "~> 2.0" + } + azurerm = { + source = "hashicorp/azurerm" + version = ">= 3.116, < 5.0" + } + random = { + source = "hashicorp/random" + version = "~> 3.7" + } + } +} + +# tflint-ignore: terraform_module_provider_declaration, terraform_output_separate, terraform_variable_separate +provider "azurerm" { + features { + resource_group { + prevent_deletion_if_contains_resources = false + } + } +} + +module "naming" { + source = "Azure/naming/azurerm" + version = "0.4.2" +} + +module "regions" { + source = "Azure/avm-utl-regions/azurerm" + version = "0.5.0" + + availability_zones_filter = true +} + +locals { + deployment_region = "canadacentral" #temporarily pinning on single region + tags = { + scenario = "linux_os_managed_disk" + } +} + +resource "random_integer" "region_index" { + max = length(module.regions.regions_by_name) - 1 + min = 0 +} + +resource "random_integer" "zone_index" { + max = length(module.regions.regions_by_name[local.deployment_region].zones) + min = 1 +} + +resource "azurerm_resource_group" "this_rg" { + location = local.deployment_region + name = module.naming.resource_group.name_unique + tags = local.tags +} + +module "vm_sku" { + source = "Azure/avm-utl-sku-finder/azapi" + version = "0.3.0" + + location = azurerm_resource_group.this_rg.location + cache_results = true + vm_filters = { + min_vcpus = 2 + max_vcpus = 2 + encryption_at_host_supported = true + accelerated_networking_enabled = true + premium_io_supported = true + location_zone = random_integer.zone_index.result + } + + depends_on = [random_integer.zone_index] +} + +module "natgateway" { + source = "Azure/avm-res-network-natgateway/azurerm" + version = "0.2.1" + + location = azurerm_resource_group.this_rg.location + name = module.naming.nat_gateway.name_unique + resource_group_name = azurerm_resource_group.this_rg.name + enable_telemetry = true + public_ips = { + public_ip_1 = { + name = "nat_gw_pip1" + } + } +} + +module "vnet" { + source = "Azure/avm-res-network-virtualnetwork/azurerm" + version = "=0.8.1" + + address_space = ["10.0.0.0/16"] + location = azurerm_resource_group.this_rg.location + resource_group_name = azurerm_resource_group.this_rg.name + name = module.naming.virtual_network.name_unique + subnets = { + vm_subnet_1 = { + name = "${module.naming.subnet.name_unique}-1" + address_prefixes = ["10.0.1.0/24"] + nat_gateway = { + id = module.natgateway.resource_id + } + } + } +} + +data "azurerm_client_config" "current" {} + +# Look up the latest Ubuntu 20.04 Gen2 image version for creating a managed disk +data "azurerm_platform_image" "ubuntu" { + location = azurerm_resource_group.this_rg.location + offer = "0001-com-ubuntu-server-focal" + publisher = "Canonical" + sku = "20_04-lts-gen2" +} + +# Create a managed disk from a platform image to simulate a pre-existing OS disk +# In real-world scenarios, this disk would come from a backup restore, VHD import, or disk copy +resource "azurerm_managed_disk" "os_disk" { + create_option = "FromImage" + location = azurerm_resource_group.this_rg.location + name = "${module.naming.managed_disk.name_unique}-os" + resource_group_name = azurerm_resource_group.this_rg.name + storage_account_type = "Premium_LRS" + hyper_v_generation = "V2" + image_reference_id = "/Subscriptions/${data.azurerm_client_config.current.subscription_id}/Providers/Microsoft.Compute/Locations/${azurerm_resource_group.this_rg.location}/Publishers/${data.azurerm_platform_image.ubuntu.publisher}/ArtifactTypes/VMImage/Offers/${data.azurerm_platform_image.ubuntu.offer}/Skus/${data.azurerm_platform_image.ubuntu.sku}/Versions/${data.azurerm_platform_image.ubuntu.version}" + os_type = "Linux" + tags = local.tags + zone = random_integer.zone_index.result +} + +# Create a Linux VM by attaching the pre-existing managed disk as the OS disk +module "testvm" { + source = "../../" + + location = azurerm_resource_group.this_rg.location + name = module.naming.virtual_machine.name_unique + resource_group_name = azurerm_resource_group.this_rg.name + zone = random_integer.zone_index.result + enable_telemetry = var.enable_telemetry + network_interfaces = { + network_interface_1 = { + name = module.naming.network_interface.name_unique + ip_configurations = { + ip_configuration_1 = { + name = "${module.naming.network_interface.name_unique}-ipconfig1" + private_ip_subnet_resource_id = module.vnet.subnets["vm_subnet_1"].resource_id + } + } + } + } + os_disk_attach_mode = true + os_managed_disk_id = azurerm_managed_disk.os_disk.id + os_type = "Linux" + sku_size = module.vm_sku.sku + tags = local.tags +} diff --git a/examples/linux_os_managed_disk/variables.tf b/examples/linux_os_managed_disk/variables.tf new file mode 100644 index 0000000..cd711a5 --- /dev/null +++ b/examples/linux_os_managed_disk/variables.tf @@ -0,0 +1,10 @@ +# tflint-ignore: terraform_variable_separate, terraform_standard_module_structure +variable "enable_telemetry" { + type = bool + default = true + description = < # Ubuntu VM with a number of common VM features -This example demonstrates the creation of a simple Ubuntu VM with the following features: +This example demonstrates the creation of a simple Ubuntu VM with the following features: **Note: This configuration example shows the use of a plaintext password. This is strongly discouraged but is included here to ensure testing of the feature during example testing. @@ -228,19 +228,8 @@ module "avm_res_keyvault_vault" { module "testvm" { source = "../../" - location = azurerm_resource_group.this_rg.location - name = module.naming.virtual_machine.name_unique - network_interfaces = { - network_interface_1 = { - name = module.naming.network_interface.name_unique - ip_configurations = { - ip_configuration_1 = { - name = "${module.naming.network_interface.name_unique}-ipconfig1" - private_ip_subnet_resource_id = module.vnet.subnets["vm_subnet_1"].resource_id - } - } - } - } + location = azurerm_resource_group.this_rg.location + name = module.naming.virtual_machine.name_unique resource_group_name = azurerm_resource_group.this_rg.name zone = random_integer.zone_index.result account_credentials = { @@ -253,6 +242,17 @@ module "testvm" { } enable_telemetry = var.enable_telemetry encryption_at_host_enabled = true + network_interfaces = { + network_interface_1 = { + name = module.naming.network_interface.name_unique + ip_configurations = { + ip_configuration_1 = { + name = "${module.naming.network_interface.name_unique}-ipconfig1" + private_ip_subnet_resource_id = module.vnet.subnets["vm_subnet_1"].resource_id + } + } + } + } os_disk = { caching = "ReadWrite" storage_account_type = "Premium_LRS" diff --git a/examples/linux_ubuntu_with_plaintext_password/_header.md b/examples/linux_ubuntu_with_plaintext_password/_header.md index 95a3a3b..0ae4e90 100644 --- a/examples/linux_ubuntu_with_plaintext_password/_header.md +++ b/examples/linux_ubuntu_with_plaintext_password/_header.md @@ -1,13 +1,13 @@ # Ubuntu VM with a number of common VM features -This example demonstrates the creation of a simple Ubuntu VM with the following features: +This example demonstrates the creation of a simple Ubuntu VM with the following features: -**Note: This configuration example shows the use of a plaintext password. This is strongly discouraged but is included here to ensure testing of the feature during example testing. +**Note: This configuration example shows the use of a plaintext password. This is strongly discouraged but is included here to ensure testing of the feature during example testing. - a single private IPv4 address - an user provided plaintext password for an admin user named azureuser - password authentication enabled - - a default OS 128gb OS disk + - a default OS 128gb OS disk - deploys into a randomly selected region It includes the following resources in addition to the VM resource: diff --git a/examples/linux_ubuntu_with_plaintext_password/main.tf b/examples/linux_ubuntu_with_plaintext_password/main.tf index 429fdd9..9756d87 100644 --- a/examples/linux_ubuntu_with_plaintext_password/main.tf +++ b/examples/linux_ubuntu_with_plaintext_password/main.tf @@ -207,19 +207,8 @@ module "avm_res_keyvault_vault" { module "testvm" { source = "../../" - location = azurerm_resource_group.this_rg.location - name = module.naming.virtual_machine.name_unique - network_interfaces = { - network_interface_1 = { - name = module.naming.network_interface.name_unique - ip_configurations = { - ip_configuration_1 = { - name = "${module.naming.network_interface.name_unique}-ipconfig1" - private_ip_subnet_resource_id = module.vnet.subnets["vm_subnet_1"].resource_id - } - } - } - } + location = azurerm_resource_group.this_rg.location + name = module.naming.virtual_machine.name_unique resource_group_name = azurerm_resource_group.this_rg.name zone = random_integer.zone_index.result account_credentials = { @@ -232,6 +221,17 @@ module "testvm" { } enable_telemetry = var.enable_telemetry encryption_at_host_enabled = true + network_interfaces = { + network_interface_1 = { + name = module.naming.network_interface.name_unique + ip_configurations = { + ip_configuration_1 = { + name = "${module.naming.network_interface.name_unique}-ipconfig1" + private_ip_subnet_resource_id = module.vnet.subnets["vm_subnet_1"].resource_id + } + } + } + } os_disk = { caching = "ReadWrite" storage_account_type = "Premium_LRS" diff --git a/examples/linux_vmss_flex_vm_attach/README.md b/examples/linux_vmss_flex_vm_attach/README.md index 689655b..cc84f91 100644 --- a/examples/linux_vmss_flex_vm_attach/README.md +++ b/examples/linux_vmss_flex_vm_attach/README.md @@ -231,8 +231,16 @@ resource "azurerm_orchestrated_virtual_machine_scale_set" "this" { module "testvm" { source = "../../" - location = azurerm_resource_group.this_rg.location - name = module.naming.virtual_machine.name_unique + location = azurerm_resource_group.this_rg.location + name = module.naming.virtual_machine.name_unique + resource_group_name = azurerm_resource_group.this_rg.name + zone = random_integer.zone_index.result + admin_password = random_password.admin_password.result + admin_username = "azureuser" + disable_password_authentication = false + enable_telemetry = var.enable_telemetry + encryption_at_host_enabled = true + generate_admin_password_or_ssh_key = false network_interfaces = { network_interface_1 = { name = module.naming.network_interface.name_unique @@ -244,14 +252,6 @@ module "testvm" { } } } - resource_group_name = azurerm_resource_group.this_rg.name - zone = random_integer.zone_index.result - admin_password = random_password.admin_password.result - admin_username = "azureuser" - disable_password_authentication = false - enable_telemetry = var.enable_telemetry - encryption_at_host_enabled = true - generate_admin_password_or_ssh_key = false os_disk = { caching = "ReadWrite" storage_account_type = "Premium_LRS" @@ -275,19 +275,8 @@ module "testvm" { module "testvm2" { source = "../../" - location = azurerm_resource_group.this_rg.location - name = "${module.naming.virtual_machine.name_unique}-01" - network_interfaces = { - network_interface_1 = { - name = "${module.naming.network_interface.name_unique}-01" - ip_configurations = { - ip_configuration_1 = { - name = "${module.naming.network_interface.name_unique}-01-ipconfig1" - private_ip_subnet_resource_id = module.vnet.subnets["vm_subnet_1"].resource_id - } - } - } - } + location = azurerm_resource_group.this_rg.location + name = "${module.naming.virtual_machine.name_unique}-01" resource_group_name = azurerm_resource_group.this_rg.name zone = random_integer.zone_index.result account_credentials = { @@ -300,6 +289,17 @@ module "testvm2" { } enable_telemetry = var.enable_telemetry encryption_at_host_enabled = true + network_interfaces = { + network_interface_1 = { + name = "${module.naming.network_interface.name_unique}-01" + ip_configurations = { + ip_configuration_1 = { + name = "${module.naming.network_interface.name_unique}-01-ipconfig1" + private_ip_subnet_resource_id = module.vnet.subnets["vm_subnet_1"].resource_id + } + } + } + } os_disk = { caching = "ReadWrite" storage_account_type = "Premium_LRS" diff --git a/examples/linux_vmss_flex_vm_attach/main.tf b/examples/linux_vmss_flex_vm_attach/main.tf index 81399ca..7fb748b 100644 --- a/examples/linux_vmss_flex_vm_attach/main.tf +++ b/examples/linux_vmss_flex_vm_attach/main.tf @@ -210,8 +210,16 @@ resource "azurerm_orchestrated_virtual_machine_scale_set" "this" { module "testvm" { source = "../../" - location = azurerm_resource_group.this_rg.location - name = module.naming.virtual_machine.name_unique + location = azurerm_resource_group.this_rg.location + name = module.naming.virtual_machine.name_unique + resource_group_name = azurerm_resource_group.this_rg.name + zone = random_integer.zone_index.result + admin_password = random_password.admin_password.result + admin_username = "azureuser" + disable_password_authentication = false + enable_telemetry = var.enable_telemetry + encryption_at_host_enabled = true + generate_admin_password_or_ssh_key = false network_interfaces = { network_interface_1 = { name = module.naming.network_interface.name_unique @@ -223,14 +231,6 @@ module "testvm" { } } } - resource_group_name = azurerm_resource_group.this_rg.name - zone = random_integer.zone_index.result - admin_password = random_password.admin_password.result - admin_username = "azureuser" - disable_password_authentication = false - enable_telemetry = var.enable_telemetry - encryption_at_host_enabled = true - generate_admin_password_or_ssh_key = false os_disk = { caching = "ReadWrite" storage_account_type = "Premium_LRS" @@ -254,19 +254,8 @@ module "testvm" { module "testvm2" { source = "../../" - location = azurerm_resource_group.this_rg.location - name = "${module.naming.virtual_machine.name_unique}-01" - network_interfaces = { - network_interface_1 = { - name = "${module.naming.network_interface.name_unique}-01" - ip_configurations = { - ip_configuration_1 = { - name = "${module.naming.network_interface.name_unique}-01-ipconfig1" - private_ip_subnet_resource_id = module.vnet.subnets["vm_subnet_1"].resource_id - } - } - } - } + location = azurerm_resource_group.this_rg.location + name = "${module.naming.virtual_machine.name_unique}-01" resource_group_name = azurerm_resource_group.this_rg.name zone = random_integer.zone_index.result account_credentials = { @@ -279,6 +268,17 @@ module "testvm2" { } enable_telemetry = var.enable_telemetry encryption_at_host_enabled = true + network_interfaces = { + network_interface_1 = { + name = "${module.naming.network_interface.name_unique}-01" + ip_configurations = { + ip_configuration_1 = { + name = "${module.naming.network_interface.name_unique}-01-ipconfig1" + private_ip_subnet_resource_id = module.vnet.subnets["vm_subnet_1"].resource_id + } + } + } + } os_disk = { caching = "ReadWrite" storage_account_type = "Premium_LRS" diff --git a/examples/linux_zonal_vm_with_zrs_disk/.gitkeep b/examples/linux_zonal_vm_with_zrs_disk/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/examples/linux_zonal_vm_with_zrs_disk/README.md b/examples/linux_zonal_vm_with_zrs_disk/README.md new file mode 100644 index 0000000..8fe390d --- /dev/null +++ b/examples/linux_zonal_vm_with_zrs_disk/README.md @@ -0,0 +1,344 @@ + + +# Default + +This example demonstrates the creation of a simple Ubuntu VM with the following features: + + - a single private IPv4 address + - an auto-generated SSH key for an admin user named azureuser + - password authentication disabled + - a single default OS 128gb OS disk + - deploys into a randomly selected region + - bound to a single randomly selected availablity zone + - a single ZRS data disk of 128gb + +It includes the following resources in addition to the VM resource: + + - A Vnet with two subnets + - A keyvault for storing the login secrets + - An optional subnet, public ip, and bastion which can be enabled by uncommenting the bastion resources when running the example. + +```hcl +terraform { + required_version = ">= 1.9, < 2.0" + + required_providers { + azapi = { + source = "azure/azapi" + version = "~> 2.0" + } + azurerm = { + source = "hashicorp/azurerm" + version = ">= 3.116, < 5.0" + } + random = { + source = "hashicorp/random" + version = "~> 3.7" + } + } +} + +# tflint-ignore: terraform_module_provider_declaration, terraform_output_separate, terraform_variable_separate +provider "azurerm" { + features { + resource_group { + prevent_deletion_if_contains_resources = false + } + key_vault { + purge_soft_delete_on_destroy = true + } + } +} + +module "naming" { + source = "Azure/naming/azurerm" + version = "0.4.2" +} + +module "regions" { + source = "Azure/avm-utl-regions/azurerm" + version = "0.5.0" + + availability_zones_filter = true +} + +locals { + #deployment_region = module.regions.regions[random_integer.region_index.result].name + deployment_region = "canadacentral" #temporarily pinning on single region + tags = { + scenario = "Default" + } +} + +resource "random_integer" "region_index" { + max = length(module.regions.regions_by_name) - 1 + min = 0 +} + +resource "random_integer" "zone_index" { + max = length(module.regions.regions_by_name[local.deployment_region].zones) + min = 1 +} + +resource "azurerm_resource_group" "this_rg" { + location = local.deployment_region + name = module.naming.resource_group.name_unique + tags = local.tags +} + +module "vm_sku" { + source = "Azure/avm-utl-sku-finder/azapi" + version = "0.3.0" + + location = azurerm_resource_group.this_rg.location + cache_results = true + vm_filters = { + min_vcpus = 2 + max_vcpus = 2 + encryption_at_host_supported = true + accelerated_networking_enabled = true + premium_io_supported = true + location_zone = random_integer.zone_index.result + } + + depends_on = [random_integer.zone_index] +} + +module "natgateway" { + source = "Azure/avm-res-network-natgateway/azurerm" + version = "0.2.1" + + location = azurerm_resource_group.this_rg.location + name = module.naming.nat_gateway.name_unique + resource_group_name = azurerm_resource_group.this_rg.name + enable_telemetry = true + public_ips = { + public_ip_1 = { + name = "nat_gw_pip1" + } + } +} + +module "vnet" { + source = "Azure/avm-res-network-virtualnetwork/azurerm" + version = "=0.8.1" + + address_space = ["10.0.0.0/16"] + location = azurerm_resource_group.this_rg.location + resource_group_name = azurerm_resource_group.this_rg.name + name = module.naming.virtual_network.name_unique + subnets = { + vm_subnet_1 = { + name = "${module.naming.subnet.name_unique}-1" + address_prefixes = ["10.0.1.0/24"] + nat_gateway = { + id = module.natgateway.resource_id + } + } + vm_subnet_2 = { + name = "${module.naming.subnet.name_unique}-2" + address_prefixes = ["10.0.2.0/24"] + nat_gateway = { + id = module.natgateway.resource_id + } + } + AzureBastionSubnet = { + name = "AzureBastionSubnet" + address_prefixes = ["10.0.3.0/24"] + } + } +} + +/* Uncomment this section if you would like to include a bastion resource with this example. +resource "azurerm_public_ip" "bastionpip" { + name = module.naming.public_ip.name_unique + location = azurerm_resource_group.this_rg.location + resource_group_name = azurerm_resource_group.this_rg.name + allocation_method = "Static" + sku = "Standard" +} + +resource "azurerm_bastion_host" "bastion" { + name = module.naming.bastion_host.name_unique + location = azurerm_resource_group.this_rg.location + resource_group_name = azurerm_resource_group.this_rg.name + + ip_configuration { + name = "${module.naming.bastion_host.name_unique}-ipconf" + subnet_id = module.vnet.subnets["AzureBastionSubnet"].resource_id + public_ip_address_id = azurerm_public_ip.bastionpip.id + } +} +*/ + +data "azurerm_client_config" "current" {} + +module "avm_res_keyvault_vault" { + source = "Azure/avm-res-keyvault-vault/azurerm" + version = "=0.10.0" + + location = azurerm_resource_group.this_rg.location + name = "${module.naming.key_vault.name_unique}-linux-default" + resource_group_name = azurerm_resource_group.this_rg.name + tenant_id = data.azurerm_client_config.current.tenant_id + network_acls = { + default_action = "Allow" + } + role_assignments = { + deployment_user_secrets = { + role_definition_id_or_name = "Key Vault Secrets Officer" + principal_id = data.azurerm_client_config.current.object_id + } + } + tags = local.tags + wait_for_rbac_before_secret_operations = { + create = "60s" + } +} + +module "testvm" { + source = "../../" + + location = azurerm_resource_group.this_rg.location + name = module.naming.virtual_machine.name_unique + resource_group_name = azurerm_resource_group.this_rg.name + zone = random_integer.zone_index.result + account_credentials = { + key_vault_configuration = { + resource_id = module.avm_res_keyvault_vault.resource_id + } + } + data_disk_managed_disks = { + disk_1 = { + caching = "None" + lun = 10 + name = "data-disk-01" + storage_account_type = "StandardSSD_ZRS" + disk_size_gb = 32 + } + } + enable_telemetry = var.enable_telemetry + network_interfaces = { + network_interface_1 = { + name = module.naming.network_interface.name_unique + ip_configurations = { + ip_configuration_1 = { + name = "${module.naming.network_interface.name_unique}-ipconfig1" + private_ip_subnet_resource_id = module.vnet.subnets["vm_subnet_1"].resource_id + } + } + } + } + os_type = "Linux" + sku_size = module.vm_sku.sku + source_image_reference = { + publisher = "Canonical" + offer = "0001-com-ubuntu-server-focal" + sku = "20_04-lts-gen2" + version = "latest" + } + tags = local.tags + + depends_on = [ + module.avm_res_keyvault_vault + ] +} +``` + + +## Requirements + +The following requirements are needed by this module: + +- [terraform](#requirement\_terraform) (>= 1.9, < 2.0) + +- [azapi](#requirement\_azapi) (~> 2.0) + +- [azurerm](#requirement\_azurerm) (>= 3.116, < 5.0) + +- [random](#requirement\_random) (~> 3.7) + +## Resources + +The following resources are used by this module: + +- [azapi_update_resource.allow_drop_unencrypted_vnet](https://registry.terraform.io/providers/azure/azapi/latest/docs/resources/update_resource) (resource) +- [azurerm_resource_group.this_rg](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/resource_group) (resource) +- [random_integer.region_index](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/integer) (resource) +- [random_integer.zone_index](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/integer) (resource) +- [azurerm_client_config.current](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/client_config) (data source) + + +## Required Inputs + +No required inputs. + +## Optional Inputs + +The following input variables are optional (have default values): + +### [enable\_telemetry](#input\_enable\_telemetry) + +Description: This variable controls whether or not telemetry is enabled for the module. +For more information see https://aka.ms/avm/telemetryinfo. +If it is set to false, then no telemetry will be collected. + +Type: `bool` + +Default: `true` + +## Outputs + +No outputs. + +## Modules + +The following Modules are called: + +### [avm\_res\_keyvault\_vault](#module\_avm\_res\_keyvault\_vault) + +Source: Azure/avm-res-keyvault-vault/azurerm + +Version: =0.10.0 + +### [naming](#module\_naming) + +Source: Azure/naming/azurerm + +Version: 0.4.2 + +### [natgateway](#module\_natgateway) + +Source: Azure/avm-res-network-natgateway/azurerm + +Version: 0.2.1 + +### [regions](#module\_regions) + +Source: Azure/avm-utl-regions/azurerm + +Version: 0.5.0 + +### [testvm](#module\_testvm) + +Source: ../../ + +Version: + +### [vm\_sku](#module\_vm\_sku) + +Source: Azure/avm-utl-sku-finder/azapi + +Version: 0.3.0 + +### [vnet](#module\_vnet) + +Source: Azure/avm-res-network-virtualnetwork/azurerm + +Version: =0.8.1 + + +## Data Collection + +The software may collect information about you and your use of the software and send it to Microsoft. Microsoft may use this information to provide services and improve our products and services. You may turn off the telemetry as described in the repository. There are also some features in the software that may enable you and Microsoft to collect data from users of your applications. If you use these features, you must comply with applicable law, including providing appropriate notices to users of your applications together with a copy of Microsoft’s privacy statement. Our privacy statement is located at . You can learn more about data collection and use in the help documentation and our privacy statement. Your use of the software operates as your consent to these practices. + \ No newline at end of file diff --git a/examples/linux_zonal_vm_with_zrs_disk/_footer.md b/examples/linux_zonal_vm_with_zrs_disk/_footer.md new file mode 100644 index 0000000..bc56bcb --- /dev/null +++ b/examples/linux_zonal_vm_with_zrs_disk/_footer.md @@ -0,0 +1,4 @@ + +## Data Collection + +The software may collect information about you and your use of the software and send it to Microsoft. Microsoft may use this information to provide services and improve our products and services. You may turn off the telemetry as described in the repository. There are also some features in the software that may enable you and Microsoft to collect data from users of your applications. If you use these features, you must comply with applicable law, including providing appropriate notices to users of your applications together with a copy of Microsoft’s privacy statement. Our privacy statement is located at . You can learn more about data collection and use in the help documentation and our privacy statement. Your use of the software operates as your consent to these practices. diff --git a/examples/linux_zonal_vm_with_zrs_disk/_header.md b/examples/linux_zonal_vm_with_zrs_disk/_header.md new file mode 100644 index 0000000..f8d8bc8 --- /dev/null +++ b/examples/linux_zonal_vm_with_zrs_disk/_header.md @@ -0,0 +1,17 @@ +# Default + +This example demonstrates the creation of a simple Ubuntu VM with the following features: + + - a single private IPv4 address + - an auto-generated SSH key for an admin user named azureuser + - password authentication disabled + - a single default OS 128gb OS disk + - deploys into a randomly selected region + - bound to a single randomly selected availablity zone + - a single ZRS data disk of 128gb + +It includes the following resources in addition to the VM resource: + + - A Vnet with two subnets + - A keyvault for storing the login secrets + - An optional subnet, public ip, and bastion which can be enabled by uncommenting the bastion resources when running the example. diff --git a/examples/linux_zonal_vm_with_zrs_disk/features.tf b/examples/linux_zonal_vm_with_zrs_disk/features.tf new file mode 100644 index 0000000..6caa43e --- /dev/null +++ b/examples/linux_zonal_vm_with_zrs_disk/features.tf @@ -0,0 +1,7 @@ +resource "azapi_update_resource" "allow_drop_unencrypted_vnet" { + resource_id = "/subscriptions/${data.azurerm_client_config.current.subscription_id}/providers/Microsoft.Features/featureProviders/Microsoft.Compute/subscriptionFeatureRegistrations/EncryptionAtHost" + type = "Microsoft.Features/featureProviders/subscriptionFeatureRegistrations@2021-07-01" + body = { + properties = {} + } +} diff --git a/examples/linux_zonal_vm_with_zrs_disk/main.tf b/examples/linux_zonal_vm_with_zrs_disk/main.tf new file mode 100644 index 0000000..0bf0422 --- /dev/null +++ b/examples/linux_zonal_vm_with_zrs_disk/main.tf @@ -0,0 +1,224 @@ +terraform { + required_version = ">= 1.9, < 2.0" + + required_providers { + azapi = { + source = "azure/azapi" + version = "~> 2.0" + } + azurerm = { + source = "hashicorp/azurerm" + version = ">= 3.116, < 5.0" + } + random = { + source = "hashicorp/random" + version = "~> 3.7" + } + } +} + +# tflint-ignore: terraform_module_provider_declaration, terraform_output_separate, terraform_variable_separate +provider "azurerm" { + features { + resource_group { + prevent_deletion_if_contains_resources = false + } + key_vault { + purge_soft_delete_on_destroy = true + } + } +} + +module "naming" { + source = "Azure/naming/azurerm" + version = "0.4.2" +} + +module "regions" { + source = "Azure/avm-utl-regions/azurerm" + version = "0.5.0" + + availability_zones_filter = true +} + +locals { + #deployment_region = module.regions.regions[random_integer.region_index.result].name + deployment_region = "canadacentral" #temporarily pinning on single region + tags = { + scenario = "Default" + } +} + +resource "random_integer" "region_index" { + max = length(module.regions.regions_by_name) - 1 + min = 0 +} + +resource "random_integer" "zone_index" { + max = length(module.regions.regions_by_name[local.deployment_region].zones) + min = 1 +} + +resource "azurerm_resource_group" "this_rg" { + location = local.deployment_region + name = module.naming.resource_group.name_unique + tags = local.tags +} + +module "vm_sku" { + source = "Azure/avm-utl-sku-finder/azapi" + version = "0.3.0" + + location = azurerm_resource_group.this_rg.location + cache_results = true + vm_filters = { + min_vcpus = 2 + max_vcpus = 2 + encryption_at_host_supported = true + accelerated_networking_enabled = true + premium_io_supported = true + location_zone = random_integer.zone_index.result + } + + depends_on = [random_integer.zone_index] +} + +module "natgateway" { + source = "Azure/avm-res-network-natgateway/azurerm" + version = "0.2.1" + + location = azurerm_resource_group.this_rg.location + name = module.naming.nat_gateway.name_unique + resource_group_name = azurerm_resource_group.this_rg.name + enable_telemetry = true + public_ips = { + public_ip_1 = { + name = "nat_gw_pip1" + } + } +} + +module "vnet" { + source = "Azure/avm-res-network-virtualnetwork/azurerm" + version = "=0.8.1" + + address_space = ["10.0.0.0/16"] + location = azurerm_resource_group.this_rg.location + resource_group_name = azurerm_resource_group.this_rg.name + name = module.naming.virtual_network.name_unique + subnets = { + vm_subnet_1 = { + name = "${module.naming.subnet.name_unique}-1" + address_prefixes = ["10.0.1.0/24"] + nat_gateway = { + id = module.natgateway.resource_id + } + } + vm_subnet_2 = { + name = "${module.naming.subnet.name_unique}-2" + address_prefixes = ["10.0.2.0/24"] + nat_gateway = { + id = module.natgateway.resource_id + } + } + AzureBastionSubnet = { + name = "AzureBastionSubnet" + address_prefixes = ["10.0.3.0/24"] + } + } +} + +/* Uncomment this section if you would like to include a bastion resource with this example. +resource "azurerm_public_ip" "bastionpip" { + name = module.naming.public_ip.name_unique + location = azurerm_resource_group.this_rg.location + resource_group_name = azurerm_resource_group.this_rg.name + allocation_method = "Static" + sku = "Standard" +} + +resource "azurerm_bastion_host" "bastion" { + name = module.naming.bastion_host.name_unique + location = azurerm_resource_group.this_rg.location + resource_group_name = azurerm_resource_group.this_rg.name + + ip_configuration { + name = "${module.naming.bastion_host.name_unique}-ipconf" + subnet_id = module.vnet.subnets["AzureBastionSubnet"].resource_id + public_ip_address_id = azurerm_public_ip.bastionpip.id + } +} +*/ + +data "azurerm_client_config" "current" {} + +module "avm_res_keyvault_vault" { + source = "Azure/avm-res-keyvault-vault/azurerm" + version = "=0.10.0" + + location = azurerm_resource_group.this_rg.location + name = "${module.naming.key_vault.name_unique}-linux-default" + resource_group_name = azurerm_resource_group.this_rg.name + tenant_id = data.azurerm_client_config.current.tenant_id + network_acls = { + default_action = "Allow" + } + role_assignments = { + deployment_user_secrets = { + role_definition_id_or_name = "Key Vault Secrets Officer" + principal_id = data.azurerm_client_config.current.object_id + } + } + tags = local.tags + wait_for_rbac_before_secret_operations = { + create = "60s" + } +} + +module "testvm" { + source = "../../" + + location = azurerm_resource_group.this_rg.location + name = module.naming.virtual_machine.name_unique + resource_group_name = azurerm_resource_group.this_rg.name + zone = random_integer.zone_index.result + account_credentials = { + key_vault_configuration = { + resource_id = module.avm_res_keyvault_vault.resource_id + } + } + data_disk_managed_disks = { + disk_1 = { + caching = "None" + lun = 10 + name = "data-disk-01" + storage_account_type = "StandardSSD_ZRS" + disk_size_gb = 32 + } + } + enable_telemetry = var.enable_telemetry + network_interfaces = { + network_interface_1 = { + name = module.naming.network_interface.name_unique + ip_configurations = { + ip_configuration_1 = { + name = "${module.naming.network_interface.name_unique}-ipconfig1" + private_ip_subnet_resource_id = module.vnet.subnets["vm_subnet_1"].resource_id + } + } + } + } + os_type = "Linux" + sku_size = module.vm_sku.sku + source_image_reference = { + publisher = "Canonical" + offer = "0001-com-ubuntu-server-focal" + sku = "20_04-lts-gen2" + version = "latest" + } + tags = local.tags + + depends_on = [ + module.avm_res_keyvault_vault + ] +} diff --git a/examples/linux_zonal_vm_with_zrs_disk/variables.tf b/examples/linux_zonal_vm_with_zrs_disk/variables.tf new file mode 100644 index 0000000..cd711a5 --- /dev/null +++ b/examples/linux_zonal_vm_with_zrs_disk/variables.tf @@ -0,0 +1,10 @@ +# tflint-ignore: terraform_variable_separate, terraform_standard_module_structure +variable "enable_telemetry" { + type = bool + default = true + description = < 0 ? var.account_credentials.admin_credentials.ssh_keys : (length(local.deprecated_keys) > 0 ? local.deprecated_keys : [])) #set the ssh key secret value to the generated key if password authentication is disabled and no ssh key is provided. Otherwise, set it to "no_key" to indicate that no key was provided. - admin_ssh_key_secret_value = ((local.password_authentication_disabled == true) && (lower(var.os_type) == "linux") && length(local.admin_ssh_key_input) == 0) ? tls_private_key.this[0].private_key_pem : "no_key" + admin_ssh_key_secret_value = (!local.os_disk_is_imported && (local.password_authentication_disabled == true) && (lower(var.os_type) == "linux") && length(local.admin_ssh_key_input) == 0) ? tls_private_key.this[0].private_key_pem : "no_key" #concat the ssh key values list admin_ssh_keys = concat(var.admin_ssh_keys, local.admin_ssh_key) #set this to the local after deprecation #set the admin user to use the following order: # 1. account_credentials.username # 2. admin_username # 3. azureuser (default value if not provided)) - admin_username = var.account_credentials.admin_credentials.username != "azureuser" ? var.account_credentials.admin_credentials.username : (var.admin_username != "azureuser" ? var.admin_username : "azureuser") #both default to azureuser without input so no need for special handling. After deprecation, set admin_username to var.account_credentials.username + # When os_managed_disk_id is set, admin_username must be null (Provider ExactlyOneOf constraint) + admin_username = local.os_disk_is_imported ? null : (var.account_credentials.admin_credentials.username != "azureuser" ? var.account_credentials.admin_credentials.username : (var.admin_username != "azureuser" ? var.admin_username : "azureuser")) #both default to azureuser without input so no need for special handling. After deprecation, set admin_username to var.account_credentials.username #set the name for the password secret in the key vault if the key vault secret configuration is not null and there is a password input. credential_secret_name_password = ( local.credentials_key_vault_config != null ? ( local.credentials_key_vault_config.secret_configuration != null ? ( - local.credentials_key_vault_config.secret_configuration.name != null ? local.credentials_key_vault_config.secret_configuration.name : "${var.name}-${local.admin_username}-password" - ) : "${var.name}-${local.admin_username}-password") : "${var.name}-${local.admin_username}-password") + local.credentials_key_vault_config.secret_configuration.name != null ? local.credentials_key_vault_config.secret_configuration.name : "${var.name}-${coalesce(local.admin_username, "imported")}-password" + ) : "${var.name}-${coalesce(local.admin_username, "imported")}-password") : "${var.name}-${coalesce(local.admin_username, "imported")}-password") #set the name for the ssh secret in the key vault if the key vault secret configuration is not null and there is a password input. credential_secret_name_ssh_key = ( local.credentials_key_vault_config != null ? ( local.credentials_key_vault_config.secret_configuration != null ? ( - local.credentials_key_vault_config.secret_configuration.name != null ? local.credentials_key_vault_config.secret_configuration.name : "${var.name}-${local.admin_username}-ssh-private-key" - ) : "${var.name}-${local.admin_username}-ssh-private-key") : "${var.name}-${local.admin_username}-ssh-private-key") + local.credentials_key_vault_config.secret_configuration.name != null ? local.credentials_key_vault_config.secret_configuration.name : "${var.name}-${coalesce(local.admin_username, "imported")}-ssh-private-key" + ) : "${var.name}-${coalesce(local.admin_username, "imported")}-ssh-private-key") : "${var.name}-${coalesce(local.admin_username, "imported")}-ssh-private-key") #use locals to define whether a secret should be created in the key vault credential_secret_vault_count = ( #if the key vault config is set, then create a credential secret local.credentials_key_vault_config != null ? 1 : 0 #the resource_id value is a required field in both cases, so we can use that to determine if the key vault config is set. @@ -66,6 +71,7 @@ locals { #ssh key for handling deprecated ssh key input (the schema's are different,so we need to handle this) flattened_ssh_keys = flatten([for key in var.admin_ssh_keys : key.public_key]) generate_admin_ssh_key_count = ( + !local.os_disk_is_imported && (lower(var.os_type) == "linux") && ( (var.generate_admin_password_or_ssh_key == true) && @@ -73,18 +79,19 @@ locals { ) && (local.password_authentication_disabled == true) ? 1 : 0 ) generate_random_password_count = ( - ( - (lower(var.os_type) == "windows") && - ( - (var.generate_admin_password_or_ssh_key == true) && - (var.account_credentials.admin_credentials.generate_admin_password_or_ssh_key == true) - ) - ) ? 1 : ( - (lower(var.os_type) == "linux") && + local.os_disk_is_imported ? 0 : ( ( - (var.generate_admin_password_or_ssh_key == true && var.account_credentials.admin_credentials.generate_admin_password_or_ssh_key == true) && (local.password_authentication_disabled == false) - ) - ) ? 1 : 0 + (lower(var.os_type) == "windows") && + ( + (var.generate_admin_password_or_ssh_key == true) && + (var.account_credentials.admin_credentials.generate_admin_password_or_ssh_key == true) + ) + ) ? 1 : ( + (lower(var.os_type) == "linux") && + ( + (var.generate_admin_password_or_ssh_key == true && var.account_credentials.admin_credentials.generate_admin_password_or_ssh_key == true) && (local.password_authentication_disabled == false) + ) + ) ? 1 : 0) ) generated_secret_expiration_date_utc = local.generated_secret_expiration_date_utc_new == null ? local.generated_secret_expiration_date_utc_depr : local.generated_secret_expiration_date_utc_new #calculate the expiration date for the key vault secret. If the key vault config is set, then use that value. Otherwise, use the default value of 45 days. @@ -93,10 +100,11 @@ locals { password_authentication_disabled = var.account_credentials.password_authentication_disabled == false ? var.account_credentials.password_authentication_disabled : ( var.disable_password_authentication == false ? var.disable_password_authentication : true) #defaults to true for both vars. Prefer var.account_credentials value if set, otherwise use var.disable_password_authentication. If both are set, prefer var.account_credentials value. After deprecation, set password_authentication_disabled to var.account_credentials.password_authentication_disabled #set the count to 1 if a password value is provided and a secret configuration is provided or generated. This will be used to create the key vault secret. - password_secret_count = ( + #skip credential secrets when using imported OS disk (Attach mode) since no credentials are managed + password_secret_count = local.os_disk_is_imported ? 0 : ( (local.credential_secret_vault_count == 1 && lower(var.os_type) == "windows") || (local.credential_secret_vault_count == 1 && lower(var.os_type) == "linux" && local.password_authentication_disabled == false) ? 1 : 0 ) #set the count to 1 if a ssh value is provided and a secret configuration is provided or generated. This will be used to create the key vault secret. - ssh_secret_count = (local.credential_secret_vault_count == 1 && local.generate_admin_ssh_key_count == 1) ? 1 : 0 + ssh_secret_count = local.os_disk_is_imported ? 0 : (local.credential_secret_vault_count == 1 && local.generate_admin_ssh_key_count == 1) ? 1 : 0 } diff --git a/locals.tf b/locals.tf index 74eae13..9691b33 100644 --- a/locals.tf +++ b/locals.tf @@ -117,6 +117,11 @@ locals { [for nic, value in var.network_interfaces : nic if value.is_primary], [for nic, value in var.network_interfaces : nic if !value.is_primary] ) + # Whether the OS disk is being imported from an existing managed disk (Attach mode) + # Uses var.os_disk_attach_mode (known at plan time) rather than var.os_managed_disk_id != null + # because os_managed_disk_id is typically a computed resource attribute (unknown at plan time), + # which would make count expressions that depend on this local undeterminable during planning. + os_disk_is_imported = var.os_disk_attach_mode #concat the input variable with the simple list going forward - this is a placeholder so that we can continue to reference the local source image reference value when it includes the simpleOS option. source_image_reference = var.source_image_reference #get the first system managed identity id if it is provisioned and depending on whether the vm type is linux or windows diff --git a/main.disks.tf b/main.disks.tf index 3b4b951..8ca1a7f 100644 --- a/main.disks.tf +++ b/main.disks.tf @@ -34,7 +34,7 @@ resource "azurerm_managed_disk" "this" { tier = each.value.tier trusted_launch_enabled = each.value.trusted_launch_enabled upload_size_bytes = each.value.upload_size_bytes - zone = var.zone + zone = strcontains(each.value.storage_account_type, "ZRS") ? null : var.zone dynamic "encryption_settings" { for_each = each.value.encryption_settings diff --git a/main.linux_vm.tf b/main.linux_vm.tf index a412496..cc55399 100644 --- a/main.linux_vm.tf +++ b/main.linux_vm.tf @@ -1,7 +1,7 @@ resource "azurerm_linux_virtual_machine" "this" { count = (lower(var.os_type) == "linux") ? 1 : 0 - #required properties + #required properties (admin_username is null when using os_managed_disk_id per Provider ExactlyOneOf constraint) admin_username = local.admin_username location = var.location name = var.name @@ -10,13 +10,13 @@ resource "azurerm_linux_virtual_machine" "this" { resource_group_name = var.resource_group_name size = var.sku_size #optional properties - admin_password = (local.password_authentication_disabled ? null : local.admin_password_linux) + admin_password = local.os_disk_is_imported ? null : (local.password_authentication_disabled ? null : local.admin_password_linux) allow_extension_operations = var.allow_extension_operations availability_set_id = var.availability_set_resource_id - bypass_platform_safety_checks_on_user_schedule_enabled = var.bypass_platform_safety_checks_on_user_schedule_enabled + bypass_platform_safety_checks_on_user_schedule_enabled = local.os_disk_is_imported ? null : var.bypass_platform_safety_checks_on_user_schedule_enabled capacity_reservation_group_id = var.capacity_reservation_group_resource_id - computer_name = coalesce(var.computer_name, var.name) - custom_data = var.custom_data + computer_name = local.os_disk_is_imported ? null : coalesce(var.computer_name, var.name) + custom_data = local.os_disk_is_imported ? null : var.custom_data dedicated_host_group_id = var.dedicated_host_group_resource_id dedicated_host_id = var.dedicated_host_resource_id disable_password_authentication = local.password_authentication_disabled @@ -27,15 +27,16 @@ resource "azurerm_linux_virtual_machine" "this" { extensions_time_budget = var.extensions_time_budget license_type = var.license_type max_bid_price = var.max_bid_price - patch_assessment_mode = var.patch_assessment_mode - patch_mode = var.patch_mode + os_managed_disk_id = var.os_managed_disk_id + patch_assessment_mode = local.os_disk_is_imported ? null : var.patch_assessment_mode + patch_mode = local.os_disk_is_imported ? null : var.patch_mode platform_fault_domain = var.platform_fault_domain priority = var.priority - provision_vm_agent = var.provision_vm_agent + provision_vm_agent = local.os_disk_is_imported ? null : var.provision_vm_agent proximity_placement_group_id = var.proximity_placement_group_resource_id - reboot_setting = var.reboot_setting + reboot_setting = local.os_disk_is_imported ? null : var.reboot_setting secure_boot_enabled = var.secure_boot_enabled - source_image_id = var.source_image_resource_id + source_image_id = local.os_disk_is_imported ? null : var.source_image_resource_id tags = local.tags user_data = var.user_data virtual_machine_scale_set_id = var.virtual_machine_scale_set_resource_id @@ -45,7 +46,7 @@ resource "azurerm_linux_virtual_machine" "this" { os_disk { caching = var.os_disk.caching - storage_account_type = var.os_disk.storage_account_type + storage_account_type = local.os_disk_is_imported ? null : var.os_disk.storage_account_type disk_encryption_set_id = var.os_disk.disk_encryption_set_id disk_size_gb = var.os_disk.disk_size_gb name = var.os_disk.name @@ -71,7 +72,7 @@ resource "azurerm_linux_virtual_machine" "this" { } } dynamic "admin_ssh_key" { - for_each = toset(local.admin_ssh_keys) + for_each = local.os_disk_is_imported ? [] : toset(local.admin_ssh_keys) content { public_key = admin_ssh_key.value.public_key @@ -128,7 +129,7 @@ resource "azurerm_linux_virtual_machine" "this" { } } dynamic "source_image_reference" { - for_each = var.source_image_resource_id == null ? ["source_image_reference"] : [] + for_each = (var.source_image_resource_id == null && !local.os_disk_is_imported) ? ["source_image_reference"] : [] content { offer = local.source_image_reference.offer @@ -156,6 +157,13 @@ resource "azurerm_linux_virtual_machine" "this" { azurerm_network_interface_application_gateway_backend_address_pool_association.this, azurerm_network_interface_nat_rule_association.this ] + + lifecycle { + precondition { + condition = var.os_managed_disk_id == null || var.os_disk.diff_disk_settings == null + error_message = "The os_managed_disk_id and os_disk.diff_disk_settings are mutually exclusive. Ephemeral OS disks cannot be used when attaching an existing managed disk." + } + } } moved { diff --git a/main.windows_vm.tf b/main.windows_vm.tf index 4208144..ac662b2 100644 --- a/main.windows_vm.tf +++ b/main.windows_vm.tf @@ -1,8 +1,8 @@ resource "azurerm_windows_virtual_machine" "this" { count = (lower(var.os_type) == "windows") ? 1 : 0 - #required properties - admin_password = local.admin_password_windows + #required properties (admin_password and admin_username are null when using os_managed_disk_id per Provider ExactlyOneOf/ConflictsWith constraints) + admin_password = local.os_disk_is_imported ? null : local.admin_password_windows admin_username = local.admin_username location = var.location name = var.name @@ -13,30 +13,31 @@ resource "azurerm_windows_virtual_machine" "this" { #optional properties allow_extension_operations = var.allow_extension_operations availability_set_id = var.availability_set_resource_id - bypass_platform_safety_checks_on_user_schedule_enabled = var.bypass_platform_safety_checks_on_user_schedule_enabled + bypass_platform_safety_checks_on_user_schedule_enabled = local.os_disk_is_imported ? null : var.bypass_platform_safety_checks_on_user_schedule_enabled capacity_reservation_group_id = var.capacity_reservation_group_resource_id - computer_name = coalesce(var.computer_name, var.name) - custom_data = var.custom_data + computer_name = local.os_disk_is_imported ? null : coalesce(var.computer_name, var.name) + custom_data = local.os_disk_is_imported ? null : var.custom_data dedicated_host_group_id = var.dedicated_host_group_resource_id dedicated_host_id = var.dedicated_host_resource_id disk_controller_type = var.disk_controller_type edge_zone = var.edge_zone - enable_automatic_updates = var.enable_automatic_updates + enable_automatic_updates = local.os_disk_is_imported ? null : var.enable_automatic_updates encryption_at_host_enabled = var.encryption_at_host_enabled eviction_policy = var.eviction_policy extensions_time_budget = var.extensions_time_budget - hotpatching_enabled = var.hotpatching_enabled + hotpatching_enabled = local.os_disk_is_imported ? null : var.hotpatching_enabled license_type = var.license_type max_bid_price = var.max_bid_price - patch_assessment_mode = var.patch_assessment_mode - patch_mode = var.patch_mode + os_managed_disk_id = var.os_managed_disk_id + patch_assessment_mode = local.os_disk_is_imported ? null : var.patch_assessment_mode + patch_mode = local.os_disk_is_imported ? null : var.patch_mode platform_fault_domain = var.platform_fault_domain priority = var.priority - provision_vm_agent = var.provision_vm_agent + provision_vm_agent = local.os_disk_is_imported ? null : var.provision_vm_agent proximity_placement_group_id = var.proximity_placement_group_resource_id - reboot_setting = var.reboot_setting + reboot_setting = local.os_disk_is_imported ? null : var.reboot_setting secure_boot_enabled = var.secure_boot_enabled - source_image_id = var.source_image_resource_id + source_image_id = local.os_disk_is_imported ? null : var.source_image_resource_id tags = local.tags timezone = var.timezone user_data = var.user_data @@ -47,7 +48,7 @@ resource "azurerm_windows_virtual_machine" "this" { os_disk { caching = var.os_disk.caching - storage_account_type = var.os_disk.storage_account_type + storage_account_type = local.os_disk_is_imported ? null : var.os_disk.storage_account_type disk_encryption_set_id = var.os_disk.disk_encryption_set_id disk_size_gb = var.os_disk.disk_size_gb name = var.os_disk.name @@ -133,7 +134,7 @@ resource "azurerm_windows_virtual_machine" "this" { } } dynamic "source_image_reference" { - for_each = var.source_image_resource_id == null ? ["source_image_reference"] : [] + for_each = (var.source_image_resource_id == null && !local.os_disk_is_imported) ? ["source_image_reference"] : [] content { offer = local.source_image_reference.offer @@ -175,6 +176,11 @@ resource "azurerm_windows_virtual_machine" "this" { winrm_listener, # Once the certificate got rotated, it will trigger a destroy/recreate of the VM. vm_agent_platform_updates_enabled # Added property to ignore_changes as it continues to detect change in state. ] + + precondition { + condition = var.os_managed_disk_id == null || var.os_disk.diff_disk_settings == null + error_message = "The os_managed_disk_id and os_disk.diff_disk_settings are mutually exclusive. Ephemeral OS disks cannot be used when attaching an existing managed disk." + } } } diff --git a/terraform.tf b/terraform.tf index 9018228..fc38a14 100644 --- a/terraform.tf +++ b/terraform.tf @@ -16,7 +16,7 @@ terraform { } random = { source = "hashicorp/random" - version = "~> 3.7" + version = ">= 3.6.2, < 4.0.0" } tls = { source = "hashicorp/tls" diff --git a/variables.tf b/variables.tf index 6befebb..f78d445 100644 --- a/variables.tf +++ b/variables.tf @@ -2,6 +2,8 @@ # # The following arguments are supported: +########## optional variables + ########## Required variables variable "location" { type = string @@ -20,169 +22,6 @@ variable "name" { } } -variable "network_interfaces" { - type = map(object({ - name = string - ip_configurations = map(object({ - name = string - app_gateway_backend_pools = optional(map(object({ - app_gateway_backend_pool_resource_id = string - })), {}) - create_public_ip_address = optional(bool, false) - gateway_load_balancer_frontend_ip_configuration_resource_id = optional(string) - is_primary_ipconfiguration = optional(bool, true) - load_balancer_backend_pools = optional(map(object({ - load_balancer_backend_pool_resource_id = string - })), {}) - load_balancer_nat_rules = optional(map(object({ - load_balancer_nat_rule_resource_id = string - })), {}) - private_ip_address = optional(string) - private_ip_address_allocation = optional(string, "Dynamic") - private_ip_address_version = optional(string, "IPv4") - private_ip_subnet_resource_id = optional(string) - public_ip_address_lock_name = optional(string) - public_ip_address_name = optional(string) - public_ip_address_resource_id = optional(string) - })) - accelerated_networking_enabled = optional(bool, false) - application_security_groups = optional(map(object({ - application_security_group_resource_id = string - })), {}) - diagnostic_settings = optional(map(object({ - name = optional(string, null) - log_categories = optional(set(string), []) - log_groups = optional(set(string), []) - metric_categories = optional(set(string), ["AllMetrics"]) - log_analytics_destination_type = optional(string, null) - workspace_resource_id = optional(string, null) - storage_account_resource_id = optional(string, null) - event_hub_authorization_rule_resource_id = optional(string, null) - event_hub_name = optional(string, null) - marketplace_partner_resource_id = optional(string, null) - })), {}) - dns_servers = optional(list(string)) - inherit_tags = optional(bool, true) - internal_dns_name_label = optional(string) - ip_forwarding_enabled = optional(bool, false) - is_primary = optional(bool, false) - lock_level = optional(string) - lock_name = optional(string) - network_security_groups = optional(map(object({ - network_security_group_resource_id = string - })), {}) - resource_group_name = optional(string) - role_assignments = optional(map(object({ - principal_id = string - role_definition_id_or_name = string - assign_to_child_public_ip_addresses = optional(bool, true) - condition = optional(string, null) - condition_version = optional(string, null) - delegated_managed_identity_resource_id = optional(string, null) - description = optional(string, null) - skip_service_principal_aad_check = optional(bool, false) - principal_type = optional(string, null) - })), {}) - tags = optional(map(string), null) - })) - description = <` - Use a custom map key to define each network interface - - `name` = (Required) The name of the Network Interface. Changing this forces a new resource to be created. - - `ip_configurations` - A required map of objects defining each interfaces IP configurations - - `` - Use a custom map key to define each ip configuration - - `name` = (Required) - A name used for this IP Configuration. - - `app_gateway_backend_pools` = (Optional) - A map defining app gateway backend pool(s) this IP configuration should be associated to. - - `` - Use a custom map key to define each app gateway backend pool association. This is done to handle issues with certain details not being known until after apply. - - `app_gateway_backend_pool_resource_id` = (Required) - An application gateway backend pool Azure Resource ID can be entered to join this ip configuration to the backend pool of an Application Gateway. - - `create_public_ip_address` = (Optional) - Select true here to have the module create the public IP address for this IP Configuration - - `gateway_load_balancer_frontend_ip_configuration_resource_id` = (Optional) - The Frontend IP Configuration Azure Resource ID of a Gateway SKU Load Balancer.) - - `is_primary_ipconfiguration` = (Optional) - Is this the Primary IP Configuration? Must be true for the first ip_configuration when multiple are specified. - - `load_balancer_backend_pools` = (Optional) - A map defining load balancer backend pool(s) this IP configuration should be associated to. - - `` - Use a custom map key to define each load balancer backend pool association. This is done to handle issues with certain details not being known until after apply. - - `load_balancer_backend_pool_resource_id` = (Required) - A Load Balancer backend pool Azure Resource ID can be entered to join this ip configuration to a load balancer backend pool. - - `load_balancer_nat_rules` = (Optional) - A map defining load balancer NAT rule(s) that this IP Configuration should be associated to. - - `` - Use a custom map key to define each load balancer NAT Rule association. This is done to handle issues with certain details not being known until after apply. - - `load_balancer_nat_rule_resource_id` = (Optional) - A Load Balancer Nat Rule Azure Resource ID can be entered to associate this ip configuration to a load balancer NAT rule. - - `private_ip_address` = (Optional) - The Static IP Address which should be used. Configured when private_ip_address_allocation is set to Static - - `private_ip_address_allocation` = (Optional) - The allocation method used for the Private IP Address. Possible values are Dynamic and Static. Dynamic means "An IP is automatically assigned during creation of this Network Interface" and is the default; Static means "User supplied IP address will be used" - - `private_ip_address_version` = (Optional) - The IP Version to use. Possible values are IPv4 or IPv6. Defaults to IPv4. - - `private_ip_subnet_resource_id` = (Optional) - The Azure Resource ID of the Subnet where this Network Interface should be located in. - - `public_ip_address_resource_id` = (Optional) - Reference to a Public IP Address resource ID to associate with this NIC - - `accelerated_networking_enabled` = (Optional) - Should Accelerated Networking be enabled? Defaults to false. Only certain Virtual Machine sizes are supported for Accelerated Networking. To use Accelerated Networking in an Availability Set, the Availability Set must be deployed onto an Accelerated Networking enabled cluster. - - `application_security_groups` = (Optional) - A map defining the Application Security Group(s) that this network interface should be a part of. - - `` - Use a custom map key to define each Application Security Group association. This is done to handle issues with certain details not being known until after apply. - - `application_security_group_resource_id` = (Required) - The Application Security Group (ASG) Azure Resource ID for this Network Interface to be associated to. - - `diagnostic_settings` = (Optional) - A map of objects defining the network interface resource diagnostic settings - - `` - Use a custom map key to define each diagnostic setting configuration - - `name` = (required) - Name to use for the Diagnostic setting configuration. Changing this creates a new resource - - `event_hub_authorization_rule_resource_id` = (Optional) - The Event Hub Namespace Authorization Rule Resource ID when sending logs or metrics to an Event Hub Namespace - - `event_hub_name` = (Optional) - The Event Hub name when sending logs or metrics to an Event Hub - - `log_analytics_destination_type` = (Optional) - Valid values are null, AzureDiagnostics, and Dedicated. Defaults to null - - `log_categories_and_groups` = (Optional) - List of strings used to define log categories and groups. Currently not valid for the VM resource - - `marketplace_partner_resource_id` = (Optional) - The marketplace partner solution Azure Resource ID when sending logs or metrics to a partner integration - - `metric_categories` = (Optional) - List of strings used to define metric categories. Currently only AllMetrics is valid - - `storage_account_resource_id` = (Optional) - The Storage Account Azure Resource ID when sending logs or metrics to a Storage Account - - `workspace_resource_id` = (Optional) - The Log Analytics Workspace Azure Resource ID when sending logs or metrics to a Log Analytics Workspace - - `dns_servers` = (Optional) - A list of IP Addresses defining the DNS Servers which should be used for this Network Interface. - - `inherit_tags` = (Optional) - Defaults to true. Set this to false if only the tags defined on this resource should be applied. This is potential future functionality and is currently ignored. - - `internal_dns_name_label` = (Optional) - The (relative) DNS Name used for internal communications between Virtual Machines in the same Virtual Network. - - `ip_forwarding_enabled` = (Optional) - Should IP Forwarding be enabled? Defaults to false - - `lock_level` = (Optional) - Set this value to override the resource level lock value. Possible values are `None`, `CanNotDelete`, and `ReadOnly`. - - `lock_name` = (Optional) - The name for the lock on this nic - - `network_security_groups` = (Optional) - A map describing Network Security Group(s) that this Network Interface should be associated to. - - `` - Use a custom map key to define each network security group association. This is done to handle issues with certain details not being known until after apply. - - `network_security_group_resource_id` = (Optional) - The Network Security Group (NSG) Azure Resource ID used to associate this Network Interface to the NSG. - - `resource_group_name` (Optional) - Specify a resource group name if the network interface should be created in a separate resource group from the virtual machine - - `role_assignments` = An optional map of objects defining role assignments on the individual network configuration resource - - `` - Use a custom map key to define each role assignment configuration - - `assign_to_child_public_ip_addresses` = (Optional) - Set this to true if the assignment should also apply to any children public IP addresses. - - `condition` = (Optional) - The condition that limits the resources that the role can be assigned to. Changing this forces a new resource to be created. - - `condition_version` = (Optional) - The version of the condition. Possible values are 1.0 or 2.0. Changing this forces a new resource to be created. - - `delegated_managed_identity_resource_id` = (Optional) - The delegated Azure Resource Id which contains a Managed Identity. Changing this forces a new resource to be created. - - `description` = (Optional) - The description for this Role Assignment. Changing this forces a new resource to be created. - - `principal_id` = (optional) - The ID of the Principal (User, Group or Service Principal) to assign the Role Definition to. Changing this forces a new resource to be created. - - `role_definition_id_or_name` = (Optional) - The Scoped-ID of the Role Definition or the built-in role name. Changing this forces a new resource to be created. Conflicts with role_definition_name - - `skip_service_principal_aad_check` = (Optional) - If the principal_id is a newly provisioned Service Principal set this value to true to skip the Azure Active Directory check which may fail due to replication lag. This argument is only valid if the principal_id is a Service Principal identity. Defaults to true. - - `principal_type` = (Optional) - The type of the `principal_id`. Possible values are `User`, `Group` and `ServicePrincipal`. It is necessary to explicitly set this attribute when creating role assignments if the principal creating the assignment is constrained by ABAC rules that filters on the PrincipalType attribute. - - `tags` = (Optional) - A mapping of tags to assign to the resource. - -Example Inputs: - -```hcl -#Simple private IP single NIC with IPV4 private address -network_interfaces = { - network_interface_1 = { - name = "testnic1" - ip_configurations = { - ip_configuration_1 = { - name = "testnic1-ipconfig1" - private_ip_subnet_resource_id = azurerm_subnet.this_subnet_1.id - } - } - } -} - -#Simple NIC with private and public IP address -network_interfaces = { - network_interface_1 = { - name = "testnic1" - ip_configurations = { - ip_configuration_1 = { - name = "testnic1-ipconfig1" - private_ip_subnet_resource_id = azurerm_subnet.this_subnet_1.id - create_public_ip_address = true - public_ip_address_name = "vm1-testnic1-publicip1" - } - } - } -} -``` -NETWORK_INTERFACES - nullable = false -} - variable "resource_group_name" { type = string description = "The resource group name of the resource group where the vm resources will be deployed." @@ -441,7 +280,15 @@ variable "custom_data" { description = "(Optional) The Base64 encoded Custom Data for building this virtual machine. Changing this forces a new resource to be created" validation { - condition = var.custom_data == null ? true : can(base64decode(var.custom_data)) + # Gzipped payloads (e.g. from cloudinit_config with gzip=true) are valid + # base64 but base64decode() rejects them because the decoded bytes are not + # valid UTF-8. Gzip streams always start with magic bytes 0x1f 0x8b 0x08, + # which base64-encode to the prefix "H4sI". We check for that prefix as a + # fallback to accept gzipped cloud-init data. + condition = var.custom_data == null ? true : ( + can(base64decode(var.custom_data)) || + startswith(var.custom_data, "H4sI") + ) error_message = "The `custom_data` must be either `null` or a valid Base64-Encoded string." } } @@ -929,6 +776,190 @@ variable "max_bid_price" { description = "(Optional) The maximum price you're willing to pay for this Virtual Machine, in US Dollars; which must be greater than the current spot price. If this bid price falls below the current spot price the Virtual Machine will be evicted using the `eviction_policy`. Defaults to `-1`, which means that the Virtual Machine should not be evicted for price reasons. This can only be configured when `priority` is set to `Spot`." } +variable "network_interfaces" { + type = map(object({ + name = string + ip_configurations = map(object({ + name = string + app_gateway_backend_pools = optional(map(object({ + app_gateway_backend_pool_resource_id = string + })), {}) + create_public_ip_address = optional(bool, false) + gateway_load_balancer_frontend_ip_configuration_resource_id = optional(string) + is_primary_ipconfiguration = optional(bool, true) + load_balancer_backend_pools = optional(map(object({ + load_balancer_backend_pool_resource_id = string + })), {}) + load_balancer_nat_rules = optional(map(object({ + load_balancer_nat_rule_resource_id = string + })), {}) + private_ip_address = optional(string) + private_ip_address_allocation = optional(string, "Dynamic") + private_ip_address_version = optional(string, "IPv4") + private_ip_subnet_resource_id = optional(string) + public_ip_address_lock_name = optional(string) + public_ip_address_name = optional(string) + public_ip_address_resource_id = optional(string) + })) + accelerated_networking_enabled = optional(bool, false) + application_security_groups = optional(map(object({ + application_security_group_resource_id = string + })), {}) + diagnostic_settings = optional(map(object({ + name = optional(string, null) + log_categories = optional(set(string), []) + log_groups = optional(set(string), []) + metric_categories = optional(set(string), ["AllMetrics"]) + log_analytics_destination_type = optional(string, null) + workspace_resource_id = optional(string, null) + storage_account_resource_id = optional(string, null) + event_hub_authorization_rule_resource_id = optional(string, null) + event_hub_name = optional(string, null) + marketplace_partner_resource_id = optional(string, null) + })), {}) + dns_servers = optional(list(string)) + inherit_tags = optional(bool, true) + internal_dns_name_label = optional(string) + ip_forwarding_enabled = optional(bool, false) + is_primary = optional(bool, false) + lock_level = optional(string) + lock_name = optional(string) + network_security_groups = optional(map(object({ + network_security_group_resource_id = string + })), {}) + resource_group_name = optional(string) + role_assignments = optional(map(object({ + principal_id = string + role_definition_id_or_name = string + assign_to_child_public_ip_addresses = optional(bool, true) + condition = optional(string, null) + condition_version = optional(string, null) + delegated_managed_identity_resource_id = optional(string, null) + description = optional(string, null) + skip_service_principal_aad_check = optional(bool, false) + principal_type = optional(string, null) + })), {}) + tags = optional(map(string), null) + })) + default = { + ipconfig_1 = { + name = "default-ipv4-ipconfig" + ip_configurations = { + ip_config_1 = { + name = "ipv4-ipconfig" + private_ip_address = null + private_ip_address_version = "IPv4" + private_ip_address_allocation = "Dynamic" + private_ip_subnet_resource_id = null + public_ip_address_resource_id = null + is_primary_ipconfiguration = true + gateway_load_balancer_frontend_ip_configuration_resource_id = null + } + } + dns_servers = null + accelerated_networking_enabled = true + ip_forwarding_enabled = false + internal_dns_name_label = null + tags = null + } } + description = <` - Use a custom map key to define each network interface + - `name` = (Required) The name of the Network Interface. Changing this forces a new resource to be created. + - `ip_configurations` - A required map of objects defining each interfaces IP configurations + - `` - Use a custom map key to define each ip configuration + - `name` = (Required) - A name used for this IP Configuration. + - `app_gateway_backend_pools` = (Optional) - A map defining app gateway backend pool(s) this IP configuration should be associated to. + - `` - Use a custom map key to define each app gateway backend pool association. This is done to handle issues with certain details not being known until after apply. + - `app_gateway_backend_pool_resource_id` = (Required) - An application gateway backend pool Azure Resource ID can be entered to join this ip configuration to the backend pool of an Application Gateway. + - `create_public_ip_address` = (Optional) - Select true here to have the module create the public IP address for this IP Configuration + - `gateway_load_balancer_frontend_ip_configuration_resource_id` = (Optional) - The Frontend IP Configuration Azure Resource ID of a Gateway SKU Load Balancer.) + - `is_primary_ipconfiguration` = (Optional) - Is this the Primary IP Configuration? Must be true for the first ip_configuration when multiple are specified. + - `load_balancer_backend_pools` = (Optional) - A map defining load balancer backend pool(s) this IP configuration should be associated to. + - `` - Use a custom map key to define each load balancer backend pool association. This is done to handle issues with certain details not being known until after apply. + - `load_balancer_backend_pool_resource_id` = (Required) - A Load Balancer backend pool Azure Resource ID can be entered to join this ip configuration to a load balancer backend pool. + - `load_balancer_nat_rules` = (Optional) - A map defining load balancer NAT rule(s) that this IP Configuration should be associated to. + - `` - Use a custom map key to define each load balancer NAT Rule association. This is done to handle issues with certain details not being known until after apply. + - `load_balancer_nat_rule_resource_id` = (Optional) - A Load Balancer Nat Rule Azure Resource ID can be entered to associate this ip configuration to a load balancer NAT rule. + - `private_ip_address` = (Optional) - The Static IP Address which should be used. Configured when private_ip_address_allocation is set to Static + - `private_ip_address_allocation` = (Optional) - The allocation method used for the Private IP Address. Possible values are Dynamic and Static. Dynamic means "An IP is automatically assigned during creation of this Network Interface" and is the default; Static means "User supplied IP address will be used" + - `private_ip_address_version` = (Optional) - The IP Version to use. Possible values are IPv4 or IPv6. Defaults to IPv4. + - `private_ip_subnet_resource_id` = (Optional) - The Azure Resource ID of the Subnet where this Network Interface should be located in. + - `public_ip_address_resource_id` = (Optional) - Reference to a Public IP Address resource ID to associate with this NIC + - `accelerated_networking_enabled` = (Optional) - Should Accelerated Networking be enabled? Defaults to false. Only certain Virtual Machine sizes are supported for Accelerated Networking. To use Accelerated Networking in an Availability Set, the Availability Set must be deployed onto an Accelerated Networking enabled cluster. + - `application_security_groups` = (Optional) - A map defining the Application Security Group(s) that this network interface should be a part of. + - `` - Use a custom map key to define each Application Security Group association. This is done to handle issues with certain details not being known until after apply. + - `application_security_group_resource_id` = (Required) - The Application Security Group (ASG) Azure Resource ID for this Network Interface to be associated to. + - `diagnostic_settings` = (Optional) - A map of objects defining the network interface resource diagnostic settings + - `` - Use a custom map key to define each diagnostic setting configuration + - `name` = (required) - Name to use for the Diagnostic setting configuration. Changing this creates a new resource + - `event_hub_authorization_rule_resource_id` = (Optional) - The Event Hub Namespace Authorization Rule Resource ID when sending logs or metrics to an Event Hub Namespace + - `event_hub_name` = (Optional) - The Event Hub name when sending logs or metrics to an Event Hub + - `log_analytics_destination_type` = (Optional) - Valid values are null, AzureDiagnostics, and Dedicated. Defaults to null + - `log_categories_and_groups` = (Optional) - List of strings used to define log categories and groups. Currently not valid for the VM resource + - `marketplace_partner_resource_id` = (Optional) - The marketplace partner solution Azure Resource ID when sending logs or metrics to a partner integration + - `metric_categories` = (Optional) - List of strings used to define metric categories. Currently only AllMetrics is valid + - `storage_account_resource_id` = (Optional) - The Storage Account Azure Resource ID when sending logs or metrics to a Storage Account + - `workspace_resource_id` = (Optional) - The Log Analytics Workspace Azure Resource ID when sending logs or metrics to a Log Analytics Workspace + - `dns_servers` = (Optional) - A list of IP Addresses defining the DNS Servers which should be used for this Network Interface. + - `inherit_tags` = (Optional) - Defaults to true. Set this to false if only the tags defined on this resource should be applied. This is potential future functionality and is currently ignored. + - `internal_dns_name_label` = (Optional) - The (relative) DNS Name used for internal communications between Virtual Machines in the same Virtual Network. + - `ip_forwarding_enabled` = (Optional) - Should IP Forwarding be enabled? Defaults to false + - `lock_level` = (Optional) - Set this value to override the resource level lock value. Possible values are `None`, `CanNotDelete`, and `ReadOnly`. + - `lock_name` = (Optional) - The name for the lock on this nic + - `network_security_groups` = (Optional) - A map describing Network Security Group(s) that this Network Interface should be associated to. + - `` - Use a custom map key to define each network security group association. This is done to handle issues with certain details not being known until after apply. + - `network_security_group_resource_id` = (Optional) - The Network Security Group (NSG) Azure Resource ID used to associate this Network Interface to the NSG. + - `resource_group_name` (Optional) - Specify a resource group name if the network interface should be created in a separate resource group from the virtual machine + - `role_assignments` = An optional map of objects defining role assignments on the individual network configuration resource + - `` - Use a custom map key to define each role assignment configuration + - `assign_to_child_public_ip_addresses` = (Optional) - Set this to true if the assignment should also apply to any children public IP addresses. + - `condition` = (Optional) - The condition that limits the resources that the role can be assigned to. Changing this forces a new resource to be created. + - `condition_version` = (Optional) - The version of the condition. Possible values are 1.0 or 2.0. Changing this forces a new resource to be created. + - `delegated_managed_identity_resource_id` = (Optional) - The delegated Azure Resource Id which contains a Managed Identity. Changing this forces a new resource to be created. + - `description` = (Optional) - The description for this Role Assignment. Changing this forces a new resource to be created. + - `principal_id` = (optional) - The ID of the Principal (User, Group or Service Principal) to assign the Role Definition to. Changing this forces a new resource to be created. + - `role_definition_id_or_name` = (Optional) - The Scoped-ID of the Role Definition or the built-in role name. Changing this forces a new resource to be created. Conflicts with role_definition_name + - `skip_service_principal_aad_check` = (Optional) - If the principal_id is a newly provisioned Service Principal set this value to true to skip the Azure Active Directory check which may fail due to replication lag. This argument is only valid if the principal_id is a Service Principal identity. Defaults to true. + - `principal_type` = (Optional) - The type of the `principal_id`. Possible values are `User`, `Group` and `ServicePrincipal`. It is necessary to explicitly set this attribute when creating role assignments if the principal creating the assignment is constrained by ABAC rules that filters on the PrincipalType attribute. + - `tags` = (Optional) - A mapping of tags to assign to the resource. + +Example Inputs: + +```hcl +#Simple private IP single NIC with IPV4 private address +network_interfaces = { + network_interface_1 = { + name = "testnic1" + ip_configurations = { + ip_configuration_1 = { + name = "testnic1-ipconfig1" + private_ip_subnet_resource_id = azurerm_subnet.this_subnet_1.id + } + } + } +} + +#Simple NIC with private and public IP address +network_interfaces = { + network_interface_1 = { + name = "testnic1" + ip_configurations = { + ip_configuration_1 = { + name = "testnic1-ipconfig1" + private_ip_subnet_resource_id = azurerm_subnet.this_subnet_1.id + create_public_ip_address = true + public_ip_address_name = "vm1-testnic1-publicip1" + } + } + } +} +``` +NETWORK_INTERFACES + nullable = false +} + variable "os_disk" { type = object({ caching = string @@ -985,6 +1016,58 @@ OS_DISK nullable = false } +variable "os_disk_attach_mode" { + type = bool + default = false + description = <<-DESCRIPTION + (Optional) Set to `true` when using `os_managed_disk_id` to attach an existing managed disk as the OS disk (Attach mode). + + This variable must be set explicitly because Terraform cannot determine `os_managed_disk_id != null` at plan time when the + disk ID comes from a computed resource attribute (e.g., `azurerm_managed_disk.example.id`). Setting this to `true` ensures + that credential generation, Key Vault secret creation, and other OS profile settings are correctly skipped during planning. + + > Note: Always set `os_disk_attach_mode = true` when setting `os_managed_disk_id`. + + Example Inputs: + + ```hcl + os_disk_attach_mode = true + os_managed_disk_id = azurerm_managed_disk.restored_os_disk.id + ``` + DESCRIPTION + nullable = false +} + +variable "os_managed_disk_id" { + type = string + default = null + description = <<-DESCRIPTION + (Optional) The ID of an existing Managed Disk which should be attached as the OS Disk of this Virtual Machine. Changing this forces a new resource to be created. + + When set, `source_image_resource_id` and `source_image_reference` must not be used, and the module will not manage OS profile settings + (admin credentials, computer name, custom data, patching configuration, etc.) since the OS is pre-configured on the existing disk. + + > Note: This is mutually exclusive with `source_image_resource_id` and `source_image_reference`. Only one source for the OS disk can be specified. + > Note: Always set `os_disk_attach_mode = true` when using this variable. + + Example Inputs: + + ```hcl + os_disk_attach_mode = true + os_managed_disk_id = "/subscriptions/{subscription_id}/resourceGroups/{rg_name}/providers/Microsoft.Compute/disks/{disk_name}" + ``` + DESCRIPTION + + validation { + condition = var.os_managed_disk_id == null || can(regex("^/subscriptions/[^/]+/resourceGroups/[^/]+/providers/Microsoft.Compute/disks/[^/]+$", var.os_managed_disk_id)) + error_message = "The os_managed_disk_id must be a valid Azure Managed Disk resource ID (e.g., /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Compute/disks/{name})." + } + validation { + condition = var.os_managed_disk_id == null || var.source_image_resource_id == null + error_message = "The os_managed_disk_id and source_image_resource_id are mutually exclusive. Only one source for the OS disk can be specified." + } +} + variable "os_type" { type = string default = "Windows" @@ -1428,7 +1511,7 @@ variable "source_image_reference" { version = "latest" } description = <