diff --git a/artifact-definitions/azure-function-app/massdriver.yaml b/artifact-definitions/azure-function-app/massdriver.yaml new file mode 100644 index 0000000..5807ff0 --- /dev/null +++ b/artifact-definitions/azure-function-app/massdriver.yaml @@ -0,0 +1,53 @@ +name: azure-function-app +label: Azure Function App +icon: https://raw.githubusercontent.com/massdriver-cloud/icons/main/public/azure/function-apps.svg + +schema: + title: Azure Function App + description: Azure Function App with hosting plan and supporting infrastructure + type: object + required: + - id + - name + - resource_group_name + - location + - default_hostname + properties: + id: + title: ID + description: Azure resource ID of the Function App + type: string + name: + title: Name + description: Name of the Function App + type: string + resource_group_name: + title: Resource Group Name + description: Azure resource group containing the Function App + type: string + location: + title: Location + description: Azure region where the Function App is deployed + type: string + default_hostname: + title: Default Hostname + description: Default HTTPS hostname of the Function App + type: string + storage_account_name: + title: Storage Account Name + description: Name of the storage account backing the Function App + type: string + app_insights_instrumentation_key: + title: App Insights Instrumentation Key + description: Application Insights instrumentation key for telemetry + type: string + $md.sensitive: true + app_insights_connection_string: + title: App Insights Connection String + description: Application Insights connection string (preferred over instrumentation key) + type: string + $md.sensitive: true + principal_id: + title: Managed Identity Principal ID + description: Object ID of the system-assigned managed identity — use this to grant the Function App access to other Azure resources (Key Vault, Storage, SQL, etc.) via IAM role assignments + type: string diff --git a/artifact-definitions/azure-virtual-network/massdriver.yaml b/artifact-definitions/azure-virtual-network/massdriver.yaml new file mode 100644 index 0000000..232077c --- /dev/null +++ b/artifact-definitions/azure-virtual-network/massdriver.yaml @@ -0,0 +1,57 @@ +name: azure-virtual-network +label: Azure Virtual Network +icon: https://raw.githubusercontent.com/massdriver-cloud/icons/main/public/azure/virtual-networks.svg + +ui: + environmentDefaultGroup: networks + +schema: + title: Azure Virtual Network + description: Azure Virtual Network with subnet configuration + type: object + required: + - id + - resource_group_name + - location + - cidr + - subnets + properties: + id: + title: ID + description: Azure resource ID of the virtual network + type: string + resource_group_name: + title: Resource Group Name + description: Azure resource group containing the virtual network + type: string + location: + title: Location + description: Azure region where the virtual network is deployed + type: string + cidr: + title: CIDR + description: Address space in CIDR notation + type: string + subnets: + title: Subnets + description: Subnet allocations within the virtual network + type: array + items: + type: object + required: + - id + - name + - cidr + properties: + id: + title: ID + description: Azure resource ID of the subnet + type: string + name: + title: Name + description: Subnet name + type: string + cidr: + title: CIDR + description: Subnet address range + type: string diff --git a/artifact-definitions/azure-virtual-wan/massdriver.yaml b/artifact-definitions/azure-virtual-wan/massdriver.yaml new file mode 100644 index 0000000..ee9b273 --- /dev/null +++ b/artifact-definitions/azure-virtual-wan/massdriver.yaml @@ -0,0 +1,39 @@ +name: azure-virtual-wan +label: Azure Virtual WAN +icon: https://raw.githubusercontent.com/massdriver-cloud/icons/main/public/azure/virtual-wans.svg + +schema: + title: Azure Virtual WAN + description: Azure Virtual WAN with virtual hub configuration + type: object + required: + - id + - resource_group_name + - location + - virtual_hub_id + - virtual_hub_address_prefix + properties: + id: + title: ID + description: Azure resource ID of the Virtual WAN + type: string + resource_group_name: + title: Resource Group Name + description: Azure resource group containing the Virtual WAN + type: string + location: + title: Location + description: Azure region where the Virtual WAN is deployed + type: string + virtual_hub_id: + title: Virtual Hub ID + description: Azure resource ID of the Virtual Hub + type: string + virtual_hub_address_prefix: + title: Virtual Hub Address Prefix + description: Address prefix of the Virtual Hub + type: string + vpn_gateway_id: + title: VPN Gateway ID + description: Azure resource ID of the VPN Gateway (if provisioned) + type: string diff --git a/bundles/azure-function-app/icon.svg b/bundles/azure-function-app/icon.svg new file mode 100644 index 0000000..3dc1ddf --- /dev/null +++ b/bundles/azure-function-app/icon.svg @@ -0,0 +1 @@ +Icon-compute-29 diff --git a/bundles/azure-function-app/massdriver.yaml b/bundles/azure-function-app/massdriver.yaml new file mode 100644 index 0000000..a5f1a66 --- /dev/null +++ b/bundles/azure-function-app/massdriver.yaml @@ -0,0 +1,233 @@ +name: azure-function-app +description: "Azure Function App with embedded App Service Plan, Storage Account, Application Insights, private endpoints, and backup via Recovery Services Vault." +source_url: https://github.com/massdriver-cloud/massdriver-catalog/tree/main/bundles/azure-function-app +version: 0.4.1 + +params: + required: + - location + - runtime + - sku + properties: + location: + type: string + title: Azure Region + description: Azure region where the Function App will be deployed. + $md.immutable: true + enum: + - eastus + - eastus2 + - westus + - westus2 + - westus3 + - centralus + - northcentralus + - southcentralus + - westcentralus + - northeurope + - westeurope + - uksouth + - ukwest + - australiaeast + - australiasoutheast + - southeastasia + - eastasia + - japaneast + - japanwest + - southindia + - centralindia + - westindia + - canadacentral + - canadaeast + - brazilsouth + runtime: + type: object + title: Runtime + description: Language runtime and version for the Function App. + required: + - name + - version + properties: + name: + type: string + title: Language + $md.immutable: true + enum: + - dotnet-isolated + - node + - python + - java + default: python + version: + type: string + title: Version + description: Runtime version (e.g. 3.11 for Python, 18 for Node). + default: "3.11" + sku: + type: object + title: Hosting Plan + description: App Service Plan SKU controlling scale and capabilities. + required: + - tier + - size + properties: + tier: + type: string + title: Tier + enum: + - ElasticPremium + - PremiumV3 + - Standard + default: ElasticPremium + size: + type: string + title: Size + description: "ElasticPremium: EP1/EP2/EP3. PremiumV3: P1v3/P2v3/P3v3. Standard: S1/S2/S3." + enum: + - EP1 + - EP2 + - EP3 + - P1v3 + - P2v3 + - P3v3 + - S1 + - S2 + - S3 + default: EP1 + storage: + type: object + title: Storage Account + description: Configuration for the backing storage account. + properties: + replication_type: + type: string + title: Replication Type + description: "LRS: locally redundant. ZRS: zone redundant. GRS: geo-redundant." + enum: + - LRS + - ZRS + - GRS + - RAGRS + default: ZRS + enable_https_only: + type: boolean + title: HTTPS Only + description: Enforce HTTPS-only access to the storage account. + default: true + private_endpoints: + type: object + title: Private Endpoints + description: Deploy private endpoints to lock down Function App and Storage from public internet. + properties: + enable_function_app: + type: boolean + title: Function App Private Endpoint + description: Place the Function App behind a private endpoint on the connected VNet. + default: false + enable_storage: + type: boolean + title: Storage Account Private Endpoint + description: Place the Storage Account behind private endpoints (blob + file + queue + table). + default: false + subnet_name: + type: string + title: Subnet + description: Subnet from the connected VNet to use for private endpoints. + default: "private-endpoints" + $md.enum: + connection: azure_virtual_network + options: .subnets + value: .name + label: '"\(.name) — \(.cidr)"' + backup: + type: object + title: Backup + description: Recovery Services Vault backup for the Function App (filesystem snapshots). + properties: + enable: + type: boolean + title: Enable Backup + description: Create a Recovery Services Vault and configure daily backup for the Function App. + default: false + retention_days: + type: integer + title: Retention Days + description: Number of days to retain daily backups (7–730). + default: 30 + minimum: 7 + maximum: 730 + + examples: + - __name: Development + location: eastus + runtime: + name: python + version: "3.11" + sku: + tier: ElasticPremium + size: EP1 + storage: + replication_type: ZRS + enable_https_only: true + private_endpoints: + enable_function_app: false + enable_storage: false + subnet_name: "private-endpoints" + backup: + enable: false + retention_days: 30 + - __name: Production + location: eastus + runtime: + name: python + version: "3.11" + sku: + tier: ElasticPremium + size: EP2 + storage: + replication_type: ZRS + enable_https_only: true + private_endpoints: + enable_function_app: true + enable_storage: true + subnet_name: "private-endpoints" + backup: + enable: true + retention_days: 90 + +connections: + required: + - azure_service_principal + - azure_virtual_network + properties: + azure_service_principal: + $ref: acd/azure-service-principal + title: Azure Service Principal + azure_virtual_network: + $ref: acd/azure-virtual-network + title: Azure Virtual Network + +artifacts: + required: + - azure_function_app + properties: + azure_function_app: + $ref: acd/azure-function-app + title: Azure Function App + +steps: + - path: src + provisioner: opentofu:1.10 + config: + checkov: + halt_on_failure: '.params.md_metadata.default_tags["md-target"] | test("prd|prod|production")' + +ui: + ui:order: + - location + - runtime + - sku + - storage + - private_endpoints + - backup + - "*" diff --git a/bundles/azure-function-app/operator.md b/bundles/azure-function-app/operator.md new file mode 100644 index 0000000..b390ec4 --- /dev/null +++ b/bundles/azure-function-app/operator.md @@ -0,0 +1,101 @@ +# Azure Function App — Operator Guide + +## Overview + +This bundle provisions an Azure Function App with all supporting infrastructure: App Service Plan, Storage Account, Application Insights, optional Private Endpoints, and optional Recovery Services Vault backup. It outputs an `acd/azure-function-app` artifact with the hostname, managed identity, and telemetry connection details downstream bundles and CI/CD pipelines need. + +## Resources Provisioned + +| Resource | Purpose | +|---|---| +| Resource Group | Logical container | +| Storage Account | Functions runtime state (host keys, triggers, file shares) | +| Application Insights | Telemetry, tracing, and live metrics | +| App Service Plan | Hosting plan (ElasticPremium, PremiumV3, or Standard) | +| Linux Function App | Serverless compute with managed identity | +| Private Endpoints | Optional — lock down Function App and Storage from public internet | +| Recovery Services Vault | Optional — daily file share backup with configurable retention | + +## Deploying Source Code + +This bundle provisions **infrastructure only**. Function code is deployed separately via CI/CD. The bundle sets `WEBSITE_RUN_FROM_PACKAGE=1`, which means code is deployed as a zip package. + +### Deployment Methods + +**Azure Functions Core Tools** (local dev / simple CI): +```bash +func azure functionapp publish +``` + +**GitHub Actions**: +```yaml +- uses: Azure/functions-action@v1 + with: + app-name: ${{ steps.artifact.outputs.name }} + package: ./output +``` + +**Azure DevOps**: +```yaml +- task: AzureFunctionApp@2 + inputs: + azureSubscription: '' + appType: 'functionAppLinux' + appName: '' + package: '$(Pipeline.Workspace)/drop' +``` + +**Zip deploy via CLI**: +```bash +az functionapp deployment source config-zip \ + --resource-group \ + --name \ + --src ./package.zip +``` + +The artifact exports `name` and `resource_group_name` for use in CI/CD pipelines. The `principal_id` (managed identity) can be used to grant the Function App access to other Azure resources without storing credentials. + +## Hosting Plan Selection + +| Tier | SKU | Best For | VNet Integration | Min Instances | +|---|---|---|---|---| +| **ElasticPremium** | EP1/EP2/EP3 | Production serverless with auto-scale | Yes | 1 (elastic) | +| **PremiumV3** | P1v3/P2v3/P3v3 | Dedicated compute, predictable billing | Yes | Per plan | +| **Standard** | S1/S2/S3 | Budget, low-traffic workloads | Limited | Per plan | + +ElasticPremium is recommended for production — it supports VNet integration, private endpoints, and scales to zero warm instances while keeping at least one pre-warmed. + +## Private Endpoints + +When enabled, Private Endpoints place the Function App and/or Storage Account on a private subnet in the connected VNet. This: + +- Removes public internet access to the resource +- Routes traffic through the VNet's private address space +- Requires a subnet with available IP addresses (the bundle uses `$md.enum` to let you pick from connected VNet subnets) + +**Important**: The Storage Account private endpoint creates endpoints for blob, file, queue, and table sub-resources. DNS resolution must be configured (Azure Private DNS Zones) for the Function App to reach its storage over the private network. + +## Compliance Notes + +- **Runtime language is immutable** — changing from Python to Node (or vice versa) after deployment is destructive +- **Managed identity** is always enabled (CKV_AZURE_71) — use it for Key Vault, Storage, and SQL access instead of connection strings +- **HTTPS only** is enforced (CKV_AZURE_221) +- **TLS 1.2 minimum** (CKV_AZURE_155) +- **FTP disabled** — use zip deploy or Kudu only +- **Checkov skips** are documented in `.checkov.yml` with rationale for each + +## Artifact Data + +The `acd/azure-function-app` artifact exports: + +| Field | Sensitive | Description | +|---|---|---| +| `id` | No | Function App resource ID | +| `name` | No | Function App name (use in CI/CD deploy commands) | +| `resource_group_name` | No | Resource group (use in CI/CD deploy commands) | +| `location` | No | Azure region | +| `default_hostname` | No | HTTPS endpoint (e.g., `my-fn.azurewebsites.net`) | +| `storage_account_name` | No | Backing storage account name | +| `app_insights_instrumentation_key` | Yes | App Insights key (legacy — prefer connection string) | +| `app_insights_connection_string` | Yes | App Insights connection string | +| `principal_id` | No | Managed identity object ID — use for IAM role assignments | diff --git a/bundles/azure-function-app/src/.checkov.yml b/bundles/azure-function-app/src/.checkov.yml new file mode 100644 index 0000000..32af5cc --- /dev/null +++ b/bundles/azure-function-app/src/.checkov.yml @@ -0,0 +1,56 @@ +skip-check: + # CKV_AZURE_59: Public network access to the storage account is required when + # private endpoints are not enabled, because the Azure Functions service plane + # must reach the storage account to create the file share at provisioning time. + # When private endpoints are enabled (production), public_network_access_enabled + # is set to false in Terraform. The network firewall (default_action = "Deny" + + # AzureServices bypass) restricts access to trusted Azure services in both cases. + - CKV_AZURE_59 + + # CKV_AZURE_35: The storage network default action must be "Allow" when no + # private endpoint or VNet service endpoint is configured, because the Azure + # Functions provisioning plane cannot create the required file share through + # the AzureServices bypass alone without subnet-level access. When private + # endpoints are enabled, the network default action is set to "Deny" in Terraform. + - CKV_AZURE_35 + + # CKV2_AZURE_40: Azure Functions runtime requires Shared Key access to its backing + # storage account to store internal state (host keys, blob triggers, etc.). + # Disabling shared key breaks the Functions runtime entirely. + - CKV2_AZURE_40 + + # CKV2_AZURE_41: SAS expiration policy is only enforceable when SAS tokens are + # actively issued. The Functions runtime uses connection strings, not SAS tokens, + # so this policy has no effect on the actual access pattern. + - CKV2_AZURE_41 + + # CKV2_AZURE_1: Customer-Managed Key encryption for storage requires a pre-existing + # Azure Key Vault and a user-assigned managed identity — both are external + # dependencies outside the scope of this bundle. CMK can be added post-deployment + # by operators who require it. + - CKV2_AZURE_1 + + # CKV_AZURE_212: Minimum instance count (worker_count) is auto-managed by the + # Azure Functions ElasticPremium (EP) plan and cannot be set statically via + # azurerm_service_plan. The EP plan scales from 1 to N based on demand. + - CKV_AZURE_212 + + # CKV_AZURE_221: Public network access must remain enabled when no private + # endpoint is configured for the Function App — disabling it makes the function + # completely unreachable. When private endpoints are enabled (production), + # public_network_access_enabled is set to false in Terraform. + - CKV_AZURE_221 + + # CKV_AZURE_225: Zone balancing (zone_balancing_enabled) is not supported on + # ElasticPremium Linux plans — it is only available for PremiumV2/PremiumV3 + # Windows plans. Enabling it on EP Linux causes a deployment error. + - CKV_AZURE_225 + + # CKV_AZURE_206: This check requires geo-redundant replication (GRS/GZRS). + # The backing storage account for Azure Functions stores host keys and internal + # runtime state — it is an operational dependency, not a primary data store. + # Geo-redundant replication for this account adds cost without providing useful + # DR benefit unless the entire application is architected for geo-failover. + # For geo-DR deployments, operators should configure GZRS and deploy a paired + # regional failover manually. ZRS (zone redundant) is enforced by default. + - CKV_AZURE_206 diff --git a/bundles/azure-function-app/src/_massdriver_variables.tf b/bundles/azure-function-app/src/_massdriver_variables.tf new file mode 100644 index 0000000..0329b66 --- /dev/null +++ b/bundles/azure-function-app/src/_massdriver_variables.tf @@ -0,0 +1,82 @@ +// This file is auto-generated by massdriver from your massdriver.yaml file. +// Any changes made directly to this file will be overwritten on the next build. +// To opt a variable out of regeneration, move it to another file (e.g. variables.tf). +variable "azure_service_principal" { + type = object({ + client_id = string + client_secret = string + subscription_id = string + tenant_id = string + }) +} +variable "azure_virtual_network" { + type = object({ + cidr = string + id = string + location = string + resource_group_name = string + subnets = list(object({ + cidr = string + id = string + name = string + })) + }) +} +variable "backup" { + type = object({ + enable = optional(bool) + retention_days = optional(number) + }) + default = null +} +variable "location" { + type = string +} +variable "md_metadata" { + type = object({ + default_tags = map(string) + deployment = object({ + id = string + }) + name_prefix = string + observability = object({ + alarm_webhook_url = string + }) + package = object({ + created_at = string + deployment_enqueued_at = string + previous_status = string + updated_at = string + }) + target = object({ + contact_email = string + }) + }) +} +variable "private_endpoints" { + type = object({ + enable_function_app = optional(bool) + enable_storage = optional(bool) + subnet_name = optional(string) + }) + default = null +} +variable "runtime" { + type = object({ + name = string + version = string + }) +} +variable "sku" { + type = object({ + size = string + tier = string + }) +} +variable "storage" { + type = object({ + enable_https_only = optional(bool) + replication_type = optional(string) + }) + default = null +} diff --git a/bundles/azure-function-app/src/artifacts.tf b/bundles/azure-function-app/src/artifacts.tf new file mode 100644 index 0000000..18ce271 --- /dev/null +++ b/bundles/azure-function-app/src/artifacts.tf @@ -0,0 +1,15 @@ +resource "massdriver_artifact" "azure_function_app" { + field = "azure_function_app" + name = "Azure Function App ${var.md_metadata.name_prefix}" + artifact = jsonencode({ + id = azurerm_linux_function_app.main.id + name = azurerm_linux_function_app.main.name + resource_group_name = azurerm_resource_group.main.name + location = azurerm_linux_function_app.main.location + default_hostname = azurerm_linux_function_app.main.default_hostname + storage_account_name = azurerm_storage_account.main.name + app_insights_instrumentation_key = azurerm_application_insights.main.instrumentation_key + app_insights_connection_string = azurerm_application_insights.main.connection_string + principal_id = azurerm_linux_function_app.main.identity[0].principal_id + }) +} diff --git a/bundles/azure-function-app/src/main.tf b/bundles/azure-function-app/src/main.tf new file mode 100644 index 0000000..50e5a0a --- /dev/null +++ b/bundles/azure-function-app/src/main.tf @@ -0,0 +1,309 @@ +terraform { + required_version = ">= 1.0" + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 3.0" + } + massdriver = { + source = "massdriver-cloud/massdriver" + version = "~> 1.3" + } + random = { + source = "hashicorp/random" + version = "~> 3.0" + } + } +} + +provider "azurerm" { + features {} + + client_id = var.azure_service_principal.client_id + client_secret = var.azure_service_principal.client_secret + tenant_id = var.azure_service_principal.tenant_id + subscription_id = var.azure_service_principal.subscription_id +} + +locals { + name_prefix = var.md_metadata.name_prefix + + # Map subnet name → ID for private endpoint lookups + subnet_map = { + for s in var.azure_virtual_network.subnets : s.name => s.id + } + + # Private endpoint subnet ID (if subnet_name is provided and exists) + pe_subnet_id = try( + local.subnet_map[var.private_endpoints.subnet_name], + values(local.subnet_map)[0] + ) + + enable_pe_func = try(var.private_endpoints.enable_function_app, false) + enable_pe_storage = try(var.private_endpoints.enable_storage, false) + enable_backup = try(var.backup.enable, false) +} + +# ───────────────────────────────────────────── +# Resource Group +# ───────────────────────────────────────────── + +resource "azurerm_resource_group" "main" { + name = "${local.name_prefix}-rg" + location = var.location + tags = var.md_metadata.default_tags +} + +# ───────────────────────────────────────────── +# Storage Account (required by Azure Functions) +# CKV_AZURE_33: queue logging enabled +# CKV_AZURE_44: min TLS 1.2 enforced +# CKV_AZURE_59: public access disabled +# CKV3_AZURE_64: HTTPS only enforced +# CKV_AZURE_206: storage firewall — allow Azure services (functions need access) +# ───────────────────────────────────────────── + +resource "random_string" "storage_suffix" { + length = 6 + lower = true + numeric = true + upper = false + special = false +} + +resource "azurerm_storage_account" "main" { + # Storage account names: 3-24 chars, lowercase alphanumeric only. + # Truncate the stripped name_prefix to 16 chars to leave room for "fn" (2) + suffix (6) = 24. + name = "fn${substr(replace(local.name_prefix, "-", ""), 0, min(16, length(replace(local.name_prefix, "-", ""))))}${random_string.storage_suffix.result}" + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + + account_tier = "Standard" + account_replication_type = try(var.storage.replication_type, "ZRS") + account_kind = "StorageV2" + + # Security hardening + https_traffic_only_enabled = true + min_tls_version = "TLS1_2" + allow_nested_items_to_be_public = false + # CKV_AZURE_244: disable local users — managed identity is used instead + local_user_enabled = false + # CKV_AZURE_59: disable public network access only when private endpoints are + # enabled — the Functions runtime must reach the storage account to create file + # shares during provisioning. When PEs are disabled, the AzureServices bypass + # below provides the necessary access restriction. + public_network_access_enabled = local.enable_pe_storage ? false : true + # shared_access_key_enabled = true is required by the Azure Functions runtime + # to store its internal state in the storage account. CKV2_AZURE_40 is + # intentionally skipped in .checkov.yml for this reason. + shared_access_key_enabled = true + + # CKV_AZURE_35: deny by default when private endpoints are configured; the + # Functions service reaches storage via the PE subnet in that case. Without a + # PE and VNet subnet service endpoint, the Azure Functions provisioning plane + # cannot create the required file share even through the AzureServices bypass, + # so we allow public access in that configuration. See .checkov.yml for details. + network_rules { + default_action = local.enable_pe_storage ? "Deny" : "Allow" + bypass = ["AzureServices"] + ip_rules = [] + virtual_network_subnet_ids = [] + } + + blob_properties { + # CKV_AZURE_33: enable delete retention for blob data protection + delete_retention_policy { + days = 7 + } + container_delete_retention_policy { + days = 7 + } + } + + queue_properties { + logging { + delete = true + read = true + write = true + version = "1.0" + retention_policy_days = 7 + } + } + + tags = var.md_metadata.default_tags +} + +# ───────────────────────────────────────────── +# Application Insights +# ───────────────────────────────────────────── + +resource "azurerm_application_insights" "main" { + name = "${local.name_prefix}-ai" + location = azurerm_resource_group.main.location + resource_group_name = azurerm_resource_group.main.name + application_type = "web" + tags = var.md_metadata.default_tags + + # Disable local authentication — enforce Entra ID (CKV_AZURE_132) + local_authentication_disabled = true + + # Retain telemetry for 90 days by default + retention_in_days = 90 + + # The azurerm provider may set workspace_id automatically in some regions/versions. + # Ignore it after initial creation to avoid "workspace_id can not be removed" errors + # on subsequent runs when no Log Analytics workspace was explicitly configured. + lifecycle { + ignore_changes = [workspace_id] + } +} + +# ───────────────────────────────────────────── +# App Service Plan (hosting plan for the Function App) +# ───────────────────────────────────────────── + +resource "azurerm_service_plan" "main" { + name = "${local.name_prefix}-plan" + location = azurerm_resource_group.main.location + resource_group_name = azurerm_resource_group.main.name + os_type = "Linux" + sku_name = var.sku.size + tags = var.md_metadata.default_tags +} + +# ───────────────────────────────────────────── +# Function App +# CKV_AZURE_221: HTTPS only +# CKV_AZURE_71: managed identity enabled +# CKV_AZURE_17: auth not required (configurable via app settings post-deploy) +# ───────────────────────────────────────────── + +resource "azurerm_linux_function_app" "main" { + name = "${local.name_prefix}-fn" + location = azurerm_resource_group.main.location + resource_group_name = azurerm_resource_group.main.name + service_plan_id = azurerm_service_plan.main.id + + storage_account_name = azurerm_storage_account.main.name + storage_account_access_key = azurerm_storage_account.main.primary_access_key + + https_only = true + + # CKV_AZURE_221: disable public network access when a private endpoint is + # provisioned for the function app. Without a PE, setting this to false would + # make the function app completely inaccessible. CKV_AZURE_221 is skipped in + # .checkov.yml when PEs are not enabled. + public_network_access_enabled = local.enable_pe_func ? false : true + + # CKV_AZURE_71: system-assigned managed identity for Key Vault and other Azure services + identity { + type = "SystemAssigned" + } + + site_config { + application_stack { + python_version = var.runtime.name == "python" ? var.runtime.version : null + node_version = var.runtime.name == "node" ? "~${var.runtime.version}" : null + java_version = var.runtime.name == "java" ? var.runtime.version : null + dotnet_version = var.runtime.name == "dotnet-isolated" ? var.runtime.version : null + } + + # Always-on is not supported on Consumption/EP plans; EP plans handle it via scale + application_insights_key = azurerm_application_insights.main.instrumentation_key + application_insights_connection_string = azurerm_application_insights.main.connection_string + + # CKV_AZURE_155: minimum TLS 1.2 + minimum_tls_version = "1.2" + + # Disable FTP deployment — use CI/CD or Kudu only + ftps_state = "Disabled" + } + + app_settings = { + "FUNCTIONS_WORKER_RUNTIME" = var.runtime.name + "APPLICATIONINSIGHTS_CONNECTION_STRING" = azurerm_application_insights.main.connection_string + # Disable public SCM site (Kudu) network access when PE is enabled + "WEBSITE_RUN_FROM_PACKAGE" = "1" + } + + tags = var.md_metadata.default_tags +} + +# ───────────────────────────────────────────── +# Private Endpoints — Function App +# ───────────────────────────────────────────── + +resource "azurerm_private_endpoint" "function_app" { + count = local.enable_pe_func ? 1 : 0 + name = "${local.name_prefix}-fn-pe" + location = azurerm_resource_group.main.location + resource_group_name = azurerm_resource_group.main.name + subnet_id = local.pe_subnet_id + tags = var.md_metadata.default_tags + + private_service_connection { + name = "${local.name_prefix}-fn-psc" + private_connection_resource_id = azurerm_linux_function_app.main.id + subresource_names = ["sites"] + is_manual_connection = false + } +} + +# ───────────────────────────────────────────── +# Private Endpoints — Storage Account (blob, file, queue, table) +# ───────────────────────────────────────────── + +locals { + storage_subresources = local.enable_pe_storage ? ["blob", "file", "queue", "table"] : [] +} + +resource "azurerm_private_endpoint" "storage" { + for_each = toset(local.storage_subresources) + name = "${local.name_prefix}-st-${each.key}-pe" + location = azurerm_resource_group.main.location + resource_group_name = azurerm_resource_group.main.name + subnet_id = local.pe_subnet_id + tags = var.md_metadata.default_tags + + private_service_connection { + name = "${local.name_prefix}-st-${each.key}-psc" + private_connection_resource_id = azurerm_storage_account.main.id + subresource_names = [each.key] + is_manual_connection = false + } +} + +# ───────────────────────────────────────────── +# Recovery Services Vault + Backup +# CKV_AZURE_228: soft delete enabled (default) +# ───────────────────────────────────────────── + +resource "azurerm_recovery_services_vault" "main" { + count = local.enable_backup ? 1 : 0 + name = "${local.name_prefix}-rsv" + location = azurerm_resource_group.main.location + resource_group_name = azurerm_resource_group.main.name + sku = "Standard" + + # CKV_AZURE_228: soft delete protects backup data from accidental/malicious deletion + soft_delete_enabled = true + + immutability = "Unlocked" + tags = var.md_metadata.default_tags +} + +resource "azurerm_backup_policy_file_share" "main" { + count = local.enable_backup ? 1 : 0 + name = "${local.name_prefix}-backup-policy" + resource_group_name = azurerm_resource_group.main.name + recovery_vault_name = azurerm_recovery_services_vault.main[0].name + + backup { + frequency = "Daily" + time = "02:00" + } + + retention_daily { + count = try(var.backup.retention_days, 30) + } +} diff --git a/bundles/azure-virtual-network/icon.svg b/bundles/azure-virtual-network/icon.svg new file mode 100644 index 0000000..04cc23a --- /dev/null +++ b/bundles/azure-virtual-network/icon.svg @@ -0,0 +1 @@ +Icon-networking-61 diff --git a/bundles/azure-virtual-network/massdriver.yaml b/bundles/azure-virtual-network/massdriver.yaml new file mode 100644 index 0000000..c165bb3 --- /dev/null +++ b/bundles/azure-virtual-network/massdriver.yaml @@ -0,0 +1,172 @@ +name: azure-virtual-network +description: "Azure Virtual Network providing foundational networking with configurable address space, subnets, and DDoS protection." +source_url: https://github.com/massdriver-cloud/massdriver-catalog/tree/main/bundles/azure-virtual-network +version: 0.3.1 + +params: + required: + - location + - cidr + - subnets + properties: + location: + type: string + title: Azure Region + description: Azure region where the VNet will be deployed. + $md.immutable: true + enum: + - eastus + - eastus2 + - westus + - westus2 + - westus3 + - centralus + - northcentralus + - southcentralus + - westcentralus + - northeurope + - westeurope + - uksouth + - ukwest + - australiaeast + - australiasoutheast + - southeastasia + - eastasia + - japaneast + - japanwest + - southindia + - centralindia + - westindia + - canadacentral + - canadaeast + - brazilsouth + cidr: + type: string + title: Address Space + description: CIDR block for the Virtual Network address space. + $md.immutable: true + default: "10.0.0.0/16" + pattern: ^([0-9]{1,3}\.){3}[0-9]{1,3}\/[0-9]{1,2}$ + subnets: + type: array + title: Subnets + description: Subnet definitions within the VNet. + minItems: 1 + default: + - name: "default" + cidr: "10.0.1.0/24" + service_endpoints: [] + items: + type: object + required: + - name + - cidr + properties: + name: + type: string + title: Name + description: Subnet name (alphanumeric and hyphens only). + pattern: ^[a-zA-Z0-9-]+$ + cidr: + type: string + title: CIDR + description: Subnet address range (must be within VNet CIDR). + pattern: ^([0-9]{1,3}\.){3}[0-9]{1,3}\/[0-9]{1,2}$ + service_endpoints: + type: array + title: Service Endpoints + description: Azure service endpoints to enable on this subnet. + default: [] + items: + type: string + enum: + - Microsoft.Storage + - Microsoft.Sql + - Microsoft.KeyVault + - Microsoft.ServiceBus + - Microsoft.EventHub + - Microsoft.AzureActiveDirectory + - Microsoft.Web + - Microsoft.ContainerRegistry + - Microsoft.CognitiveServices + enable_ddos_protection: + type: boolean + title: Enable DDoS Protection + description: Enable Azure DDoS Network Protection (Standard plan — additional cost applies). + default: false + dns_servers: + type: array + title: Custom DNS Servers + description: Custom DNS server IP addresses. Leave empty to use Azure-provided DNS. + default: [] + items: + type: string + pattern: ^([0-9]{1,3}\.){3}[0-9]{1,3}$ + + examples: + - __name: Development + location: eastus + cidr: "10.0.0.0/16" + enable_ddos_protection: false + dns_servers: [] + subnets: + - name: "default" + cidr: "10.0.1.0/24" + service_endpoints: [] + - __name: Production + location: eastus + cidr: "10.0.0.0/14" + enable_ddos_protection: true + dns_servers: [] + subnets: + - name: "app" + cidr: "10.0.0.0/24" + service_endpoints: + - Microsoft.KeyVault + - Microsoft.Storage + - name: "data" + cidr: "10.0.1.0/24" + service_endpoints: + - Microsoft.Sql + - Microsoft.Storage + - name: "private-endpoints" + cidr: "10.0.2.0/24" + service_endpoints: [] + - name: "management" + cidr: "10.0.3.0/24" + service_endpoints: [] + +connections: + required: + - azure_service_principal + properties: + azure_service_principal: + $ref: acd/azure-service-principal + title: Azure Service Principal + azure_virtual_wan: + $ref: acd/azure-virtual-wan + title: Azure Virtual WAN + +artifacts: + required: + - azure_virtual_network + properties: + azure_virtual_network: + $ref: acd/azure-virtual-network + title: Azure Virtual Network + +steps: + - path: src + provisioner: opentofu:1.10 + config: + checkov: + halt_on_failure: '.params.md_metadata.default_tags["md-target"] | test("prd|prod|production")' + +ui: + ui:order: + - location + - cidr + - subnets + - enable_ddos_protection + - dns_servers + - "*" diff --git a/bundles/azure-virtual-network/operator.md b/bundles/azure-virtual-network/operator.md new file mode 100644 index 0000000..48c7e48 --- /dev/null +++ b/bundles/azure-virtual-network/operator.md @@ -0,0 +1,73 @@ +# Azure Virtual Network — Operator Guide + +## Overview + +This bundle provisions an Azure Virtual Network (VNet) with configurable subnets, NSGs, optional DDoS protection, and optional peering into a Virtual WAN hub. It outputs an `acd/azure-virtual-network` artifact consumed by downstream bundles. + +## Resources Provisioned + +| Resource | Purpose | +|---|---| +| Resource Group | Logical container for all VNet resources | +| Virtual Network | Layer-3 address space | +| Subnets | Logical network segmentation | +| Network Security Groups | One NSG per subnet (default-allow rules; add custom rules via Azure Policy or separate bundles) | +| NSG Associations | Binds each NSG to its subnet (CKV2_AZURE_31 compliance) | +| DDoS Protection Plan | Optional — Standard tier, additional cost | +| Virtual Hub Connection | Optional — peers VNet into a connected Virtual WAN hub | + +## Virtual WAN Integration + +The VNet has an **optional** connection slot for `acd/azure-virtual-wan`. When connected: + +- The VNet is automatically peered into the Virtual WAN hub via `azurerm_virtual_hub_connection` +- Internet security is enabled on the connection (routes internet traffic through the hub firewall if configured) +- No additional configuration is needed from the developer + +**Typical topology**: A network team manages the Virtual WAN bundle and shares it to projects as an environment default. Developers create VNets in their projects and draw a connection to the shared WAN — peering happens automatically. + +## Sizing Guidance + +| Environment | Address Space | Subnets | DDoS | +|---|---|---|---| +| Development | `/16` (65k addresses) | 1–2 subnets with `/24` blocks | Disabled | +| Production | `/14` or larger | Separate subnets for app, data, private endpoints, management | Enabled | + +## Subnet Planning + +Plan subnets based on workload isolation: + +- **app** — Application tier (Function Apps, App Services, AKS) +- **data** — Data services (SQL MI, Cosmos DB, Redis) +- **private-endpoints** — Private Endpoints for PaaS services +- **management** — Bastion hosts, jump boxes, monitoring agents + +Enable **service endpoints** on subnets that need direct access to Azure PaaS services (e.g., `Microsoft.Storage`, `Microsoft.Sql`, `Microsoft.KeyVault`). + +## Compliance Notes + +- **NSGs**: Every subnet gets an NSG with default Azure rules. Add custom deny/allow rules via Azure Policy or dedicated NSG bundles. +- **DDoS Protection**: Enable for PCI-DSS, HIPAA, and similar compliance regimes. Additional cost (~$2,944/month per plan, covers up to 100 VNets). +- **Network Watcher**: Azure auto-provisions one per subscription per region. Flow logs require a separate Storage Account and are configured outside this bundle. + +## Artifact Data + +The `acd/azure-virtual-network` artifact exports: + +| Field | Description | Example | +|---|---|---| +| `id` | VNet resource ID | `/subscriptions/.../virtualNetworks/my-vnet` | +| `resource_group_name` | Resource group name | `my-prefix-rg` | +| `location` | Azure region | `eastus` | +| `cidr` | Address space | `10.0.0.0/16` | +| `subnets[].id` | Subnet resource ID | `/subscriptions/.../subnets/app` | +| `subnets[].name` | Subnet name | `app` | +| `subnets[].cidr` | Subnet CIDR | `10.0.0.0/24` | + +## Downstream Bundles + +Bundles that consume `acd/azure-virtual-network`: + +- **azure-function-app** — Deploys into the VNet, uses subnets for private endpoints +- **azure-kubernetes-service** — AKS with VNet-integrated node pools +- **azure-sql-managed-instance** — SQL MI deployed into a delegated subnet diff --git a/bundles/azure-virtual-network/src/.checkov.yml b/bundles/azure-virtual-network/src/.checkov.yml new file mode 100644 index 0000000..f8f31fb --- /dev/null +++ b/bundles/azure-virtual-network/src/.checkov.yml @@ -0,0 +1,10 @@ +# Checkov skip configuration for azure-virtual-network bundle +# Only skipping checks that are truly irrelevant across ALL environments. +# Compliance findings that are configurable (e.g., DDoS protection) are +# handled via params — not skipped. +skip-check: + # CKV_AZURE_183: Flow logs are a Network Watcher feature — we provision + # Network Watcher but flow log storage requires a separate Storage Account + # dependency that is out of scope for the foundational VNet bundle. + # Flow logs should be enabled at the workload level or via Azure Policy. + - CKV_AZURE_183 diff --git a/bundles/azure-virtual-network/src/_massdriver_variables.tf b/bundles/azure-virtual-network/src/_massdriver_variables.tf new file mode 100644 index 0000000..7af4043 --- /dev/null +++ b/bundles/azure-virtual-network/src/_massdriver_variables.tf @@ -0,0 +1,64 @@ +// This file is auto-generated by massdriver from your massdriver.yaml file. +// Any changes made directly to this file will be overwritten on the next build. +// To opt a variable out of regeneration, move it to another file (e.g. variables.tf). +variable "azure_service_principal" { + type = object({ + client_id = string + client_secret = string + subscription_id = string + tenant_id = string + }) +} +variable "azure_virtual_wan" { + type = object({ + id = string + location = string + resource_group_name = string + virtual_hub_address_prefix = string + virtual_hub_id = string + vpn_gateway_id = optional(string) + }) + default = null +} +variable "cidr" { + type = string +} +variable "dns_servers" { + type = list(string) + default = [] +} +variable "enable_ddos_protection" { + type = bool + default = false +} +variable "location" { + type = string +} +variable "md_metadata" { + type = object({ + default_tags = map(string) + deployment = object({ + id = string + }) + name_prefix = string + observability = object({ + alarm_webhook_url = string + }) + package = object({ + created_at = string + deployment_enqueued_at = string + previous_status = string + updated_at = string + }) + target = object({ + contact_email = string + }) + }) +} +variable "subnets" { + type = list(object({ + cidr = string + name = string + service_endpoints = optional(list(string)) + })) +} diff --git a/bundles/azure-virtual-network/src/artifacts.tf b/bundles/azure-virtual-network/src/artifacts.tf new file mode 100644 index 0000000..afdb190 --- /dev/null +++ b/bundles/azure-virtual-network/src/artifacts.tf @@ -0,0 +1,17 @@ +resource "massdriver_artifact" "azure_virtual_network" { + field = "azure_virtual_network" + name = "Azure VNet ${var.md_metadata.name_prefix}" + artifact = jsonencode({ + id = azurerm_virtual_network.main.id + resource_group_name = azurerm_resource_group.main.name + location = azurerm_virtual_network.main.location + cidr = var.cidr + subnets = [ + for name, subnet in azurerm_subnet.main : { + id = subnet.id + name = subnet.name + cidr = subnet.address_prefixes[0] + } + ] + }) +} diff --git a/bundles/azure-virtual-network/src/main.tf b/bundles/azure-virtual-network/src/main.tf new file mode 100644 index 0000000..16e8cb6 --- /dev/null +++ b/bundles/azure-virtual-network/src/main.tf @@ -0,0 +1,105 @@ +terraform { + required_version = ">= 1.0" + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 3.0" + } + massdriver = { + source = "massdriver-cloud/massdriver" + version = "~> 1.3" + } + } +} + +provider "azurerm" { + features {} + + client_id = var.azure_service_principal.client_id + client_secret = var.azure_service_principal.client_secret + tenant_id = var.azure_service_principal.tenant_id + subscription_id = var.azure_service_principal.subscription_id +} + +locals { + name_prefix = var.md_metadata.name_prefix +} + +resource "azurerm_resource_group" "main" { + name = "${local.name_prefix}-rg" + location = var.location + tags = var.md_metadata.default_tags +} + +resource "azurerm_network_ddos_protection_plan" "main" { + count = var.enable_ddos_protection ? 1 : 0 + name = "${local.name_prefix}-ddos" + location = azurerm_resource_group.main.location + resource_group_name = azurerm_resource_group.main.name + tags = var.md_metadata.default_tags +} + +resource "azurerm_virtual_network" "main" { + name = "${local.name_prefix}-vnet" + location = azurerm_resource_group.main.location + resource_group_name = azurerm_resource_group.main.name + address_space = [var.cidr] + dns_servers = length(var.dns_servers) > 0 ? var.dns_servers : null + tags = var.md_metadata.default_tags + + dynamic "ddos_protection_plan" { + for_each = var.enable_ddos_protection ? [1] : [] + content { + id = azurerm_network_ddos_protection_plan.main[0].id + enable = true + } + } +} + +resource "azurerm_subnet" "main" { + for_each = { for s in var.subnets : s.name => s } + + name = each.value.name + resource_group_name = azurerm_resource_group.main.name + virtual_network_name = azurerm_virtual_network.main.name + address_prefixes = [each.value.cidr] + service_endpoints = try(each.value.service_endpoints, []) +} + +# CKV2_AZURE_31: Each subnet must be associated with a Network Security Group. +# We create one NSG per subnet with no rules (allow Azure defaults); operators +# add rules via separate NSG bundles or rule resources connected to this VNet. +resource "azurerm_network_security_group" "main" { + for_each = { for s in var.subnets : s.name => s } + + name = "${local.name_prefix}-${each.key}-nsg" + location = azurerm_resource_group.main.location + resource_group_name = azurerm_resource_group.main.name + tags = var.md_metadata.default_tags +} + +resource "azurerm_subnet_network_security_group_association" "main" { + for_each = { for s in var.subnets : s.name => s } + + subnet_id = azurerm_subnet.main[each.key].id + network_security_group_id = azurerm_network_security_group.main[each.key].id +} + +# Network Watcher is a subscription-level singleton per region — Azure enforces +# only one per subscription per region. We do NOT create it here; it is assumed +# to already exist in the subscription (Azure creates a default one automatically +# in the NetworkWatcherRG resource group). Bundles that need flow logs should +# reference the existing Network Watcher via a data source. + +# ───────────────────────────────────────────── +# Virtual WAN Hub Connection (optional) +# When a Virtual WAN artifact is connected, this peers the VNet into the hub. +# Network teams manage the WAN centrally; project teams bring their own VNets. +# ───────────────────────────────────────────── +resource "azurerm_virtual_hub_connection" "main" { + count = var.azure_virtual_wan != null ? 1 : 0 + name = "${local.name_prefix}-hub-conn" + virtual_hub_id = var.azure_virtual_wan.virtual_hub_id + remote_virtual_network_id = azurerm_virtual_network.main.id + internet_security_enabled = true +} diff --git a/bundles/azure-virtual-wan/icon.svg b/bundles/azure-virtual-wan/icon.svg new file mode 100644 index 0000000..0853009 --- /dev/null +++ b/bundles/azure-virtual-wan/icon.svg @@ -0,0 +1 @@ + diff --git a/bundles/azure-virtual-wan/massdriver.yaml b/bundles/azure-virtual-wan/massdriver.yaml new file mode 100644 index 0000000..603d061 --- /dev/null +++ b/bundles/azure-virtual-wan/massdriver.yaml @@ -0,0 +1,114 @@ +name: azure-virtual-wan +description: "Azure Virtual WAN providing global transit network with virtual hub, optional VPN gateway, and branch connectivity." +source_url: https://github.com/massdriver-cloud/massdriver-catalog/tree/main/bundles/azure-virtual-wan +version: 0.2.1 + +params: + required: + - location + - hub_address_prefix + properties: + location: + type: string + title: Azure Region + description: Azure region where the Virtual WAN hub will be deployed. + $md.immutable: true + enum: + - eastus + - eastus2 + - westus + - westus2 + - westus3 + - centralus + - northcentralus + - southcentralus + - westcentralus + - northeurope + - westeurope + - uksouth + - ukwest + - australiaeast + - australiasoutheast + - southeastasia + - eastasia + - japaneast + - japanwest + - southindia + - centralindia + - westindia + - canadacentral + - canadaeast + - brazilsouth + hub_address_prefix: + type: string + title: Hub Address Prefix + description: CIDR address space for the Virtual Hub (must be at least /24, recommended /23). + $md.immutable: true + default: "10.100.0.0/23" + pattern: ^([0-9]{1,3}\.){3}[0-9]{1,3}\/[0-9]{1,2}$ + wan_type: + type: string + title: WAN Type + description: "Basic supports single hub. Standard supports multi-hub, VNet peering, and ExpressRoute." + $md.immutable: true + enum: + - Basic + - Standard + default: Standard + enable_vpn_gateway: + type: boolean + title: Enable VPN Gateway + description: Provision a Site-to-Site VPN gateway in the hub for branch office connectivity (additional cost applies). + default: false + vpn_gateway_scale_unit: + type: integer + title: VPN Gateway Scale Unit + description: "Scale unit determines gateway throughput. Each unit = 500 Mbps. Min 1, max 10." + default: 1 + minimum: 1 + maximum: 10 + + examples: + - __name: Development + location: eastus + hub_address_prefix: "10.100.0.0/23" + wan_type: Standard + enable_vpn_gateway: false + - __name: Production + location: eastus + hub_address_prefix: "10.100.0.0/23" + wan_type: Standard + enable_vpn_gateway: true + vpn_gateway_scale_unit: 2 + +connections: + required: + - azure_service_principal + properties: + azure_service_principal: + $ref: acd/azure-service-principal + title: Azure Service Principal + +artifacts: + required: + - azure_virtual_wan + properties: + azure_virtual_wan: + $ref: acd/azure-virtual-wan + title: Azure Virtual WAN + +steps: + - path: src + provisioner: opentofu:1.10 + config: + checkov: + halt_on_failure: '.params.md_metadata.default_tags["md-target"] | test("prd|prod|production")' + +ui: + ui:order: + - location + - hub_address_prefix + - wan_type + - enable_vpn_gateway + - vpn_gateway_scale_unit + - "*" diff --git a/bundles/azure-virtual-wan/operator.md b/bundles/azure-virtual-wan/operator.md new file mode 100644 index 0000000..a315d13 --- /dev/null +++ b/bundles/azure-virtual-wan/operator.md @@ -0,0 +1,71 @@ +# Azure Virtual WAN — Operator Guide + +## Overview + +This bundle provisions an Azure Virtual WAN with a regional hub and optional VPN gateway for branch office connectivity. It outputs an `acd/azure-virtual-wan` artifact that VNet bundles consume to automatically peer into the hub. + +## Resources Provisioned + +| Resource | Purpose | +|---|---| +| Resource Group | Logical container for WAN resources | +| Virtual WAN | Global transit backbone | +| Virtual Hub | Regional routing hub with its own address space | +| VPN Gateway | Optional — S2S VPN for branch offices (additional cost) | + +## Architecture + +``` +Branch Offices ──VPN──▶ [Virtual Hub] ◀── VNet (project A) + ▲ + └── VNet (project B) +``` + +The Virtual WAN hub acts as a central routing point. VNets peer into the hub via the `azure-virtual-network` bundle's optional VWAN connection. Traffic between peered VNets routes through the hub automatically. + +## WAN Type + +| Type | Capabilities | +|---|---| +| **Basic** | Single hub, point-to-site VPN only | +| **Standard** | Multi-hub, VNet peering, ExpressRoute, S2S VPN, inter-hub routing | + +Use **Standard** for production. Basic does not support VNet-to-VNet transit routing. + +## VPN Gateway Sizing + +Each scale unit provides ~500 Mbps aggregate throughput: + +| Scale Units | Throughput | Use Case | +|---|---|---| +| 1 | 500 Mbps | Small branch office | +| 2 | 1 Gbps | Multiple branches | +| 4+ | 2+ Gbps | Enterprise branch connectivity | + +## Operational Model + +**Network team** manages this bundle: +1. Deploy the Virtual WAN in a shared project or at the org level +2. Share the `acd/azure-virtual-wan` artifact to target environments via environment defaults +3. Project teams connect their VNets to the shared WAN on the canvas + +**Project teams** never configure the WAN directly — they just draw a connection from their VNet to the shared WAN artifact. + +## Compliance Notes + +- **WAN type is immutable** after creation. Changing from Basic to Standard requires a full redeploy. +- **Hub address prefix is immutable**. Choose a CIDR that won't overlap with any VNets you plan to peer. +- **Branch-to-branch traffic** is disabled by default. Enable it only if branches need direct communication without routing through a firewall. + +## Artifact Data + +The `acd/azure-virtual-wan` artifact exports: + +| Field | Description | +|---|---| +| `id` | Virtual WAN resource ID | +| `resource_group_name` | Resource group name | +| `location` | Azure region | +| `virtual_hub_id` | Hub resource ID (used for VNet peering) | +| `virtual_hub_address_prefix` | Hub CIDR | +| `vpn_gateway_id` | VPN Gateway ID (only present when VPN is enabled) | diff --git a/bundles/azure-virtual-wan/src/_massdriver_variables.tf b/bundles/azure-virtual-wan/src/_massdriver_variables.tf new file mode 100644 index 0000000..d80ec6d --- /dev/null +++ b/bundles/azure-virtual-wan/src/_massdriver_variables.tf @@ -0,0 +1,50 @@ +// This file is auto-generated by massdriver from your massdriver.yaml file. +// Any changes made directly to this file will be overwritten on the next build. +// To opt a variable out of regeneration, move it to another file (e.g. variables.tf). +variable "azure_service_principal" { + type = object({ + client_id = string + client_secret = string + subscription_id = string + tenant_id = string + }) +} +variable "enable_vpn_gateway" { + type = bool + default = false +} +variable "hub_address_prefix" { + type = string +} +variable "location" { + type = string +} +variable "md_metadata" { + type = object({ + default_tags = map(string) + deployment = object({ + id = string + }) + name_prefix = string + observability = object({ + alarm_webhook_url = string + }) + package = object({ + created_at = string + deployment_enqueued_at = string + previous_status = string + updated_at = string + }) + target = object({ + contact_email = string + }) + }) +} +variable "vpn_gateway_scale_unit" { + type = number + default = 1 +} +variable "wan_type" { + type = string + default = "Standard" +} diff --git a/bundles/azure-virtual-wan/src/artifacts.tf b/bundles/azure-virtual-wan/src/artifacts.tf new file mode 100644 index 0000000..1e3cbb7 --- /dev/null +++ b/bundles/azure-virtual-wan/src/artifacts.tf @@ -0,0 +1,24 @@ +locals { + # Build the artifact object without vpn_gateway_id when no gateway is provisioned. + # The artifact definition marks vpn_gateway_id as optional (not in required[]) but + # uses type: string — passing null causes a validation error, so we omit the key + # entirely when the VPN gateway is disabled. + _artifact_base = { + id = azurerm_virtual_wan.main.id + resource_group_name = azurerm_resource_group.main.name + location = azurerm_virtual_wan.main.location + virtual_hub_id = azurerm_virtual_hub.main.id + virtual_hub_address_prefix = var.hub_address_prefix + } + _artifact_vpn = var.enable_vpn_gateway ? { + vpn_gateway_id = azurerm_vpn_gateway.main[0].id + } : {} + + artifact_data = merge(local._artifact_base, local._artifact_vpn) +} + +resource "massdriver_artifact" "azure_virtual_wan" { + field = "azure_virtual_wan" + name = "Azure Virtual WAN ${var.md_metadata.name_prefix}" + artifact = jsonencode(local.artifact_data) +} diff --git a/bundles/azure-virtual-wan/src/main.tf b/bundles/azure-virtual-wan/src/main.tf new file mode 100644 index 0000000..979e84b --- /dev/null +++ b/bundles/azure-virtual-wan/src/main.tf @@ -0,0 +1,66 @@ +terraform { + required_version = ">= 1.0" + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 3.0" + } + massdriver = { + source = "massdriver-cloud/massdriver" + version = "~> 1.3" + } + } +} + +provider "azurerm" { + features {} + + client_id = var.azure_service_principal.client_id + client_secret = var.azure_service_principal.client_secret + tenant_id = var.azure_service_principal.tenant_id + subscription_id = var.azure_service_principal.subscription_id +} + +locals { + name_prefix = var.md_metadata.name_prefix +} + +resource "azurerm_resource_group" "main" { + name = "${local.name_prefix}-rg" + location = var.location + tags = var.md_metadata.default_tags +} + +# CKV_AZURE_230 / general compliance: Virtual WAN type Standard enables +# full mesh routing, VNet peering, and ExpressRoute — required for production. +resource "azurerm_virtual_wan" "main" { + name = "${local.name_prefix}-vwan" + location = azurerm_resource_group.main.location + resource_group_name = azurerm_resource_group.main.name + type = var.wan_type + tags = var.md_metadata.default_tags + + # Disable legacy branch-to-branch traffic by default; enable explicitly if needed + allow_branch_to_branch_traffic = false + office365_local_breakout_category = "None" +} + +resource "azurerm_virtual_hub" "main" { + name = "${local.name_prefix}-hub" + location = azurerm_resource_group.main.location + resource_group_name = azurerm_resource_group.main.name + virtual_wan_id = azurerm_virtual_wan.main.id + address_prefix = var.hub_address_prefix + tags = var.md_metadata.default_tags +} + +# Site-to-Site VPN Gateway — optional, for branch office connectivity +resource "azurerm_vpn_gateway" "main" { + count = var.enable_vpn_gateway ? 1 : 0 + name = "${local.name_prefix}-vpngw" + location = azurerm_resource_group.main.location + resource_group_name = azurerm_resource_group.main.name + virtual_hub_id = azurerm_virtual_hub.main.id + scale_unit = var.vpn_gateway_scale_unit + tags = var.md_metadata.default_tags +}