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
Section titled “azure_ilb_xc.tf”# The internal application owns both CE diagnostic and ILB listeners.backend.tf
Section titled “backend.tf”terraform { backend "local" {}}ca_ilb.tf
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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”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”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”terraform { required_version = ">= 1.8"}modules/ce-node/main.tf
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “modules/ce-vm/versions.tf”terraform { required_version = ">= 1.8" required_providers { azurerm = { source = "hashicorp/azurerm" version = "~> 4.0" } }}modules/client-vm/main.tf
Section titled “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
Section titled “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
Section titled “modules/client-vm/versions.tf”terraform { required_version = ">= 1.8"
required_providers { azurerm = { source = "hashicorp/azurerm" version = "~> 4.0" } }}modules/xc-site/main.tf
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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" } }}