Skip to content
Aa

Terraform source

The Canadian traffic path leads with API configuration excerpts. These sources show the equivalent Terraform and supporting infrastructure. The configuration map links each control to its source file.

The sources below are imported directly from this repository.

Source provenance is retained in SOURCE_ATTRIBUTION.md. The imported attribution file is excluded from generated navigation; the source notice remains authoritative. Flag artwork uses pinned flag-icons v7.5.0 under the MIT license.

azure_ilb_xc.tf
# The internal application owns both CE diagnostic and ILB listeners.
backend.tf
terraform {
backend "local" {}
}
ca_ilb.tf
resource "azurerm_lb" "ca_ilb" {
count = var.enable_azure && var.enable_canada && var.enable_canada_ilb ? 1 : 0
name = "${var.component}-ca-ilb"
location = module.azure_hub_ca[0].location
resource_group_name = module.azure_hub_ca[0].resource_group_name
sku = "Standard"
frontend_ip_configuration {
name = "application-frontend"
subnet_id = module.azure_hub_ca[0].internal_subnet_id
private_ip_address = cidrhost(var.ca_internal_subnet_prefix, 10)
private_ip_address_allocation = "Static"
}
frontend_ip_configuration {
name = "console-frontend"
subnet_id = module.azure_hub_ca[0].internal_subnet_id
private_ip_address = cidrhost(var.ca_internal_subnet_prefix, 11)
private_ip_address_allocation = "Static"
}
tags = local.tags
}
resource "azurerm_lb_backend_address_pool" "ca_ce_backend" {
count = var.enable_azure && var.enable_canada && var.enable_canada_ilb ? 1 : 0
name = "ce-inside-backend"
loadbalancer_id = azurerm_lb.ca_ilb[0].id
}
resource "azurerm_network_interface_backend_address_pool_association" "ca_ce" {
for_each = var.enable_azure && var.enable_canada && var.enable_canada_ilb ? module.ce_topology_ca[0].ce_nodes : {}
network_interface_id = module.ce_node_ca[each.key].internal_nic_id
ip_configuration_name = "ipconfig1"
backend_address_pool_id = azurerm_lb_backend_address_pool.ca_ce_backend[0].id
}
resource "azurerm_lb_probe" "ca_site_console" {
count = var.enable_azure && var.enable_canada && var.enable_canada_ilb ? 1 : 0
name = "site-console-probe"
loadbalancer_id = azurerm_lb.ca_ilb[0].id
protocol = "Tcp"
port = 65500
interval_in_seconds = 5
number_of_probes = 2
}
resource "azurerm_lb_rule" "ca_application" {
count = var.enable_azure && var.enable_canada && var.enable_canada_ilb ? 1 : 0
name = "application-rule"
loadbalancer_id = azurerm_lb.ca_ilb[0].id
frontend_ip_configuration_name = "application-frontend"
backend_address_pool_ids = [azurerm_lb_backend_address_pool.ca_ce_backend[0].id]
probe_id = azurerm_lb_probe.ca_site_console[0].id
protocol = "Tcp"
frontend_port = 80
backend_port = 80
floating_ip_enabled = true
}
resource "azurerm_lb_rule" "ca_console" {
count = var.enable_azure && var.enable_canada && var.enable_canada_ilb ? 1 : 0
name = "console-rule"
loadbalancer_id = azurerm_lb.ca_ilb[0].id
frontend_ip_configuration_name = "console-frontend"
backend_address_pool_ids = [azurerm_lb_backend_address_pool.ca_ce_backend[0].id]
probe_id = azurerm_lb_probe.ca_site_console[0].id
protocol = "Tcp"
frontend_port = 65500
backend_port = 65500
floating_ip_enabled = false
}
data.tf
# Deployer identity resolution (read-only, azuread). An explicit deployer makes
# the lookup unnecessary, which keeps AWS-only planning from contacting Azure.
data "azuread_client_config" "current" {
count = var.enable_azure && var.deployer == "" ? 1 : 0
}
data "azuread_user" "current" {
count = var.enable_azure && var.deployer == "" ? 1 : 0
object_id = data.azuread_client_config.current[0].object_id
}
# The v15.0.3 provider embeds the v12.0.0 published network allowlist. These
# values identify destinations, not firewall rules: protocol, port, direction,
# and reachability are checked explicitly by the lifecycle preflight.
data "xcsh_network_customer_edge_defaults" "system_services" {}
data "xcsh_network_customer_edge_egress" "secure_mesh_v2" {}
locals.tf
locals {
deployment_identity_schema = "canada-topology.deployment-identity/v1"
source_branch = trimprefix(var.source_ref, "refs/heads/")
# Repository renames must not rotate immutable token labels or CE boot inputs.
# Preserve the original deployment identity salt while publishing current provenance.
deployment_identity_repository = "f5-sales-demo/canada-topology"
source_ref_sha256 = sha256("${local.deployment_identity_schema}\u0000${local.deployment_identity_repository}\u0000${var.source_ref}")
source_branch_slug_raw = trim(replace(lower(local.source_branch), "/[^a-z0-9]+/", "-"), "-")
source_branch_slug = local.source_branch_slug_raw != "" ? local.source_branch_slug_raw : "branch"
deployment_is_production = var.source_ref == "refs/heads/main"
deployment_environment_key = local.deployment_is_production ? "production" : "${trimsuffix(substr(local.source_branch_slug, 0, 19), "-")}-${substr(local.source_ref_sha256, 0, 12)}"
deployment_name_suffix = local.deployment_is_production ? "" : "-${local.deployment_environment_key}"
xc_api_url = "https://${var.expected_xc_tenant}.console.ves.volterra.io"
deployer_from_name = (
var.deployer == "" && length(data.azuread_user.current) > 0
? try(
lower("${substr(data.azuread_user.current[0].given_name, 0, 1)}${data.azuread_user.current[0].surname}"),
""
)
: ""
)
deployer_from_mail = (
var.deployer == "" && length(data.azuread_user.current) > 0 && local.deployer_from_name == ""
? try(
lower(split("@", data.azuread_user.current[0].mail)[0]),
""
)
: ""
)
deployer_from_oid = var.deployer == "" && length(data.azuread_client_config.current) > 0 ? try(substr(sha1(data.azuread_client_config.current[0].object_id), 0, 8), "") : ""
deployer_resolved = coalesce(
var.deployer,
local.deployer_from_name,
local.deployer_from_mail,
local.deployer_from_oid
)
deployer = replace(lower(local.deployer_resolved), "/[^a-z0-9]/", "")
site_prefix_base = coalesce(var.site_prefix, "${var.component}-${var.smsv2_site_generation}")
preview_site_prefix = "canada-${trimsuffix(substr(local.source_branch_slug, 0, 14), "-")}-${substr(local.source_ref_sha256, 0, 12)}"
site_prefix = local.deployment_is_production ? local.site_prefix_base : local.preview_site_prefix
ca_region_short = coalesce(var.ca_region_short, var.ca_location)
ca_site_prefix_base = coalesce(var.ca_site_prefix, "${local.site_prefix_base}-ca")
ca_site_prefix = local.deployment_is_production ? local.ca_site_prefix_base : "${local.site_prefix}-ca"
ca_resource_group_name = "${coalesce(var.ca_resource_group_name, "rg-${var.component}-ca-${local.deployer}")}${local.deployment_name_suffix}"
ca_route_server_name = "${coalesce(var.ca_route_server_name, "${var.component}-ca-rs")}${local.deployment_name_suffix}"
ca_bastion_name = "${coalesce(var.ca_bastion_name, "${var.component}-ca-bastion")}${local.deployment_name_suffix}"
ca_client_vm_name = "${coalesce(var.ca_client_vm_name, "${var.component}-ca-client")}${local.deployment_name_suffix}"
ca_origin_pool_name = "${coalesce(var.ca_origin_pool_name, "${var.component}-ca-pool")}${local.deployment_name_suffix}"
ca_lb_name = "${coalesce(var.ca_lb_name, "${var.component}-ca-f5se")}${local.deployment_name_suffix}"
ca_re_vsite_name = "${coalesce(var.ca_re_vsite_name, "${var.component}-ca-re-vsite")}${local.deployment_name_suffix}"
ca_ce_vsite_name = "${coalesce(var.ca_ce_vsite_name, "${var.component}-ca-ce-vsite")}${local.deployment_name_suffix}"
ca_lb_domain = var.ca_lb_domain
standard_tags = {
component = var.component
environment = var.environment
deployer = local.deployer
managed_by = "terraform"
canada_environment = local.deployment_environment_key
canada_repository = "canada"
canada_source_ref_sha256 = local.source_ref_sha256
canada_source_commit = var.source_commit_sha
canada_owner_id = var.deployment_owner_id
canada_actor_id = var.deployment_actor_id
}
tags = merge(var.tags, local.standard_tags)
xc_provenance_labels = {
"canada-deployment-generation" = var.smsv2_site_generation
"canada-environment" = local.deployment_environment_key
"canada-source-ref-sha256" = substr(local.source_ref_sha256, 0, 32)
"canada-source-commit" = var.source_commit_sha
"canada-owner-id" = var.deployment_owner_id
"canada-actor-id" = var.deployment_actor_id
"canada-xc-tenant" = var.expected_xc_tenant
}
ca_xc_labels = merge(local.xc_provenance_labels, {
"canada-topology" = "${local.ca_site_prefix}-azure"
})
ssh_public_key = var.ssh_public_key != "" ? var.ssh_public_key : file(pathexpand(var.ssh_public_key_path))
ce_registration_token = var.registration_token != "" ? var.registration_token : try(xcsh_token.ce[0].uid, null)
ca_ce_cloud_init = {
for key, node in try(module.ce_topology_ca[0].ce_nodes, {}) : key => templatefile("${path.module}/cloud-init/ce-node.yaml", {
probe_routing_script = file("${path.module}/cloud-init/azure-probe-routing.sh")
inside_subnet = var.ca_internal_subnet_prefix
cluster_name = node.site_name
token = local.ce_registration_token
ssh_public_key = chomp(local.ssh_public_key)
})
}
}
main.tf
# Canadian graph extracted from the attributed Canada source.
module "ce_topology_ca" {
count = var.enable_azure && var.enable_canada ? 1 : 0
source = "./modules/ce-topology"
ce_count = var.ca_ce_count
region_short = local.ca_region_short
mgmt_subnet_prefix = var.ca_mgmt_subnet_prefix
site_prefix = local.ca_site_prefix
}
# Canada Hub: RG, VNet, and CE subnets; Route Server remains opt-in.
module "azure_hub_ca" {
count = var.enable_azure && var.enable_canada ? 1 : 0
source = "./modules/azure-hub"
resource_group_name = local.ca_resource_group_name
location = var.ca_location
hub_cidr = var.ca_hub_cidr
mgmt_subnet_prefix = var.ca_mgmt_subnet_prefix
external_subnet_prefix = var.ca_external_subnet_prefix
internal_subnet_prefix = var.ca_internal_subnet_prefix
route_server_subnet_prefix = var.ca_route_server_subnet_prefix
route_server_name = local.ca_route_server_name
enable_route_server = var.enable_bgp
bastion_subnet_prefix = var.ca_bastion_subnet_prefix
enable_bastion = var.enable_bastion
bastion_name = local.ca_bastion_name
tags = local.tags
}
# One Canadian CE VM (3 NICs + identity) per node.
module "ce_node_ca" {
for_each = try(module.ce_topology_ca[0].ce_nodes, {})
source = "./modules/ce-node"
hostname = each.value.hostname
resource_group_name = try(module.azure_hub_ca[0].resource_group_name, null)
location = try(module.azure_hub_ca[0].location, null)
zone = each.value.az
vm_size = var.ce_vm_size
mgmt_subnet_id = try(module.azure_hub_ca[0].management_subnet_id, null)
external_subnet_id = try(module.azure_hub_ca[0].external_subnet_id, null)
internal_subnet_id = try(module.azure_hub_ca[0].internal_subnet_id, null)
mgmt_private_ip = each.value.slo_ip
admin_username = var.admin_username
ssh_public_key = local.ssh_public_key
custom_data = base64encode(local.ca_ce_cloud_init[each.key])
tags = local.tags
}
resource "random_password" "site_console_admin_ca" {
for_each = try(module.ce_topology_ca[0].ce_nodes, {})
length = 32
min_lower = 1
min_numeric = 1
min_special = 1
min_upper = 1
override_special = "!#%*+-=?@^_~"
keepers = {
ce_vm_instance_id = module.ce_vm_ca[each.key].vm_instance_id
}
}
resource "azurerm_virtual_machine_extension" "site_console_password_ca" {
for_each = try(module.ce_topology_ca[0].ce_nodes, {})
name = "site-console-admin-password"
virtual_machine_id = module.ce_vm_ca[each.key].vm_id
publisher = "Microsoft.Azure.Extensions"
type = "CustomScript"
type_handler_version = "2.1"
auto_upgrade_minor_version = true
protected_settings = jsonencode({
script = base64encode(<<-SCRIPT
#!/usr/bin/env bash
set -euo pipefail
password_b64='${base64encode(random_password.site_console_admin_ca[each.key].result)}'
password=$(printf '%s' "$password_b64" | base64 --decode)
printf 'admin:%s\n' "$password" | chpasswd
unset password password_b64
SCRIPT
)
})
}
# Canadian XC SMSv2 site + BGP object per node.
module "xc_site_ca" {
for_each = try(module.ce_topology_ca[0].ce_nodes, {})
source = "./modules/xc-site"
create_site = true
site_name = each.value.site_name
hostname = each.value.hostname
interface_name = each.value.interface_name
bind_registered_interfaces = var.azure_site_configuration_phase == "configured"
mgmt_nic_mac = var.azure_site_configuration_phase == "configured" ? module.ce_node_ca[each.key].mgmt_nic_mac : null
inside_nic_mac = var.azure_site_configuration_phase == "configured" ? module.ce_node_ca[each.key].inside_nic_mac : null
external_nic_mac = var.azure_site_configuration_phase == "configured" ? module.ce_node_ca[each.key].external_nic_mac : null
ce_generation_id = module.ce_node_ca[each.key].generation_id
peer_ips = try(module.azure_frr_ca[0].peer_ips, [])
ce_asn = var.ce_asn
peer_asn = var.azure_frr_asn
os_version = var.ce_os_version
sw_version = var.ce_sw_version
enable_bgp = var.enable_bgp
approve_registration = var.approve_registration && var.azure_site_configuration_phase == "configured"
labels = local.ca_xc_labels
}
module "azure_frr_ca" {
count = var.enable_azure && var.enable_canada && var.enable_bgp ? 1 : 0
source = "./modules/azure-frr"
name = "${var.component}-ca"
location = module.azure_hub_ca[0].location
resource_group_name = module.azure_hub_ca[0].resource_group_name
mgmt_subnet_id = module.azure_hub_ca[0].management_subnet_id
mgmt_subnet_prefix = var.ca_mgmt_subnet_prefix
route_server_id = module.azure_hub_ca[0].route_server_id
rs_peer_ips = module.azure_hub_ca[0].rs_peer_ips
ce_ips = [for key in sort(keys(module.ce_topology_ca[0].ce_nodes)) : module.ce_node_ca[key].mgmt_private_ip]
vip = var.ca_vip
ce_asn = var.ce_asn
frr_asn = var.azure_frr_asn
rs_asn = var.rs_asn
admin_username = var.admin_username
ssh_public_key = local.ssh_public_key
tags = local.tags
}
module "client_vm_ca" {
count = var.enable_azure && var.enable_canada ? 1 : 0
source = "./modules/client-vm"
name = local.ca_client_vm_name
resource_group_name = try(module.azure_hub_ca[0].resource_group_name, null)
location = try(module.azure_hub_ca[0].location, null)
subnet_id = try(module.azure_hub_ca[0].internal_subnet_id, null)
admin_username = var.admin_username
ssh_public_key = local.ssh_public_key
tags = local.tags
}
# ---------------------------------------------------------
# F5 XC Virtual Sites (Canada RE & Canada CE)
# ---------------------------------------------------------
resource "xcsh_virtual_site" "canada_re" {
count = var.enable_azure && var.enable_canada ? 1 : 0
name = local.ca_re_vsite_name
namespace = var.enable_canada_public_re && var.ca_re_public_ip != null ? var.ca_re_public_ip.namespace : xcsh_namespace.canada.name
labels = local.ca_xc_labels
site_type = "REGIONAL_EDGE"
site_selector {
expressions = ["ves.io/region in (${join(", ", [for city in var.ca_re_cities : "ves-io-${city}"])})"]
}
}
resource "xcsh_virtual_site" "canada_ce" {
count = var.enable_azure && var.enable_canada ? 1 : 0
name = local.ca_ce_vsite_name
namespace = xcsh_namespace.canada.name
labels = local.ca_xc_labels
site_type = "CUSTOMER_EDGE"
site_selector {
expressions = ["canada-topology in (${local.ca_xc_labels["canada-topology"]})"]
}
}
resource "xcsh_origin_pool" "canada" {
count = var.enable_azure && var.enable_canada ? 1 : 0
name = local.ca_origin_pool_name
namespace = xcsh_namespace.canada.name
description = "Canadian origin pool discovered exclusively through Canadian CEs"
labels = local.ca_xc_labels
port = var.origin_port
origin_servers {
labels = {}
private_ip {
ip = local.selected_ca_origin_ip
outside_network = {}
site_locator {
virtual_site {
name = xcsh_virtual_site.canada_ce[0].name
namespace = xcsh_namespace.canada.name
}
}
}
}
no_tls = {}
loadbalancer_algorithm = "ROUND_ROBIN"
endpoint_selection = "DISTRIBUTED"
}
resource "xcsh_public_ip_binding" "canada" {
count = var.enable_azure && var.enable_canada && var.enable_canada_public_re && var.ca_re_public_ip != null ? 1 : 0
name = var.ca_re_public_ip.name
namespace = var.ca_re_public_ip.namespace
expected_ip = var.ca_re_public_ip.ip
virtual_site = xcsh_virtual_site.canada_re[0].name
virtual_site_namespace = xcsh_virtual_site.canada_re[0].namespace
}
resource "terraform_data" "canada_public_ip_gate" {
count = var.enable_azure && var.enable_canada && var.enable_canada_public_re ? 1 : 0
input = var.ca_re_public_ip
lifecycle {
precondition {
condition = var.ca_re_public_ip != null
error_message = "Canadian public RE advertisement requires a dedicated ca_re_public_ip allocation."
}
}
}
resource "xcsh_http_loadbalancer" "canada" {
count = var.enable_azure && var.enable_canada && var.enable_canada_public_re ? 1 : 0
depends_on = [module.xc_site_ca, xcsh_virtual_site.canada_re, xcsh_virtual_site.canada_ce, terraform_data.canada_public_ip_gate]
name = local.ca_lb_name
namespace = xcsh_namespace.canada.name
description = "Canada-only public application through Toronto and Montreal Regional Edges."
labels = local.ca_xc_labels
add_location = var.enable_canada_public_re
domains = [local.ca_lb_domain]
http {
dns_volterra_managed = false
port = 80
}
advertise_custom {
dynamic "advertise_where" {
for_each = var.enable_canada_public_re ? [1] : []
content {
advertise_on_public {
public_ip {
name = try(xcsh_public_ip_binding.canada[0].name, "")
namespace = try(xcsh_public_ip_binding.canada[0].namespace, "")
}
}
use_default_port = {}
}
}
}
default_route_pools {
pool {
namespace = xcsh_namespace.canada.name
name = try(xcsh_origin_pool.canada[0].name, null)
}
weight = 1
priority = 1
}
round_robin = {}
no_challenge = {}
user_id_client_ip = {}
disable_waf = {}
disable_rate_limit = {}
disable_api_discovery = {}
disable_api_testing = {}
disable_api_definition = {}
l7_ddos_protection {}
active_service_policies {
policies {
name = xcsh_service_policy.canada_only[0].name
namespace = xcsh_namespace.canada.name
}
}
disable_trust_client_ip_headers = {}
disable_malicious_user_detection = {}
disable_malware_protection = {}
disable_threat_mesh = {}
default_sensitive_data_policy = {}
}
resource "xcsh_http_loadbalancer" "internal" {
count = var.enable_azure && var.enable_canada ? 1 : 0
depends_on = [module.xc_site_ca, xcsh_virtual_site.canada_re, xcsh_virtual_site.canada_ce, terraform_data.canada_public_ip_gate]
name = "${local.ca_lb_name}-internal"
namespace = xcsh_namespace.canada.name
description = "Internal Canadian BGP, primary-IP and ILB diagnostics."
labels = local.ca_xc_labels
add_location = false
domains = ["internal.canada.f5-sales-demo.ca"]
http {
dns_volterra_managed = false
port = 80
}
advertise_custom {
dynamic "advertise_where" {
for_each = try(module.ce_topology_ca[0].ce_nodes, {})
content {
site {
network = "SITE_NETWORK_OUTSIDE"
site {
namespace = "system"
name = advertise_where.value.site_name
}
ip = var.ca_vip
}
use_default_port = {}
}
}
dynamic "advertise_where" {
for_each = try(module.ce_topology_ca[0].ce_nodes, {})
content {
site {
network = "SITE_NETWORK_OUTSIDE"
site {
namespace = "system"
name = advertise_where.value.site_name
}
ip = advertise_where.value.slo_ip
}
use_default_port = {}
}
}
dynamic "advertise_where" {
for_each = var.enable_canada_ilb ? [1] : []
content {
port = 80
virtual_site_with_vip {
ip = cidrhost(var.ca_internal_subnet_prefix, 10)
network = "SITE_NETWORK_SPECIFIED_VIP_INSIDE"
virtual_site {
name = xcsh_virtual_site.canada_ce[0].name
namespace = xcsh_namespace.canada.name
}
}
}
}
}
default_route_pools {
pool {
namespace = xcsh_namespace.canada.name
name = try(xcsh_origin_pool.canada[0].name, null)
}
weight = 1
priority = 1
}
round_robin = {}
no_challenge = {}
user_id_client_ip = {}
disable_waf = {}
disable_rate_limit = {}
disable_api_discovery = {}
disable_api_testing = {}
disable_api_definition = {}
l7_ddos_protection {}
no_service_policies = {}
disable_trust_client_ip_headers = {}
disable_malicious_user_detection = {}
disable_malware_protection = {}
disable_threat_mesh = {}
default_sensitive_data_policy = {}
}
module "ce_vm_ca" {
source = "./modules/ce-vm"
for_each = try(module.ce_topology_ca[0].ce_nodes, {})
hostname = each.value.hostname
resource_group_name = module.azure_hub_ca[0].resource_group_name
location = module.azure_hub_ca[0].location
zone = each.value.az
vm_size = var.ce_vm_size
admin_username = var.admin_username
ssh_public_key = local.ssh_public_key
custom_data = base64encode(local.ca_ce_cloud_init[each.key])
network = module.ce_node_ca[each.key].network
tags = local.tags
depends_on = [module.xc_site_ca]
}
resource "xcsh_namespace" "canada" {
name = "canada"
labels = local.ca_xc_labels
lifecycle {
create_before_destroy = true
}
}
resource "xcsh_token" "ce" {
count = var.enable_canada ? 1 : 0
name = "canada-topology-registration"
namespace = "system"
description = "Canada dedicated CE registration token"
labels = { for key, value in local.ca_xc_labels : key => value if key != "canada-source-commit" }
type = 0
}
resource "terraform_data" "deployment_guard" {
input = { commit = var.source_commit_sha, tenant = data.external.xc_env_tenant.result.tenant }
lifecycle {
precondition {
condition = !var.enable_canada || (var.enable_azure && var.ca_ce_count == 3)
error_message = "Canada Topology requires exactly three Canadian CEs."
}
precondition {
condition = length(distinct(var.origin_developer_cidrs)) == 2
error_message = "Supply both verified workstation egress /32s."
}
precondition {
condition = var.enable_showcase_origin || var.ca_origin_ip != null
error_message = "An external Canadian origin must be supplied explicitly."
}
precondition {
condition = toset(var.ca_re_cities) == toset(["toronto", "montreal"])
error_message = "Canadian advertisement requires Toronto and Montreal."
}
precondition {
condition = cidrhost(var.ca_hub_cidr, 0) != cidrhost(format("%s/%s", var.ca_vip, split("/", var.ca_hub_cidr)[1]), 0)
error_message = "Canadian VIP must be outside the Canadian hub VNet."
}
}
}
data "external" "xc_env_tenant" {
program = [format("%s/scripts/xc-env-tenant.sh", path.module)]
lifecycle {
postcondition {
condition = contains(["", var.expected_xc_tenant], self.result.tenant)
error_message = "Ambient XC API URL does not match the expected deployment tenant."
}
}
}
resource "xcsh_service_policy" "canada_only" {
count = var.enable_azure && var.enable_canada ? 1 : 0
name = "${local.ca_lb_name}-canada-only"
namespace = xcsh_namespace.canada.name
description = "Allow actual source IPv4 addresses classified by XC GeoIP as Canada; deny all others."
labels = local.ca_xc_labels
any_server = {}
allow_list {
country_list = ["COUNTRY_CA"]
default_action_deny = {}
}
}
modules/azure-frr/main.tf
locals {
routers = {
"20" = { ip = cidrhost(var.mgmt_subnet_prefix, 20), zone = "1" }
"21" = { ip = cidrhost(var.mgmt_subnet_prefix, 21), zone = "2" }
}
}
resource "azurerm_public_ip" "frr" {
for_each = local.routers
name = "${var.name}-frr-${each.key}-pip"
location = var.location
resource_group_name = var.resource_group_name
allocation_method = "Static"
sku = "Standard"
tags = var.tags
}
resource "azurerm_network_security_group" "frr" {
name = "${var.name}-frr-nsg"
location = var.location
resource_group_name = var.resource_group_name
security_rule {
name = "bgp-from-vnet"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "179"
source_address_prefix = "VirtualNetwork"
destination_address_prefix = "*"
}
security_rule {
name = "http-to-demo-vip"
priority = 110
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "80"
source_address_prefix = "VirtualNetwork"
destination_address_prefix = var.vip
}
security_rule {
name = "deny-other-inbound"
priority = 4096
direction = "Inbound"
access = "Deny"
protocol = "*"
source_port_range = "*"
destination_port_range = "*"
source_address_prefix = "*"
destination_address_prefix = "*"
}
tags = var.tags
}
resource "azurerm_network_interface" "frr" {
for_each = local.routers
name = "${var.name}-frr-${each.key}-nic"
location = var.location
resource_group_name = var.resource_group_name
ip_forwarding_enabled = true
accelerated_networking_enabled = false
ip_configuration {
name = "ipconfig1"
subnet_id = var.mgmt_subnet_id
private_ip_address_allocation = "Static"
private_ip_address = each.value.ip
public_ip_address_id = azurerm_public_ip.frr[each.key].id
}
tags = var.tags
}
resource "azurerm_network_interface_security_group_association" "frr" {
for_each = local.routers
network_interface_id = azurerm_network_interface.frr[each.key].id
network_security_group_id = azurerm_network_security_group.frr.id
}
resource "azurerm_linux_virtual_machine" "frr" {
for_each = local.routers
name = "${var.name}-frr-${each.key}"
computer_name = "frr-${each.key}"
location = var.location
resource_group_name = var.resource_group_name
size = var.vm_size
zone = each.value.zone
admin_username = var.admin_username
disable_password_authentication = true
admin_ssh_key {
username = var.admin_username
public_key = var.ssh_public_key
}
network_interface_ids = [azurerm_network_interface.frr[each.key].id]
os_disk {
caching = "ReadWrite"
storage_account_type = "StandardSSD_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts-gen2"
version = "latest"
}
custom_data = base64encode(templatefile("${path.module}/cloud-init.yaml.tftpl", {
router_ip = each.value.ip
ce_ips = var.ce_ips
rs_peer_ips = var.rs_peer_ips
ce_asn = var.ce_asn
bootstrap_script = file("${path.module}/bootstrap.sh")
vip_translation_script = file("${path.module}/vip_translation.py")
frr_asn = var.frr_asn
rs_asn = var.rs_asn
vip = var.vip
}))
tags = var.tags
depends_on = [azurerm_network_interface_security_group_association.frr]
}
# The Route Server CLI and live qualification use Network API 2022-01-01.
# Separate resources order the peers and give each operation its own deadline.
resource "azapi_resource" "route_server_primary" {
type = "Microsoft.Network/virtualHubs/bgpConnections@2022-01-01"
name = "${var.name}-frr-20-bgp"
parent_id = var.route_server_id
body = {
properties = {
peerAsn = var.frr_asn
peerIp = local.routers["20"].ip
}
}
response_export_values = ["properties.provisioningState"]
locks = [var.route_server_id]
timeouts {
create = "30m"
update = "30m"
delete = "30m"
}
depends_on = [azurerm_linux_virtual_machine.frr]
}
resource "azapi_resource" "route_server_secondary" {
type = "Microsoft.Network/virtualHubs/bgpConnections@2022-01-01"
name = "${var.name}-frr-21-bgp"
parent_id = var.route_server_id
body = {
properties = {
peerAsn = var.frr_asn
peerIp = local.routers["21"].ip
}
}
response_export_values = ["properties.provisioningState"]
locks = [var.route_server_id]
timeouts {
create = "30m"
update = "30m"
delete = "30m"
}
depends_on = [azapi_resource.route_server_primary]
}
output "peer_ips" {
value = [for key in sort(keys(local.routers)) : local.routers[key].ip]
}
output "vm_names" {
value = [for key in sort(keys(local.routers)) : azurerm_linux_virtual_machine.frr[key].name]
}
modules/azure-frr/variables.tf
variable "name" { type = string }
variable "location" { type = string }
variable "resource_group_name" { type = string }
variable "mgmt_subnet_id" { type = string }
variable "mgmt_subnet_prefix" { type = string }
variable "route_server_id" { type = string }
variable "rs_peer_ips" { type = list(string) }
variable "ce_ips" { type = list(string) }
variable "vip" { type = string }
variable "ce_asn" { type = number }
variable "rs_asn" { type = number }
variable "frr_asn" {
type = number
default = 65020
}
variable "vm_size" {
type = string
default = "Standard_B2s"
}
variable "admin_username" { type = string }
variable "ssh_public_key" { type = string }
variable "tags" { type = map(string) }
modules/azure-frr/versions.tf
terraform {
required_version = "= 1.16.3"
required_providers {
azapi = {
source = "Azure/azapi"
version = "= 2.12.0"
}
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
modules/azure-hub/main.tf
resource "azurerm_resource_group" "this" {
name = var.resource_group_name
location = var.location
tags = var.tags
}
resource "azurerm_virtual_network" "hub" {
name = "hub-vnet"
address_space = [var.hub_cidr]
location = azurerm_resource_group.this.location
resource_group_name = azurerm_resource_group.this.name
tags = var.tags
}
resource "azurerm_subnet" "management" {
name = "snet-hub-management"
resource_group_name = azurerm_resource_group.this.name
virtual_network_name = azurerm_virtual_network.hub.name
address_prefixes = [var.mgmt_subnet_prefix]
}
# FRR advertises the VIP to Route Server for clients. Propagating that route
# back onto its own subnet causes Azure to forward the next-hop packets to FRR
# again, despite the guest route pointing at the CE.
resource "azurerm_route_table" "management" {
count = var.enable_route_server ? 1 : 0
name = "${var.route_server_name}-management"
location = var.location
resource_group_name = azurerm_resource_group.this.name
bgp_route_propagation_enabled = false
tags = var.tags
}
resource "azurerm_subnet_route_table_association" "management" {
count = var.enable_route_server ? 1 : 0
subnet_id = azurerm_subnet.management.id
route_table_id = azurerm_route_table.management[0].id
}
resource "azurerm_subnet" "external" {
name = "snet-hub-external"
resource_group_name = azurerm_resource_group.this.name
virtual_network_name = azurerm_virtual_network.hub.name
address_prefixes = [var.external_subnet_prefix]
}
resource "azurerm_subnet" "internal" {
name = "snet-hub-internal"
resource_group_name = azurerm_resource_group.this.name
virtual_network_name = azurerm_virtual_network.hub.name
address_prefixes = [var.internal_subnet_prefix]
}
# RouteServerSubnet: name is literal, /27, and has NO NSG and NO route table
# association (both are unsupported on the Route Server subnet).
resource "azurerm_subnet" "route_server" {
count = var.enable_route_server ? 1 : 0
name = "RouteServerSubnet"
resource_group_name = azurerm_resource_group.this.name
virtual_network_name = azurerm_virtual_network.hub.name
address_prefixes = [var.route_server_subnet_prefix]
}
resource "azurerm_public_ip" "route_server" {
count = var.enable_route_server ? 1 : 0
name = "${var.route_server_name}-pip"
location = azurerm_resource_group.this.location
resource_group_name = azurerm_resource_group.this.name
allocation_method = "Static"
sku = "Standard"
tags = var.tags
}
# Azure Route Server. ASN is fixed by Azure at 65515; virtual_router_ips are the
# two BGP peer addresses (10.0.4.4 / 10.0.4.5) the CEs peer to.
resource "azurerm_route_server" "this" {
count = var.enable_route_server ? 1 : 0
name = var.route_server_name
location = azurerm_resource_group.this.location
resource_group_name = azurerm_resource_group.this.name
sku = "Standard"
public_ip_address_id = azurerm_public_ip.route_server[0].id
subnet_id = azurerm_subnet.route_server[0].id
tags = var.tags
}
# --------------------------------------------------------------------------
# Azure Bastion — reachability for the CE Site Console web UI (TCP 65500)
# --------------------------------------------------------------------------
# F5's documented CE troubleshooting path is the per-node Site Console at
# https://<node-ip>:65500 as `admin`. Verified on this deployment: of a CE's three
# private addresses only the SLI one (snet-hub-internal) answers at all — mgmt/SLO
# and external are closed on every port probed — and it is reachable only from
# inside the VNet. Bastion closes the gap without distributing SSH keys and
# without touching the CE: who may connect becomes an Azure RBAC decision.
#
# AzureBastionSubnet: name is literal, /26 or larger, and — like
# RouteServerSubnet above — deliberately carries NO NSG and NO route table.
# Bastion works without an NSG; adding one obliges you to maintain Azure's exact
# required allow-rule set (GatewayManager, AzureLoadBalancer, the 8080/5701
# intra-subnet pair) for no security gain in a lab whose VMs already sit behind
# their own NSGs.
resource "azurerm_subnet" "bastion" {
count = var.enable_bastion ? 1 : 0
name = "AzureBastionSubnet"
resource_group_name = azurerm_resource_group.this.name
virtual_network_name = azurerm_virtual_network.hub.name
address_prefixes = [var.bastion_subnet_prefix]
}
resource "azurerm_public_ip" "bastion" {
count = var.enable_bastion ? 1 : 0
name = "${var.bastion_name}-pip"
location = azurerm_resource_group.this.location
resource_group_name = azurerm_resource_group.this.name
allocation_method = "Static"
sku = "Standard"
tags = var.tags
}
# Standard SKU is not a preference — Basic supports neither native-client
# tunneling nor IP-based connection.
#
# tunneling_enabled -> load-bearing. `az network bastion tunnel` forwards an
# arbitrary TCP port, which is the only way to reach 65500.
# ip_connect_enabled -> NOT the path to 65500, and deliberately not claimed to
# be. Microsoft documents that "custom ports and protocols
# aren't currently supported when connecting to a virtual
# machine via native client with IP-based connections"
# (learn.microsoft.com/azure/bastion/connect-ip-address),
# and the CLI enforces it: --target-ip-address with
# --resource-port 65500 is refused with "Allowed ports for
# Tunnel with IP connect is 22, 3389". It is on because it
# costs nothing and lets an operator target an in-VNet IP
# directly for portal RDP/SSH. The 65500 tunnel targets the
# CE by VM resource id instead — verified reaching the Site
# Console (HTTP 200) on this deployment.
resource "azurerm_bastion_host" "this" {
count = var.enable_bastion ? 1 : 0
name = var.bastion_name
location = azurerm_resource_group.this.location
resource_group_name = azurerm_resource_group.this.name
sku = "Standard"
tunneling_enabled = true
ip_connect_enabled = true
ip_configuration {
name = "configuration"
subnet_id = azurerm_subnet.bastion[0].id
public_ip_address_id = azurerm_public_ip.bastion[0].id
}
tags = var.tags
}
modules/azure-hub/outputs.tf
output "resource_group_name" {
description = "Name of the hub resource group."
value = azurerm_resource_group.this.name
}
output "location" {
description = "Azure region."
value = azurerm_resource_group.this.location
}
output "vnet_name" {
description = "Hub VNet name."
value = azurerm_virtual_network.hub.name
}
output "vnet_id" {
description = "Hub VNet resource ID."
value = azurerm_virtual_network.hub.id
}
output "management_subnet_id" {
description = "snet-hub-management subnet ID."
value = azurerm_subnet.management.id
}
output "external_subnet_id" {
description = "snet-hub-external subnet ID."
value = azurerm_subnet.external.id
}
output "internal_subnet_id" {
description = "snet-hub-internal subnet ID."
value = azurerm_subnet.internal.id
}
output "route_server_subnet_id" {
description = "RouteServerSubnet subnet ID."
value = one(azurerm_subnet.route_server[*].id)
}
output "route_server_id" {
description = "Azure Route Server resource ID."
value = one(azurerm_route_server.this[*].id)
}
output "rs_peer_ips" {
description = "The two Route Server BGP peer IPs (virtual_router_ips) the CEs peer to."
value = flatten(azurerm_route_server.this[*].virtual_router_ips)
}
output "rs_asn" {
description = "Route Server ASN (fixed by Azure at 65515)."
value = one(azurerm_route_server.this[*].virtual_router_asn)
}
output "bastion_name" {
description = "Azure Bastion host name, or null when Bastion is not deployed. This is the --name argument of `az network bastion tunnel`."
value = one(azurerm_bastion_host.this[*].name)
}
output "bastion_subnet_id" {
description = "AzureBastionSubnet subnet ID, or null when Bastion is not deployed."
value = one(azurerm_subnet.bastion[*].id)
}
output "bastion_subnet_prefix" {
description = "The AzureBastionSubnet prefix this module was given (echoed so callers can assert on it without reading a count-gated resource)."
value = var.bastion_subnet_prefix
}
modules/azure-hub/variables.tf
variable "resource_group_name" {
description = "Resource group to create for the hub."
type = string
}
variable "location" {
description = "Azure region."
type = string
}
variable "hub_cidr" {
description = "Hub VNet address space."
type = string
}
variable "mgmt_subnet_prefix" {
description = "snet-hub-management prefix."
type = string
}
variable "external_subnet_prefix" {
description = "snet-hub-external prefix."
type = string
}
variable "internal_subnet_prefix" {
description = "snet-hub-internal prefix."
type = string
}
variable "route_server_subnet_prefix" {
description = "RouteServerSubnet prefix (/27, no NSG, no route table)."
type = string
}
variable "route_server_name" {
description = "Azure Route Server name."
type = string
default = "ce-ha-lab-rrs"
}
variable "enable_route_server" {
description = "Create Azure Route Server resources. Keep false for the supported ILB-based SMSv2 showcase; true requires the separately validated eBGP multihop contract gate."
type = bool
default = false
}
variable "bastion_subnet_prefix" {
description = "AzureBastionSubnet prefix (/26 or larger, named literally AzureBastionSubnet, no NSG and no route table). Only read when enable_bastion is true."
type = string
}
variable "enable_bastion" {
description = "Deploy Azure Bastion (AzureBastionSubnet + public IP + Standard-SKU host) so the CE Site Console web UI on TCP 65500 is reachable without a jump host. Opt-in: Bastion bills hourly whether or not anyone connects."
type = bool
default = false
}
variable "bastion_name" {
description = "Azure Bastion host name (the --name argument of `az network bastion tunnel`)."
type = string
default = "ce-ha-lab-bastion"
}
variable "tags" {
description = "Tags applied to every resource."
type = map(string)
default = {}
}
modules/azure-hub/versions.tf
terraform {
required_version = ">= 1.8"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
modules/azure-ilb-app/main.tf
resource "xcsh_virtual_site" "inside" {
name = var.name
namespace = var.namespace
labels = var.labels
site_type = "CUSTOMER_EDGE"
site_selector {
expressions = ["canada-topology in (${var.labels["canada-topology"]})"]
}
}
resource "xcsh_http_loadbalancer" "inside" {
name = "${var.name}-http"
namespace = var.namespace
description = "Regional Azure ILB application on specified inside VIP ${var.vip}."
domains = [var.domain]
labels = var.labels
http { port = 80 }
advertise_custom {
advertise_where {
port = 80
virtual_site_with_vip {
ip = var.vip
network = "SITE_NETWORK_SPECIFIED_VIP_INSIDE"
virtual_site {
name = xcsh_virtual_site.inside.name
namespace = xcsh_virtual_site.inside.namespace
}
}
}
}
default_route_pools {
pool {
name = var.origin_pool_name
namespace = var.namespace
}
weight = 1
priority = 1
}
round_robin = {}
no_challenge = {}
user_id_client_ip = {}
disable_waf = {}
disable_rate_limit = {}
disable_api_discovery = {}
disable_api_testing = {}
disable_api_definition = {}
l7_ddos_protection {}
service_policies_from_namespace = {}
disable_trust_client_ip_headers = {}
disable_malicious_user_detection = {}
disable_malware_protection = {}
disable_threat_mesh = {}
default_sensitive_data_policy = {}
}
output "domain" { value = var.domain }
output "vip" { value = var.vip }
modules/azure-ilb-app/variables.tf
variable "name" { type = string }
variable "namespace" { type = string }
variable "domain" { type = string }
variable "vip" { type = string }
variable "origin_pool_name" { type = string }
variable "labels" {
type = map(string)
validation {
condition = try(length(var.labels["canada-topology"]) > 0, false)
error_message = "The inside virtual site requires the exact regional canada-topology label."
}
}
modules/azure-ilb-app/versions.tf
terraform {
required_version = "= 1.16.3"
required_providers {
xcsh = {
source = "f5-sales-demo/xcsh"
version = "= 15.0.3"
}
}
}

modules/azure-registration-mapping/main.tf

Section titled “modules/azure-registration-mapping/main.tf”
modules/azure-registration-mapping/main.tf
locals {
macs = { for role, mac in var.macs : role => lower(replace(coalesce(mac, "unbound"), "-", ":")) }
nodes = [for record in var.records : record
if record.site == var.site && record.hostname == var.hostname && record.provider == "AZURE" &&
!contains(["RETIRED", "FAILED", "FAILED_INACTIVE", "DONE"], record.state) &&
alltrue([for mac in values(local.macs) : contains([for nic in record.network : lower(replace(nic.mac, "-", ":"))], mac)])
]
network = var.runtime_required ? coalesce(var.runtime_network, []) : try(one(local.nodes).network, [])
matches = { for role, mac in local.macs : role => [for nic in local.network : nic.device if lower(replace(nic.mac, "-", ":")) == mac] }
valid = (length(local.nodes) == 1 && length(distinct(values(local.macs))) == 3 &&
alltrue([for matches in values(local.matches) : length(matches) == 1]) &&
try(one(local.matches.slo), "") == "eth0" &&
toset([for matches in values(local.matches) : try(one(matches), "")]) == toset(["eth0", "eth1", "eth2"]))
devices = { for role, matches in local.matches : role => try(one(matches), "") }
}
output "valid" { value = local.valid }
output "devices" { value = local.devices }

modules/azure-registration-mapping/variables.tf

Section titled “modules/azure-registration-mapping/variables.tf”
modules/azure-registration-mapping/variables.tf
variable "site" { type = string }
variable "hostname" { type = string }
variable "macs" { type = map(string) }
variable "records" {
type = list(object({
site = string
hostname = string
provider = string
state = string
network = list(object({ device = string, mac = string }))
}))
}
variable "runtime_required" {
description = "Admitted nodes require fresh runtime device facts instead of the registration snapshot."
type = bool
default = false
}
variable "runtime_network" {
description = "Current physical devices observed in exact owned node runtime health."
type = list(object({ device = string, mac = string }))
default = null
}

modules/azure-registration-mapping/versions.tf

Section titled “modules/azure-registration-mapping/versions.tf”
modules/azure-registration-mapping/versions.tf
terraform {
required_version = ">= 1.8"
}
modules/ce-node/main.tf
# Per-CE Azure resources: managed identity, three NICs (mgmt/external/internal,
# all with IP forwarding on and accelerated networking OFF), and the volterra-node
# VM. The mgmt NIC is the VM's FIRST NIC = eth0 = the SLO/BGP local address.
resource "azurerm_user_assigned_identity" "this" {
name = "${var.hostname}-identity"
resource_group_name = var.resource_group_name
location = var.location
tags = var.tags
}
resource "azurerm_public_ip" "mgmt" {
name = "${var.hostname}-mgmt-pip"
resource_group_name = var.resource_group_name
location = var.location
allocation_method = "Static"
sku = "Standard"
tags = var.tags
}
# eth0 / SLO — mgmt subnet, static private IP (the BGP local address), public IP.
resource "azurerm_network_interface" "mgmt" {
name = "${var.hostname}-mgmt-nic"
resource_group_name = var.resource_group_name
location = var.location
ip_forwarding_enabled = true
accelerated_networking_enabled = false
ip_configuration {
name = "ipconfig1"
subnet_id = var.mgmt_subnet_id
private_ip_address_allocation = "Static"
private_ip_address = var.mgmt_private_ip
public_ip_address_id = azurerm_public_ip.mgmt.id
}
tags = var.tags
}
resource "azurerm_network_interface" "external" {
name = "${var.hostname}-external-nic"
resource_group_name = var.resource_group_name
location = var.location
ip_forwarding_enabled = true
accelerated_networking_enabled = false
ip_configuration {
name = "ipconfig1"
subnet_id = var.external_subnet_id
private_ip_address_allocation = "Dynamic"
}
tags = var.tags
}
resource "azurerm_network_interface" "internal" {
name = "${var.hostname}-internal-nic"
resource_group_name = var.resource_group_name
location = var.location
ip_forwarding_enabled = true
accelerated_networking_enabled = false
ip_configuration {
name = "ipconfig1"
subnet_id = var.internal_subnet_id
private_ip_address_allocation = "Dynamic"
}
tags = var.tags
}
# A generation exists before guest boot and changes with every boot input or NIC
# replacement. The XC site and VM use the same generation for replacement.
resource "random_uuid" "generation" {
keepers = {
hostname = var.hostname
custom_data = sha256(var.custom_data)
image = "f5xc-ce-crt-20260201/20260201.0178.1"
vm_size = var.vm_size
location = var.location
zone = var.zone
admin_user = var.admin_username
ssh_key = sha256(var.ssh_public_key)
disk_size = tostring(var.os_disk_size_gb)
mgmt_nic = azurerm_network_interface.mgmt.id
external_nic = azurerm_network_interface.external.id
internal_nic = azurerm_network_interface.internal.id
}
}
modules/ce-node/outputs.tf
output "mgmt_nic_id" {
description = "Resource ID of the eth0/SLO (mgmt) NIC."
value = azurerm_network_interface.mgmt.id
}
output "mgmt_nic_mac" {
description = "MAC address of the eth0/SLO (mgmt) NIC. Wired into the XC site interface binding so a NIC recreate updates the site."
value = azurerm_network_interface.mgmt.mac_address
}
output "mgmt_private_ip" {
description = "Private IP of the eth0/SLO (mgmt) NIC — the BGP local address and the Route Server BGP connection peer_ip."
value = azurerm_network_interface.mgmt.private_ip_address
}
output "sli_private_ip" {
description = "Private IP of the internal/SLI NIC — the only one of the CE's three private addresses that answers, and where the Site Console web UI (TCP 65500) is served. Reach it from a workstation with `az network bastion tunnel --target-resource-id <this CE's vm_id> --resource-port 65500`; the mgmt/SLO and external addresses serve nothing."
value = azurerm_network_interface.internal.private_ip_address
}
output "identity_id" {
description = "User-assigned managed identity resource ID."
value = azurerm_user_assigned_identity.this.id
}
output "internal_nic_id" {
description = "Resource ID of the CE inside NIC used by the ILB application and console pools."
value = azurerm_network_interface.internal.id
}
output "generation_id" {
description = "Pre-boot generation shared by the managed site and CE VM."
value = random_uuid.generation.result
}
output "network" {
value = {
mgmt_nic_id = azurerm_network_interface.mgmt.id
external_nic_id = azurerm_network_interface.external.id
internal_nic_id = azurerm_network_interface.internal.id
identity_id = azurerm_user_assigned_identity.this.id
generation_id = random_uuid.generation.result
}
}
output "inside_nic_mac" { value = azurerm_network_interface.internal.mac_address }
output "external_nic_mac" { value = azurerm_network_interface.external.mac_address }
output "mgmt_public_ip" { value = azurerm_public_ip.mgmt.ip_address }
modules/ce-node/variables.tf
variable "hostname" {
description = "CE VM hostname (also the Azure VM name), e.g. f5-xc-ce-vm-01."
type = string
}
variable "resource_group_name" {
description = "Resource group (created by the hub module)."
type = string
}
variable "location" {
description = "Azure region."
type = string
}
variable "zone" {
description = "Availability zone for the VM (1, 2 or 3)."
type = string
}
variable "vm_size" {
description = "VM size."
type = string
default = "Standard_D8_v4"
}
variable "mgmt_subnet_id" {
description = "snet-hub-management subnet ID (eth0/SLO)."
type = string
}
variable "external_subnet_id" {
description = "snet-hub-external subnet ID."
type = string
}
variable "internal_subnet_id" {
description = "snet-hub-internal subnet ID."
type = string
}
variable "mgmt_private_ip" {
description = "Static private IP for the eth0/SLO (mgmt) NIC — the BGP local address (e.g. 10.0.1.4)."
type = string
}
variable "admin_username" {
description = "SSH admin username."
type = string
default = "azureuser"
}
variable "ssh_public_key" {
description = "SSH public key MATERIAL (string), passed down from the root."
type = string
}
variable "custom_data" {
description = "Base64-encoded cloud-init custom data for the volterra-node bootstrap."
type = string
}
variable "tags" {
description = "Tags applied to every resource."
type = map(string)
default = {}
}
# Sized from measurement, not from a rule of thumb. Issue #714 ran one disposable
# single-node Azure Secure Mesh v2 site per size, all from marketplace image 0.9.2,
# installing the pair the tenant advertises (crt-20260201-0179 + OS 9.2026.14):
#
# 31 GiB (the image default) FAIL — voucher DaemonSet 0/1, site stuck
# PROVISIONING with nothing installed
# 33 GB PASS
# 36 / 40 / 48 / 64 GB PASS
#
# The default below is deliberately NOT the measured 33 GB floor. 33 works for that
# pair on that image today; a build with a marginally larger payload would fail there
# with no configuration change and no obvious cause — exactly the position the image
# default is in now.
#
# Do NOT size this from F5's documented "20 GB plus 15% of capacity" pre-upgrade
# figure. That check gates Console UI upgrades only and does not apply to the API
# path; applied here it predicts 48 GB would fail, and 48 GB installs cleanly.
variable "os_disk_size_gb" {
description = "CE OS disk size in GB. The pinned f5xc-ce-crt-20260201:20260201.0178.1 marketplace image requires at least 78 GiB; the default retains a small deterministic margin."
type = number
default = 80
validation {
condition = var.os_disk_size_gb >= 78
error_message = "os_disk_size_gb must be at least 78 GB because the pinned f5xc-ce-crt-20260201:20260201.0178.1 marketplace image cannot create a smaller OS disk."
}
}
modules/ce-node/versions.tf
terraform {
required_version = ">= 1.8"
required_providers {
random = {
source = "hashicorp/random"
version = "~> 3.0"
}
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
modules/ce-topology/main.tf
# Pure computation module: expands ce_count into the per-CE node map that drives
# the ce-node and xc-site for_each loops. Has NO
# providers and NO data sources, so it is fully hermetic and plan-testable at
# any N without Azure/XC credentials (see tests/n_scaling.tftest.hcl).
terraform {
required_version = ">= 1.8"
}
variable "ce_count" {
description = "Number of CE nodes (1..3)."
type = number
default = 3
validation {
condition = var.ce_count >= 1 && var.ce_count <= 3
error_message = "ce_count must be between 1 and 3."
}
}
variable "enabled" {
description = "Whether this regional topology participates in the current plan. Disabled topologies deliberately expand to no nodes without weakening the 1..3 validation for enabled deployments."
type = bool
default = true
}
variable "region_short" {
description = "Short region token (e.g. eastus)."
type = string
default = "eastus"
}
variable "mgmt_subnet_prefix" {
description = "Management subnet prefix. The eth0/SLO IP is derived from it (4 + index)."
type = string
default = "10.0.1.0/26"
}
variable "hostname_prefix" {
description = "Prefix for CE VM hostnames (hostname = <prefix>-0<n>)."
type = string
default = "f5-xc-ce-vm"
}
variable "site_prefix" {
description = "Prefix for XC site names (site = <prefix>-<region_short>0<n>). REQUIRED, deliberately without a default: the root passes var.component, so every object name in the deployment derives from one place and no deployment-specific prefix can hide in a module default."
type = string
validation {
condition = can(regex("^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", var.site_prefix))
error_message = "site_prefix must be a DNS-style label: lowercase alphanumerics and hyphens, not starting or ending with a hyphen (it becomes part of an XC object name)."
}
}
locals {
ce_nodes = var.enabled ? {
for i in range(var.ce_count) : "${var.region_short}0${i + 1}" => {
index = i
hostname = "${var.hostname_prefix}-0${i + 1}"
site_name = "${var.site_prefix}-${var.region_short}0${i + 1}"
# eth0/SLO IP: 10.0.1.4, .5, .6 for i = 0, 1, 2.
slo_ip = cidrhost(var.mgmt_subnet_prefix, 4 + i)
# Availability zone 1, 2, 3.
az = element(["1", "2", "3"], i)
# The network_interface object XC auto-creates for the explicit SLO
# interface. The bgp peer references it by this exact name.
interface_name = "ves-io-securemesh-site-v2-${var.site_prefix}-${var.region_short}0${i + 1}-network-${var.hostname_prefix}-0${i + 1}-eth0-0"
}
} : {}
}
output "ce_nodes" {
description = "Map keyed by <region_short>0<n> of per-CE attributes (hostname, site_name, slo_ip, az, interface_name, index)."
value = local.ce_nodes
}
output "ce_count" {
description = "Number of CE nodes expanded."
value = length(local.ce_nodes)
}
modules/ce-vm/main.tf
# The caller depends on the managed XC site, so guest boot follows site creation.
resource "terraform_data" "generation" {
input = var.network.generation_id
}
resource "azurerm_linux_virtual_machine" "this" {
name = var.hostname
computer_name = var.hostname
resource_group_name = var.resource_group_name
location = var.location
size = var.vm_size
zone = var.zone
admin_username = var.admin_username
disable_password_authentication = true
admin_ssh_key {
username = var.admin_username
public_key = var.ssh_public_key
}
# eth0 first = mgmt/SLO NIC (BGP local + MAC-bound to the XC site interface).
network_interface_ids = [
var.network.mgmt_nic_id,
var.network.external_nic_id,
var.network.internal_nic_id,
]
identity {
type = "UserAssigned"
identity_ids = [var.network.identity_id]
}
os_disk {
name = "${var.hostname}-osdisk"
caching = "ReadWrite"
storage_account_type = "StandardSSD_LRS"
# Explicit, because the image default is the one size measured to fail the
# advertised version pair (#714). See variables.tf for the measurements.
disk_size_gb = var.os_disk_size_gb
}
source_image_reference {
publisher = "f5-networks"
offer = "f5xc_customer_edge"
sku = "f5xc-ce-crt-20260201"
version = "20260201.0178.1"
}
# Marketplace plan is REQUIRED for this exact certified Customer Edge image.
plan {
name = "f5xc-ce-crt-20260201"
product = "f5xc_customer_edge"
publisher = "f5-networks"
}
custom_data = var.custom_data
# Required for Azure Serial Console, which is the only way into a CE that has not
# finished its first boot: the vpm/debug API used for every other diagnostic is
# reached through the XC control plane, so it answers only once the node is ONLINE,
# and operator SSH depends on cloud-init having written admin's authorized_keys and
# on the SLI interface being up. Empty block = Azure-managed storage, so there is
# no diagnostics storage account or access key to own.
boot_diagnostics {}
tags = var.tags
depends_on = [terraform_data.generation]
lifecycle {
replace_triggered_by = [terraform_data.generation]
}
}
modules/ce-vm/outputs.tf
output "vm_name" { value = azurerm_linux_virtual_machine.this.name }
output "vm_id" { value = azurerm_linux_virtual_machine.this.id }
output "vm_instance_id" { value = azurerm_linux_virtual_machine.this.virtual_machine_id }
modules/ce-vm/variables.tf
variable "hostname" { type = string }
variable "resource_group_name" { type = string }
variable "location" { type = string }
variable "zone" { type = string }
variable "vm_size" { type = string }
variable "admin_username" { type = string }
variable "ssh_public_key" { type = string }
variable "custom_data" { type = string }
variable "os_disk_size_gb" {
type = number
default = 80
}
variable "tags" { type = map(string) }
variable "network" {
type = object({
mgmt_nic_id = string
external_nic_id = string
internal_nic_id = string
identity_id = string
generation_id = string
})
}
modules/ce-vm/versions.tf
terraform {
required_version = ">= 1.8"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
modules/client-vm/main.tf
# Simple Ubuntu test client in snet-hub-internal. Used to generate HTTP traffic
# to the VIP and to read the VNet effective route table (ECMP proof).
resource "azurerm_public_ip" "this" {
name = "${var.name}PublicIP"
resource_group_name = var.resource_group_name
location = var.location
allocation_method = "Static"
sku = "Standard"
tags = var.tags
}
resource "azurerm_network_security_group" "this" {
#checkov:skip=CKV_AZURE_10:Lab NSG - SSH open for demo access
#checkov:skip=CKV_AZURE_160:Lab NSG - HTTP port 80 required for traffic
#checkov:skip=CKV_AZURE_220:Lab NSG - SSH open for demo access
name = "${var.name}NSG"
resource_group_name = var.resource_group_name
location = var.location
dynamic "security_rule" {
for_each = var.allow_ssh ? [1] : []
content {
name = "SSH"
priority = 1001
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = "*"
destination_address_prefix = "*"
}
}
dynamic "security_rule" {
for_each = var.serve_http ? [1] : []
content {
name = "HTTP"
priority = 1002
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "80"
source_address_prefixes = var.http_source_cidrs
destination_address_prefix = "*"
}
}
dynamic "security_rule" {
for_each = var.restrict_ingress ? [1] : []
content {
name = "deny-other-origin-ingress"
priority = 4096
direction = "Inbound"
access = "Deny"
protocol = "*"
source_port_range = "*"
destination_port_range = "*"
source_address_prefix = "*"
destination_address_prefix = "*"
}
}
lifecycle {
precondition {
condition = !var.serve_http || length(var.http_source_cidrs) > 0
error_message = "HTTP origins require a nonempty explicit source ACL."
}
}
tags = var.tags
}
resource "azurerm_network_interface" "this" {
#checkov:skip=CKV_AZURE_119:Lab NIC - public IP required for demo access
name = "${var.name}VMNic"
resource_group_name = var.resource_group_name
location = var.location
ip_configuration {
name = "ipconfig1"
subnet_id = var.subnet_id
private_ip_address_allocation = var.private_ip == "" ? "Dynamic" : "Static"
private_ip_address = var.private_ip == "" ? null : var.private_ip
public_ip_address_id = azurerm_public_ip.this.id
}
tags = var.tags
}
resource "azurerm_network_interface_security_group_association" "this" {
network_interface_id = azurerm_network_interface.this.id
network_security_group_id = azurerm_network_security_group.this.id
}
resource "azurerm_linux_virtual_machine" "this" {
#checkov:skip=CKV_AZURE_50:Lab VM - no extensions required
#checkov:skip=CKV_AZURE_93:Lab VM - platform-managed encryption sufficient
name = var.name
computer_name = var.name
resource_group_name = var.resource_group_name
location = var.location
size = var.vm_size
admin_username = var.admin_username
disable_password_authentication = true
admin_ssh_key {
username = var.admin_username
public_key = var.ssh_public_key
}
network_interface_ids = [azurerm_network_interface.this.id]
# Install the declared ingress ACL before cloud-init can start an origin.
depends_on = [azurerm_network_interface_security_group_association.this]
os_disk {
caching = "ReadWrite"
storage_account_type = "Premium_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts-gen2"
version = "latest"
}
custom_data = var.custom_data != "" ? var.custom_data : null
tags = var.tags
}
output "public_ip" {
description = "Public IP of the test client."
value = azurerm_public_ip.this.ip_address
}
output "private_ip" {
description = "Private IP of the test client."
value = azurerm_network_interface.this.private_ip_address
}
output "vm_name" {
description = "Test client VM name."
value = azurerm_linux_virtual_machine.this.name
}
output "nic_name" {
description = "Test client NIC name (read effective routes from this NIC)."
value = azurerm_network_interface.this.name
}
modules/client-vm/variables.tf
variable "name" {
description = "Test client VM name; every child resource derives from it (<name>PublicIP, <name>NSG, <name>VMNic). REQUIRED, deliberately without a default: the root passes a value derived from var.component, so no deployment-specific name can hide in a module default."
type = string
}
variable "resource_group_name" {
description = "Resource group (created by the hub module)."
type = string
}
variable "location" {
description = "Azure region."
type = string
}
variable "subnet_id" {
description = "snet-hub-internal subnet ID the client attaches to."
type = string
}
variable "vm_size" {
description = "VM size."
type = string
default = "Standard_B2s"
}
variable "admin_username" {
description = "SSH admin username."
type = string
default = "azureuser"
}
variable "ssh_public_key" {
description = "SSH public key MATERIAL (string), passed down from the root."
type = string
}
variable "custom_data" {
description = "Base64-encoded cloud-init custom data."
type = string
default = ""
}
variable "tags" {
description = "Tags applied to every resource."
type = map(string)
default = {}
}
variable "serve_http" {
description = "Allow public HTTP only for the opt-in disposable origin."
type = bool
default = false
}
variable "allow_ssh" {
type = bool
description = "Allow SSH for test clients; the origin uses Azure Run Command instead."
default = true
}
variable "restrict_ingress" {
type = bool
description = "Deny unlisted origin ingress before Azure default rules."
default = false
}
variable "http_source_cidrs" {
type = list(string)
description = "Explicit HTTP source networks for the disposable origin."
default = []
validation {
condition = alltrue([for cidr in var.http_source_cidrs : can(cidrhost(cidr, 0)) && !contains(["0.0.0.0/0", "::/0"], cidr)])
error_message = "HTTP origin sources must be valid CIDRs and cannot allow the whole Internet."
}
}
variable "private_ip" {
description = "Optional reserved private IPv4 address; empty keeps ordinary test clients dynamic."
type = string
default = ""
validation {
condition = var.private_ip == "" || can(cidrhost("${var.private_ip}/32", 0)) && !strcontains(var.private_ip, ":")
error_message = "Reserved client addresses must be IPv4 literals."
}
}
modules/client-vm/versions.tf
terraform {
required_version = ">= 1.8"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
modules/xc-site/main.tf
# Hardware is read by static site name so it cannot defer approval count or
# create a site/VM dependency cycle. The configured phase is a separate plan.
data "xcsh_site_registrations_by_site" "hardware" {
count = var.bind_registered_interfaces ? 1 : 0
namespace = "system"
site_name = var.site_name
}
locals {
role_macs = {
slo = lower(replace(coalesce(var.mgmt_nic_mac, "unbound"), "-", ":"))
sli = lower(replace(coalesce(var.inside_nic_mac, "unbound"), "-", ":"))
external = lower(replace(coalesce(var.external_nic_mac, "unbound"), "-", ":"))
}
registration_records = var.bind_registered_interfaces ? [
for item in coalesce(try(data.xcsh_site_registrations_by_site.hardware[0].items, null), []) : {
site = coalesce(try(item.get_spec.passport.cluster_name, null), "unknown")
hostname = coalesce(try(item.get_spec.infra.hostname, null), "unknown")
provider = coalesce(try(item.get_spec.infra.provider_ref, null), "unknown")
state = coalesce(try(item.object.status.current_state, null), "unknown")
network = [for nic in coalesce(try(item.get_spec.infra.hw_info.network, null), []) : {
device = coalesce(nic.name, "unknown")
mac = coalesce(nic.mac_address, "unbound")
}]
}
] : []
runtime_required = (var.bind_registered_interfaces && data.xcsh_site_registration.this.found &&
contains(["APPROVED", "ADMITTED", "ONLINE", "UPGRADING", "MAINTENANCE"], coalesce(data.xcsh_site_registration.this.state, "UNKNOWN")))
binding_valid = !var.bind_registered_interfaces || module.registration_mapping.valid
devices = var.bind_registered_interfaces ? module.registration_mapping.devices : { slo = "eth0", sli = "eth1", external = "eth2" }
interfaces = [for device in ["eth0", "eth1", "eth2"] : {
device = device
mac = var.bind_registered_interfaces ? try(one([for role, name in local.devices : local.role_macs[role] if name == device]), "") : ""
inside = device == local.devices.sli
}]
}
module "registration_mapping" {
source = "../azure-registration-mapping"
site = var.site_name
hostname = var.hostname
macs = local.role_macs
records = local.registration_records
runtime_required = local.runtime_required
runtime_network = local.runtime_required ? try(jsondecode(data.external.runtime_interfaces[0].result.network), []) : null
}
# Registration retains pre-upgrade device names. Admitted nodes must use the
# running OS hardware facts; credential values never enter the external query.
data "external" "runtime_interfaces" {
count = local.runtime_required ? 1 : 0
program = ["python3", "${path.module}/../../scripts/xc-azure-runtime-interfaces.py"]
query = {
api_url = "https://${var.labels["canada-xc-tenant"]}.console.ves.volterra.io"
site_name = var.site_name
hostname = var.hostname
role_macs = jsonencode(local.role_macs)
observer_sha256 = filesha256("${path.module}/../../scripts/xc-azure-runtime-interfaces.py")
}
}
# The pre-boot node generation couples site and VM replacement without waiting
# for a running guest, which could register before its configuration exists.
resource "terraform_data" "ce_generation" {
input = var.ce_generation_id
}
# Single-node Secure Mesh v2 CE site with an EXPLICIT eth0/SLO interface. The
# explicit interface is what makes XC auto-create the network_interface object
# (var.interface_name) that the BGP peer binds to — without it a standalone bgp
# object is accepted but never renders to FRR (see xcsh #1207).
resource "xcsh_securemesh_site_v2" "this" {
count = var.create_site ? 1 : 0
name = var.site_name
namespace = "system"
description = "MCN CE-HA (BGP/ECMP) single-node SMSv2 site ${var.site_name} — explicit eth0 SLO interface for BGP peer binding."
# `null`, not `{}`, when no labels are set. xcsh #1286 makes the provider preserve a
# config-declared empty map on the POST-APPLY read-back, but import has no config to
# read: the state carries only id/name/namespace and `ReadRequest` exposes nothing
# else, so a literal `{}` would still re-plan as `+ labels = {}` on the first
# post-import plan. Sending `null` when the map is empty stops asking the provider to
# distinguish "declared empty" from "absent" — something it cannot observe on import.
# (The nested `interface_list.labels {}` marker is a separate class, fixed by xcsh #1244.)
#
# EXPECTED ONE-TIME DRIFT AFTER A NODE (RE)REGISTERS. F5 XC stamps the node's
# hardware facts onto the site as labels (host-os-version, hw-model,
# hw-serial-number, hw-vendor, hw-version) once the CE registers. The next plan
# therefore shows `- labels = {...} -> null` for that site, and applying it
# settles — XC does not re-stamp them. It is one more convergence pass in the
# already two-phase deploy, not drift to chase; observed on the site rebuilt by
# the #674 CE replacement.
labels = length(var.labels) > 0 ? var.labels : null
azure {
not_managed {
node_list {
hostname = var.hostname
type = "Control"
public_ip = null
dynamic "interface_list" {
for_each = local.interfaces
content {
name = interface_list.value.device
ethernet_interface {
device = interface_list.value.device
mac = interface_list.value.mac == null ? "" : interface_list.value.mac
}
network_option {
site_local_network = interface_list.value.inside ? null : {}
site_local_inside_network = interface_list.value.inside ? {} : null
}
dhcp_client = {}
}
}
}
}
}
block_all_services = {}
disable_ha = {}
dns_ntp_config {
f5_dns_default = {}
f5_ntp_default = {}
}
local_vrf {
default_config = {}
default_sli_config = {}
}
logs_streaming_disabled = {}
no_forward_proxy = {}
no_network_policy = {}
no_s2s_connectivity_sli = {}
no_s2s_connectivity_slo = {}
offline_survivability_mode {
no_offline_survivability_mode = {}
}
performance_enhancement_mode {
perf_mode_l7_enhanced {
# The provider schema gives perf_mode_l7_enhanced a {jumbo_disabled | jumbo_enabled}
# sub-oneof. F5 materialises jumbo_disabled server-side, so leaving both members
# undeclared makes the site land and then re-plan the marker as a removal on
# every subsequent plan — it never reaches 0 changes. Declaring the server
# default explicitly is what settles it (same fix coverage/smsv2 took in #625).
jumbo_disabled = {}
}
}
re_select {
geo_proximity = {}
}
# CE software and OS selection is create-time configuration. The node always
# installs a destination build on first boot; an empty variable arms the
# default_* marker and means "install the newest version the server advertises."
# That is the deployment policy, not an accidental omission. The clean
# 2026-08-03 rebuild selected the advertised pair on all three 64 GB nodes and
# brought all three sites ONLINE. Issue #714 separately proves why the disk
# default carries headroom: the same pair failed on the marketplace image's
# 31 GiB disk and installed at every tested size from 33 GB upwards.
#
# Terraform cannot update these fields after creation: PUT is rejected when
# pinning forward, pinning backward, or clearing a pin. The platform can perform
# an in-place change through the site upgrade_sw and upgrade_os actions, but the
# provider cannot drive those actions yet (xcsh#1390). Set a concrete value only
# when deliberately reproducing an older build.
software_settings {
os {
default_os_version = var.os_version == "" ? {} : null
operating_system_version = var.os_version == "" ? null : var.os_version
}
sw {
default_sw_version = var.sw_version == "" ? {} : null
volterra_software_version = var.sw_version == "" ? null : var.sw_version
}
}
# Rebuild the site object whenever the CE VM instance it describes is rebuilt
# (issue #674).
#
# THE FAILURE THIS PREVENTS. A CE's runtime registration is bound to one node
# instance and holds the control plane's unique
# (tenant, cluster_name, hostname) index. Destroying the VM does NOT retire
# that registration, so the replacement node — same site, same hostname —
# cannot create its own: the create fails with UniqueSecondaryIndexViolation
# and retries on a ~65 s loop forever. Nothing recovers on its own, and worse,
# nothing in the graph noticed: with no reference to the node's identity
# anywhere, `terraform plan` reported "No changes" for the whole time the
# fleet was down.
#
# WHY REPLACING THE SITE IS THE FIX. Deleting the site object takes its
# registrations with it — observed live while replacing one CE: the site
# 404ed and the registration bound to the outgoing instance disappeared in the
# same poll — so the replacement node registers into a site whose index key is
# free. Deleting only the registration is not enough: the site keeps a status
# object that then rejects the node's workload request.
#
# The VM waits for this managed site. A generation change destroys the old
# VM/site pair, recreates the site, then boots the replacement guest.
lifecycle {
replace_triggered_by = [terraform_data.ce_generation]
precondition {
condition = local.binding_valid
error_message = "Azure interface binding requires one current owned registration with three unique NIC MACs and exact guest devices."
}
}
}
# The approve API takes the runtime registration name ("r-<uuid>"), NOT the site
# name (GET .../registrations/<site> -> 404). registrations_by_site returns
# HTTP 200 with items:[] for a site whose CE has not registered yet, so this read
# never fails an early apply — it just reports found = false.
#
# NOTE: this data source must never carry depends_on. Its inputs are statically
# derived from ce_topology, so it resolves at plan time; a resource dependency
# would make the count below unknown at plan time ("The count value depends on
# resource attributes that cannot be determined until apply").
data "xcsh_site_registration" "this" {
site_name = var.site_name # == passport.cluster_name (cloud-init ClusterName)
hostname = var.hostname # discriminator for multi-node sites
namespace = "system"
}
# Approve the CE registration so the node reaches ONLINE without the manual
# console step (#1206 / #1210). The registration exists only after the CE boots
# and registers via the token, so the first apply plans no approval; re-apply
# once the CE has registered (see the deploy ordering in main.tf).
#
# The API approves NEW and PENDING registrations. Retired and
# already-admitted registrations keep the same immutable action receipt.
# The published provider verifies their cluster size and performs no new action.
# Keeping the guard in the module rather than relying on provider selection also
# protects installed provider versions that predate terminal-state filtering.
#
# Approval can auto-provision a site in XC, so it must wait for Terraform's
# explicit site creation. The data source deliberately has no dependency: its
# result determines this resource's plan-known count.
resource "xcsh_registration_approval" "this" {
count = var.approve_registration && data.xcsh_site_registration.this.found && contains(["NEW", "PENDING", "APPROVED", "ADMITTED", "ONLINE", "UPGRADING", "MAINTENANCE"], data.xcsh_site_registration.this.state) ? 1 : 0
namespace = "system"
name = data.xcsh_site_registration.this.name
cluster_size = 1
state = "APPROVED"
depends_on = [xcsh_securemesh_site_v2.this]
}
# One bgp object per CE site: eBGP from the CE (ASN var.ce_asn) to two regional FRR routers
# (ASN var.peer_asn), one external peer per router IP, each bound to the explicit SLO interface.
#
# NOT BLOCKED — and nothing about this arm is gated any more. The object-ref name
# length limit that used to block it is gone: the provider relaxed it to
# stringvalidator.LengthBetween(1, 128) in v3.74.0, so the 71-char interface object
# XC auto-generates for the explicit SLO interface
# (ves-io-securemesh-site-v2-<site>-network-<hostname>-eth0-0) validates. The floor
# that guarantees it is declared once, in versions.tf — do not restate the number.
#
# var.enable_bgp therefore defaults true and every test now runs with that default;
# it survives only as an escape hatch for deploying the topology without BGP. It is
# NOT an ordering gate: var.interface_name is derived statically from ce_topology, and
# XC accepts a bgp object naming an interface that does not exist yet (it converges
# once the CE is up — see the deploy ordering in the root main.tf).
resource "xcsh_bgp" "this" {
count = var.enable_bgp ? 1 : 0
name = "${var.site_name}-bgp"
namespace = "system"
description = "CE ${var.site_name} BGP to Azure Route Server via explicit SLO interface."
where {
site {
ref {
namespace = "system"
name = var.site_name
}
network_type = "VIRTUAL_NETWORK_SITE_LOCAL"
disable_internet_vip = {}
}
}
bgp_parameters {
asn = var.ce_asn
# local_address {} = derive the BGP router ID from the interface's local
# address (the JSON's BGP_ROUTER_ID_FROM_INTERFACE; there is no separate
# bgp_router_id_type attribute in the provider schema).
local_address = {}
}
# Iterate over a plan-KNOWN peer count (rs_peer_count) and index into
# rs_peer_ips. The IP values may be unknown until the Route Server is applied,
# but the number of peers is fixed, so the block expands cleanly at plan time.
dynamic "peers" {
for_each = { for i in range(var.peer_count) : "azure-frr-${i + 1}" => i }
content {
metadata {
name = peers.key
}
external {
asn = var.peer_asn
address = try(var.peer_ips[peers.value], "")
port = var.peer_port
interface {
namespace = "system"
name = var.interface_name
}
disable_v6 = {}
}
passive_mode_disabled = {}
bfd_disabled = {}
}
}
}
modules/xc-site/outputs.tf
output "site_name" {
description = "XC securemesh_site_v2 name."
value = var.site_name
}
output "site_created" {
description = "Whether this module instance manages its Secure Mesh Site v2 object."
value = length(xcsh_securemesh_site_v2.this) == 1
}
output "bgp_name" {
description = "XC bgp object name (null when enable_bgp is false)."
value = one(xcsh_bgp.this[*].name)
}
output "interface_name" {
description = "Auto-derived network_interface object name the BGP peer binds to."
value = var.interface_name
}
# The shared pre-boot generation used for site and VM replacement.
output "bound_generation_id" {
description = "Shared pre-boot CE generation that replaces the site and VM together."
value = terraform_data.ce_generation.output
}
output "registration_name" {
description = "Runtime registration name (r-<uuid>) resolved from the site name; null until the CE has registered."
value = data.xcsh_site_registration.this.found ? data.xcsh_site_registration.this.name : null
}
output "registration_state" {
description = "Current registration state reported by XC (NEW, APPROVED, ONLINE, ...); null until the CE has registered."
value = data.xcsh_site_registration.this.found ? data.xcsh_site_registration.this.state : null
}
output "registration_approval_name" {
description = "Name of the approved registration (null when approve_registration is false or the CE has not registered yet)."
value = one(xcsh_registration_approval.this[*].name)
}
output "peer_count" {
description = "Number of external BGP peers configured (one per regional FRR relay; 0 when enable_bgp is false)."
value = var.enable_bgp ? var.peer_count : 0
}
output "interface_count" {
value = nonsensitive(length(one(xcsh_securemesh_site_v2.this).azure.not_managed.node_list[0].interface_list))
}
modules/xc-site/variables.tf
variable "site_name" {
description = "XC securemesh_site_v2 name (e.g. canada-ce-ha-eastus01), created in namespace system."
type = string
}
variable "hostname" {
description = "CE node hostname, must match the Azure VM computer name."
type = string
}
variable "interface_name" {
description = "Auto-derived network_interface object name the BGP peer references: ves-io-securemesh-site-v2-<site>-network-<hostname>-eth0-0."
type = string
}
variable "mgmt_nic_mac" {
description = "Observed MAC of the CE eth0/SLO NIC; null before VM attachment."
type = string
default = null
}
variable "ce_generation_id" {
description = "Pre-boot generation shared by the XC site and Azure CE VM."
type = string
validation {
condition = trimspace(var.ce_generation_id) != ""
error_message = "ce_generation_id must identify the exact planned CE generation."
}
}
variable "peer_ips" {
description = "List of regional FRR BGP peer IPs (virtual_router_ips), e.g. [10.0.4.4, 10.0.4.5]. Values may be unknown until apply; the count of peers comes from peer_count so the peers block expands at plan time."
type = list(string)
}
variable "peer_count" {
description = "Number of external BGP peers (regional FRR always exposes exactly 2 virtual router IPs). Drives the peers block with a plan-known count so for_each never sees an unknown value."
type = number
default = 2
}
variable "ce_asn" {
description = "CE (local) BGP ASN."
type = number
default = 64512
}
variable "peer_asn" {
description = "Route Server (peer) BGP ASN."
type = number
default = 65515
}
variable "peer_port" {
description = "BGP peer TCP port."
type = number
default = 179
}
variable "labels" {
description = "Labels applied to the site object."
type = map(string)
default = {}
}
variable "enable_bgp" {
description = "Create the xcsh_bgp object. Defaults true, and nothing gates it: the object-ref name length blocker that once forced it false was removed in provider v3.74.0. Retained only as an escape hatch for planning or deploying the graph without BGP. See main.tf."
type = bool
default = true
}
variable "create_site" {
description = "Create the xcsh_securemesh_site_v2 resource object. Set false when the site object is auto-provisioned by registration approval."
type = bool
default = true
}
variable "approve_registration" {
description = "Approve the CE's runtime registration (named r-<uuid>, resolved from the site name by the xcsh_site_registration data source) instead of clicking Approve in the console. Two-phase by nature: the registration does not exist until the CE has booted and registered via the token, so the first apply plans no approval and a later apply creates it. For a CE that is ALREADY approved/ONLINE, import the existing approval (namespace/r-<uuid>, see the note in main.tf) rather than letting Terraform create it, since re-approving a non-NEW registration may be rejected. Set false to leave approval to an operator."
type = bool
default = true
}
variable "os_version" {
description = "CE operating system version. Empty deliberately selects the server-advertised latest version; set a value only to reproduce an older build."
type = string
default = ""
}
variable "sw_version" {
description = "CE F5 Distributed Cloud software version. Empty deliberately selects the server-advertised latest build; set a value only to reproduce an older build."
type = string
default = ""
}
variable "inside_nic_mac" {
description = "Observed MAC of the Azure internal/SLI NIC; null before VM attachment."
type = string
default = null
}
variable "external_nic_mac" {
description = "Observed MAC of the Azure external SLO NIC; null before VM attachment."
type = string
default = null
}
variable "bind_registered_interfaces" {
description = "Resolve guest devices from the current registration and owned Azure NIC MACs in the separate configured plan."
type = bool
default = false
}
modules/xc-site/versions.tf
terraform {
required_version = ">= 1.8"
required_providers {
external = {
source = "hashicorp/external"
version = "~> 2.3"
}
xcsh = {
source = "f5-sales-demo/xcsh"
version = "= 15.0.3"
}
}
}
origin.tf
data "xcsh_network_regional_edges" "origin" {
count = var.enable_azure && var.enable_showcase_origin ? 1 : 0
}
data "xcsh_network_cdn" "origin" {
count = var.enable_azure && var.enable_showcase_origin ? 1 : 0
}
resource "terraform_data" "origin_f5_acl_gate" {
count = var.enable_azure && var.enable_showcase_origin ? 1 : 0
input = local.origin_f5_cidrs
lifecycle {
precondition {
condition = (length(local.origin_f5_cidrs) > 0 &&
data.xcsh_network_regional_edges.origin[0].api_release_tag == "v12.0.0" &&
data.xcsh_network_cdn.origin[0].api_release_tag == "v12.0.0")
error_message = "Origin ingress requires nonempty F5 provider CIDRs from the pinned API release."
}
}
}
module "showcase_origin_ca" {
count = var.enable_azure && var.enable_canada && var.enable_showcase_origin ? 1 : 0
source = "./modules/client-vm"
name = "${var.component}-ca-origin${local.deployment_name_suffix}"
resource_group_name = module.azure_hub_ca[0].resource_group_name
location = module.azure_hub_ca[0].location
subnet_id = module.azure_hub_ca[0].management_subnet_id
# CE hosts use 4-6 and FRR uses 20-21 in this subnet.
private_ip = cidrhost(var.ca_mgmt_subnet_prefix, 30)
admin_username = var.admin_username
ssh_public_key = local.ssh_public_key
serve_http = true
allow_ssh = false
restrict_ingress = true
http_source_cidrs = sort(distinct(concat(local.origin_f5_cidrs, local.ca_origin_demo_cidrs, var.origin_developer_cidrs)))
tags = local.tags
depends_on = [terraform_data.origin_f5_acl_gate]
custom_data = base64encode(<<-EOF
#cloud-config
write_files:
- path: /srv/canada-origin/index.html
permissions: '0644'
content: |
canada-showcase-canada-origin
- path: /etc/systemd/system/canada-origin.service
permissions: '0644'
content: |
[Unit]
Description=Ephemeral Canadian Canada showcase HTTP origin
After=network-online.target
[Service]
ExecStart=/usr/bin/python3 -m http.server 80 --bind 0.0.0.0 --directory /srv/canada-origin
Restart=always
[Install]
WantedBy=multi-user.target
runcmd:
- [systemctl, daemon-reload]
- [systemctl, enable, --now, canada-origin]
EOF
)
}
output "ca_origin_ip" {
description = "Canadian origin endpoint for Canadian load balancers and control probes."
value = var.enable_azure && var.enable_canada ? local.selected_ca_origin_ip : null
}
output "ca_origin_ingress_acl" {
value = {
f5_cidrs = local.origin_f5_cidrs
developer_cidrs = var.origin_developer_cidrs
owned_demo_cidrs = local.ca_origin_demo_cidrs
provider_version = "15.0.3"
all_regional_edges = true
}
}
locals {
origin_f5_cidrs = var.enable_showcase_origin ? sort(distinct(concat(
data.xcsh_network_regional_edges.origin[0].cidr_blocks,
data.xcsh_network_cdn.origin[0].cidr_blocks,
))) : []
ca_origin_demo_cidrs = var.enable_canada && var.enable_showcase_origin ? [
for ip in concat(
[for node in module.ce_node_ca : node.mgmt_private_ip],
[for node in module.ce_node_ca : node.mgmt_public_ip],
[module.client_vm_ca[0].private_ip, module.client_vm_ca[0].public_ip],
) : "${ip}/32"
] : []
selected_ca_origin_ip = var.enable_showcase_origin ? module.showcase_origin_ca[0].public_ip : var.ca_origin_ip
}
outputs.tf
output "canada_ilb_private_ip" {
description = "Canadian ILB private address for supported CE Site Console health and traffic verification; null when disabled."
value = try(azurerm_lb.ca_ilb[0].frontend_ip_configuration[0].private_ip_address, null)
}
output "ca_resource_group_name" {
description = "Canadian Azure resource group used for supported ILB verification."
value = try(module.azure_hub_ca[0].resource_group_name, null)
}
output "ca_client_vm_name" {
description = "Canadian test client VM used to probe the Canadian ILB."
value = try(module.client_vm_ca[0].vm_name, null)
}
output "ca_xc_site_names" {
description = "Per-CE Canadian XC site names."
value = { for k, m in module.xc_site_ca : k => m.site_name }
}
output "ca_ce_vm_names" {
description = "Per-CE Canadian VM names used for Azure runtime and extension verification."
value = { for k, m in module.ce_vm_ca : k => m.vm_name }
}
output "ca_lb_domain" {
description = "Domain served by the Canada HTTP load balancer."
value = local.ca_lb_domain
}
output "ca_re_virtual_site_name" {
description = "Name of the Canadian Regional Edge virtual site."
value = try(xcsh_virtual_site.canada_re[0].name, null)
}
output "ca_ce_virtual_site_name" {
description = "Name of the Canadian Customer Edge virtual site."
value = try(xcsh_virtual_site.canada_ce[0].name, null)
}
output "ca_loadbalancer_name" {
description = "Name of the Canadian HTTP load balancer."
value = try(xcsh_http_loadbalancer.canada[0].name, null)
}
output "ca_origin_pool_name" {
description = "Name of the Canadian origin pool."
value = try(xcsh_origin_pool.canada[0].name, null)
}
output "ca_vip" {
description = "Canadian external HA VIP advertised through CE-to-FRR-to-Route-Server BGP. The inside ILB frontend is separate."
value = var.ca_vip
}
output "ca_ilb_id" {
description = "Azure Internal Load Balancer ID for Canadian regional path."
value = try(azurerm_lb.ca_ilb[0].id, null)
}
output "ca_ilb_frontend_ip" {
description = "Azure Internal Load Balancer frontend private IP for Canadian regional path."
value = try(azurerm_lb.ca_ilb[0].frontend_ip_configuration[0].private_ip_address, null)
}
output "ce_registration_token" {
description = "Resolved Azure CE registration token fed to cloud-init, or null when Azure is disabled and no override is supplied."
value = local.ce_registration_token
sensitive = true
}
output "ce_egress_requirements" {
description = "Explicit outbound DNS, NTP, and HTTPS checks derived from the release-pinned CE allowlists."
value = {
api_release_tag = data.xcsh_network_customer_edge_egress.secure_mesh_v2.api_release_tag
dns = {
direction = "egress"
protocols = ["udp", "tcp"]
port = 53
destinations = data.xcsh_network_customer_edge_defaults.system_services.dns_servers
}
ntp = {
direction = "egress"
protocols = ["udp"]
port = 123
destinations = data.xcsh_network_customer_edge_defaults.system_services.ntp_servers
}
https = {
direction = "egress"
protocols = ["tcp"]
port = 443
destinations = data.xcsh_network_customer_edge_egress.secure_mesh_v2.domains
}
}
}
output "canada_ilb_application_domain" {
value = try(xcsh_http_loadbalancer.internal[0].domains[0], null)
}
output "canada_ilb_console_ip" {
value = try(azurerm_lb.ca_ilb[0].frontend_ip_configuration[1].private_ip_address, null)
}
output "canada_frr_peer_ips" {
value = try(module.azure_frr_ca[0].peer_ips, [])
}
output "canada_frr_vm_names" {
value = try(module.azure_frr_ca[0].vm_names, [])
}
output "canada_route_server_name" {
value = local.ca_route_server_name
}
output "canada_client_nic_name" {
value = try(module.client_vm_ca[0].nic_name, null)
}
output "canada_ilb_name" {
value = try(azurerm_lb.ca_ilb[0].name, null)
}
output "canada_ce_mgmt_private_ips" {
value = { for key, node in module.ce_node_ca : key => node.mgmt_private_ip }
}
output "canada_ce_sli_private_ips" {
value = { for key, node in module.ce_node_ca : key => node.sli_private_ip }
}
output "canada_route_server_peer_ips" {
value = try(module.azure_hub_ca[0].rs_peer_ips, [])
}
output "azure_subscription_id" {
value = var.subscription_id
}
output "azure_interface_contract" {
description = "Verified Azure device roles used by bootstrap and MAC-binding phases."
value = local.azure_interface_contract
}
output "canada_public_re" {
description = "Private Canadian public RE acceptance inputs; null when advertisement is disabled."
sensitive = true
value = var.enable_azure && var.enable_canada && var.enable_canada_public_re ? {
allocation = var.ca_re_public_ip
namespace = xcsh_namespace.canada.name
virtual_site = xcsh_virtual_site.canada_re[0].name
re_namespace = xcsh_virtual_site.canada_re[0].namespace
ce_virtual_site = xcsh_virtual_site.canada_ce[0].name
ce_sites = [for site in module.xc_site_ca : site.site_name]
loadbalancer = xcsh_http_loadbalancer.canada[0].name
service_policy = xcsh_service_policy.canada_only[0].name
internal_loadbalancer = xcsh_http_loadbalancer.internal[0].name
pool = xcsh_origin_pool.canada[0].name
domain = local.ca_lb_domain
origin_ip = local.selected_ca_origin_ip
expected_marker = var.enable_showcase_origin ? "canada-showcase-canada-origin" : null
} : null
}
output "ca_ce_vm_ids" {
value = { for k, m in module.ce_vm_ca : k => m.vm_id }
}
output "ca_site_console_admin_passwords" {
sensitive = true
value = { for k, password in random_password.site_console_admin_ca : k => password.result }
}
output "ca_bastion_name" { value = local.ca_bastion_name }
output "deployment_identity" {
value = {
repository = var.source_repository
source_ref = var.source_ref
source_commit = var.source_commit_sha
owner = var.deployment_owner_id
actor = var.deployment_actor_id
}
}
output "canada_internal_application_domain" {
value = try(xcsh_http_loadbalancer.internal[0].domains[0], null)
}
providers.tf
provider "xcsh" { api_url = local.xc_api_url }
provider "azurerm" {
features {}
subscription_id = var.subscription_id
}
provider "azuread" {}
provider "azapi" { subscription_id = var.subscription_id }
securemesh_interface_contract.tf
# Azure always uses eth0 for management SLO; secondary device order varies.
# Resolve inside/external identities by the owned NIC MACs in live registration. Preserve all three devices from first boot, then bind
# observed Azure NIC MACs with a refresh-enabled plan after VM attachment.
variable "azure_site_configuration_phase" {
description = "Azure site bootstrap declares all devices; configured binds observed NIC MACs and permits approval."
type = string
default = "bootstrap"
validation {
condition = contains(["bootstrap", "configured"], var.azure_site_configuration_phase)
error_message = "azure_site_configuration_phase must be bootstrap or configured."
}
}
locals {
azure_interface_contract = {
version = "2.0.0"
devices = { slo = "eth0", secondary = "registered-mac" }
}
}
variables.tf
variable "admin_username" {
description = "SSH admin username for the VMs."
type = string
default = "azureuser"
}
variable "approve_registration" {
description = "Approve each CE's runtime registration from Terraform (the xcsh_site_registration data source resolves the r-<uuid> registration name from the site name, and xcsh_registration_approval approves it). Two-phase by nature: the registration only exists after the CE has booted and registered, so the first apply plans no approval and a later apply creates it. Set false to leave approval to an operator."
type = bool
default = true
}
variable "azure_frr_asn" {
description = "ASN shared by the two independent FRR relays in each Azure region."
type = number
default = 65020
validation {
condition = var.azure_frr_asn >= 64512 && var.azure_frr_asn <= 65534 && !contains([var.ce_asn, var.rs_asn], var.azure_frr_asn)
error_message = "azure_frr_asn must be a private ASN distinct from CE and Route Server ASNs."
}
}
variable "ca_bastion_name" {
description = "Canada Azure Bastion host name. Leave null to derive `<component>-ca-bastion`."
type = string
default = null
}
variable "ca_bastion_subnet_prefix" {
description = "Canada AzureBastionSubnet prefix. MUST be /26 or larger."
type = string
default = "10.200.5.0/26"
validation {
condition = tonumber(split("/", var.ca_bastion_subnet_prefix)[1]) <= 26
error_message = "ca_bastion_subnet_prefix must be /26 or larger."
}
}
variable "ca_ce_count" {
description = "Number of Canadian single-node Secure Mesh v2 CE sites (1..3)."
type = number
default = 3
validation {
condition = var.ca_ce_count >= 1 && var.ca_ce_count <= 3
error_message = "ca_ce_count must be between 1 and 3."
}
}
variable "ca_ce_vsite_name" {
description = "Name of the Canadian Customer Edge virtual site. Leave null (the default) to derive `<component>-ca-ce-vsite`."
type = string
default = null
}
variable "ca_client_vm_name" {
description = "Canada test client VM name. Leave null to derive `<component>-ca-client`."
type = string
default = null
}
variable "ca_external_subnet_prefix" {
description = "Canada snet-hub-external prefix."
type = string
default = "10.200.2.0/26"
}
variable "ca_hub_cidr" {
description = "Canada Hub VNet address space."
type = string
default = "10.200.0.0/16"
}
variable "ca_internal_subnet_prefix" {
description = "Canada snet-hub-internal prefix. Test client lives here."
type = string
default = "10.200.3.0/26"
}
variable "ca_lb_domain" {
description = "Exact public Canadian application hostname."
type = string
validation {
condition = var.ca_lb_domain == "canada.f5-sales-demo.ca"
error_message = "The public hostname must be exactly canada.f5-sales-demo.ca."
}
}
variable "ca_lb_name" {
description = "Name of the Canada HTTP load balancer. Leave null (the default) to derive `<component>-ca-f5se`."
type = string
default = null
}
variable "ca_location" {
description = "Azure region for Canadian regional resources."
type = string
default = "canadacentral"
validation {
condition = contains(["canadacentral", "canadaeast"], var.ca_location)
error_message = "Canadian regional resources must use Canada Central or Canada East."
}
}
variable "ca_mgmt_subnet_prefix" {
description = "Canada snet-hub-management prefix. CE eth0/SLO NICs live here."
type = string
default = "10.200.1.0/26"
}
variable "ca_origin_ip" {
description = "Canadian external origin IPv4 address when the owned showcase origins are disabled. Set this for externally hosted Canadian origins."
type = string
default = null
nullable = true
validation {
condition = var.ca_origin_ip == null || can(cidrhost("${var.ca_origin_ip}/32", 0))
error_message = "ca_origin_ip must be a valid IPv4 address."
}
}
variable "ca_origin_pool_name" {
description = "Name of the Canada origin pool. Leave null (the default) to derive `<component>-ca-pool`."
type = string
default = null
}
variable "ca_re_cities" {
description = "List of cities for the Canadian Regional Edge Virtual Site selector. Defaults to Toronto and Montreal."
type = list(string)
default = ["toronto", "montreal"]
validation {
condition = (
length(var.ca_re_cities) > 0 &&
alltrue([for city in var.ca_re_cities : contains(["toronto", "montreal"], city)])
)
error_message = "Canadian RE advertisement must select Toronto and/or Montreal."
}
}
variable "ca_re_public_ip" {
description = "Dedicated XC public-IP allocation reserved for this Canadian demo. Use an existing unused allocation or obtain an additional one through F5 support; keep the actual allocation in private tfvars."
type = object({
name = string
namespace = string
ip = string
})
default = null
nullable = true
validation {
condition = var.ca_re_public_ip == null || try(
length(var.ca_re_public_ip.name) > 0 &&
length(var.ca_re_public_ip.namespace) > 0 &&
can(cidrhost("${var.ca_re_public_ip.ip}/32", 0)) &&
!strcontains(var.ca_re_public_ip.ip, ":"), false)
error_message = "ca_re_public_ip must identify an allocated IPv4 public-IP object by name and namespace."
}
}
variable "ca_re_vsite_name" {
description = "Name of the Canadian Regional Edge virtual site. Leave null (the default) to derive `<component>-ca-re-vsite`."
type = string
default = null
}
variable "ca_region_short" {
description = "Short region token for Canada site names. Leave null to use var.ca_location."
type = string
default = null
}
variable "ca_resource_group_name" {
description = "Resource group that holds the Canadian hub VNet, Route Server, CE VMs and test client. Leave null (the default) to derive `rg-<component>-ca-<deployer>`."
type = string
default = null
}
variable "ca_route_server_name" {
description = "Canada Azure Route Server name. Leave null to derive `<component>-ca-rs`."
type = string
default = null
}
variable "ca_route_server_subnet_prefix" {
description = "Canada RouteServerSubnet prefix. MUST be exactly /27."
type = string
default = "10.200.4.0/27"
validation {
condition = tonumber(split("/", var.ca_route_server_subnet_prefix)[1]) == 27
error_message = "ca_route_server_subnet_prefix must be a /27."
}
}
variable "ca_site_prefix" {
description = "Prefix for Canadian XC site names (site = <ca_site_prefix>-<ca_region_short>0<n>). Leave null to derive `<component>-ca`."
type = string
default = null
}
variable "ca_vip" {
description = "HA VIP for Canadian CEs advertised as a /32 via eBGP."
type = string
default = "10.250.1.10"
validation {
condition = can(cidrhost("${var.ca_vip}/32", 0))
error_message = "ca_vip must be a valid IPv4 address."
}
}
variable "ce_asn" {
description = "BGP ASN for the Customer Edge nodes (eBGP local ASN)."
type = number
default = 64512
}
variable "ce_os_version" {
description = "CE OS version, set at create time. Empty deliberately selects the newest version advertised by F5 Distributed Cloud; the CE module now enforces an 80 GB disk default for the pinned Marketplace image. A concrete value is for reproducing an older build. Terraform cannot change this field afterwards: updates are rejected in every direction, including un-pinning. The platform can change it in place through the site upgrade_os action, but the provider cannot drive that action yet (xcsh#1390)."
type = string
default = ""
}
variable "ce_sw_version" {
description = "CE F5 Distributed Cloud software version, set at create time. Empty deliberately selects the newest advertised build; the CE module now enforces an 80 GB disk default for the pinned Marketplace image. A concrete value is for reproducing an older build. The node always installs a destination build on first boot. Terraform cannot change this field afterwards, but the platform can change it in place through the site upgrade_sw action (xcsh#1390)."
type = string
default = ""
}
variable "ce_vm_size" {
description = "VM size for the Customer Edge nodes."
type = string
default = "Standard_D8_v4"
}
variable "component" {
description = "Component name used in tags."
type = string
default = "canada-topology"
}
variable "deployer" {
description = "Override for the deployer identifier used in tags (auto-resolved from Azure AD when empty)."
type = string
default = ""
}
variable "deployment_actor_id" {
description = "Non-personal stable identifier for the automation actor that applies the deployment."
type = string
validation {
condition = can(regex("^[a-z][a-z0-9-]{2,62}$", var.deployment_actor_id))
error_message = "deployment_actor_id must be a 3-63 character lowercase non-personal identifier."
}
}
variable "deployment_owner_id" {
description = "Non-personal stable identifier for the team or service that owns the deployment."
type = string
validation {
condition = can(regex("^[a-z][a-z0-9-]{2,62}$", var.deployment_owner_id))
error_message = "deployment_owner_id must be a 3-63 character lowercase non-personal identifier."
}
}
variable "enable_azure" {
description = "Deploy the Azure US and Canada SMSv2 graphs. Keep true for the full showcase; set false only for the separately reviewed AWS-only saved-plan and preflight stage."
type = bool
default = true
}
variable "enable_bastion" {
description = "Deploy Azure Bastion so developers can reach each CE's Site Console web UI (https://<sli-ip>:65500) with Azure RBAC instead of an SSH key and a jump host."
type = bool
default = false
}
variable "enable_bgp" {
description = "Enable both regional CE-to-FRR and FRR-to-Route-Server BGP paths alongside the ILB application path."
type = bool
default = true
}
variable "enable_canada" {
description = "Enable parallel Canada-only regional infrastructure, Canadian virtual sites, and f5-sales-demo.ca load balancer."
type = bool
default = true
}
variable "enable_canada_ilb" {
description = "Deploy the Canadian inside application and Site Console ILB frontends alongside the BGP path."
type = bool
default = true
}
variable "enable_canada_public_re" {
description = "Advertise the Canadian HTTP-LB publicly on Toronto/Montreal using a dedicated allocated XC public IP. Requires ca_re_public_ip."
type = bool
default = false
}
variable "enable_showcase_origin" {
description = "Deploy a disposable Canadian HTTP origin for repeatable traffic verification."
type = bool
default = true
}
variable "environment" {
description = "Environment label used in tags."
type = string
default = "lab"
}
variable "expected_xc_tenant" {
description = "F5 XC tenant this deployment belongs to: the first hostname label of the console URL (`f5-sales-demo` for https://f5-sales-demo.console.ves.volterra.io). This is the ONLY place the tenant is named. The xcsh provider's api_url is derived from it, so the configuration — not the ambient environment — decides which tenant is written to, and a plan FAILS when XCSH_API_URL in the environment names a different one."
type = string
default = "f5-sales-demo"
validation {
# A hostname label, not a URL: the scheme and domain are added in locals.tf.
condition = can(regex("^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", var.expected_xc_tenant))
error_message = "expected_xc_tenant must be a bare DNS label (lowercase letters, digits and hyphens) such as f5-sales-demo — not a URL and not a hostname."
}
}
variable "origin_developer_cidrs" {
description = "Explicit workstation public IPv4 /32 addresses allowed to reach the demo origin for development. Keep allocations in private tfvars."
type = list(string)
default = []
validation {
condition = alltrue([for cidr in var.origin_developer_cidrs : can(cidrhost(cidr, 0)) && endswith(cidr, "/32") && !strcontains(cidr, ":")])
error_message = "Developer origin access requires exact public IPv4 /32 addresses."
}
}
variable "origin_port" {
description = "TCP port of the origin server."
type = number
default = 80
}
variable "registration_token" {
description = "Optional override for the Azure CE cloud-init site registration token. When empty (the default), enabled Azure CE nodes use the provider-generated xcsh_token.ce[0].uid; set a non-empty value to inject an externally-minted token instead."
type = string
default = ""
sensitive = true
}
variable "rs_asn" {
description = "BGP ASN of the Azure Route Server (fixed by Azure at 65515)."
type = number
default = 65515
}
variable "site_prefix" {
description = "Prefix for XC site names (site = <prefix>-<region_short>0<n>). Leave null to use the released SMSv2 identity generation derived from var.component. Renaming a site replaces the CE VM, because the site name is the ClusterName baked into cloud-init and cloud-init only runs on first boot."
type = string
default = null
}
variable "smsv2_site_generation" {
description = "Immutable identity generation appended to the default SMSv2 site prefix. The released default deliberately avoids the legacy canada-ce-ha-* global-name reservations; do not decrement or reuse an earlier generation."
type = string
default = "smsv2"
validation {
condition = can(regex("^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", var.smsv2_site_generation))
error_message = "smsv2_site_generation must be a DNS-style label: lowercase alphanumerics and hyphens, not starting or ending with a hyphen."
}
}
variable "source_commit_sha" {
description = "Immutable lowercase 40-hex commit that was reviewed and used to create the saved plan."
type = string
validation {
condition = can(regex("^[0-9a-f]{40}$", var.source_commit_sha))
error_message = "source_commit_sha must be an immutable lowercase 40-hex Git commit."
}
}
variable "source_ref" {
description = "Exact trusted branch ref of the reviewed source. PR merge refs and abbreviated branch names are rejected."
type = string
validation {
condition = (
can(regex("^refs/heads/[^[:cntrl:][:space:]~^:?*\\\\\\[]+$", var.source_ref)) &&
!strcontains(var.source_ref, "..") &&
!strcontains(var.source_ref, "@{") &&
!strcontains(var.source_ref, "//") &&
!can(regex("(^refs/heads/|/)\\.", var.source_ref)) &&
!can(regex("(\\.|\\.lock)(/|$)", var.source_ref)) &&
var.source_ref != "refs/heads/@" &&
!endswith(var.source_ref, "/")
)
error_message = "source_ref must be a valid exact refs/heads/* ref, never refs/pull/* or an abbreviated branch."
}
}
variable "source_repository" {
description = "Canonical GitHub repository identity of the reviewed source. Only this repository can produce a deployment identity."
type = string
validation {
condition = var.source_repository == "f5-sales-demo/canada"
error_message = "source_repository must be exactly f5-sales-demo/canada."
}
}
variable "ssh_public_key" {
description = "SSH public key MATERIAL (the key string). When empty, read from ssh_public_key_path. Passing material keeps plan tests hermetic."
type = string
default = ""
}
variable "ssh_public_key_path" {
description = "Path to the SSH public key file, read once at the root when ssh_public_key is empty."
type = string
default = "~/.ssh/id_ed25519.pub"
}
variable "subscription_id" {
description = "Azure subscription ID used by the azurerm provider."
type = string
# Default = the <AZURE_SUBSCRIPTION_ID> subscription the lab was built in.
default = "00000000-0000-0000-0000-000000000000"
}
variable "tags" {
description = "Additional tags merged with the standard tags (component/environment/deployer/managed_by)."
type = map(string)
default = {}
}
versions.tf
terraform {
# The automated showcase lifecycle and its test harness execute this exact
# version on the authoritative Ubuntu host.
required_version = "= 1.16.3"
required_providers {
azapi = { source = "Azure/azapi", version = "= 2.12.0" }
xcsh = {
source = "f5-sales-demo/xcsh"
version = "= 15.0.3"
}
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
azuread = {
source = "hashicorp/azuread"
version = "~> 3.0"
}
external = {
source = "hashicorp/external"
version = "~> 2.3"
}
random = {
source = "hashicorp/random"
version = "~> 3.0"
}
}
}