Skip to content

Latest commit

Β 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

☁️ Azure Virtual Hub IP Terraform Module

Manages an IP configuration on a virtual hub β€” which the provider's own documentation calls, in its opening sentence, a Route Server β€” targeting hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Caveat


🧩 Overview

  • πŸ›°οΈ Manages one IP configuration on a virtual hub β€” the older, two-resource way of building an Azure Route Server.
  • πŸ”— Wires together an existing hub, a public IP and a subnet, all by full Resource ID.
  • πŸ“ Pins a private address, or lets Azure choose one.
  • 🚩 Reports the requirements Azure enforces and the provider does not: the hub's SKU, its absence of a Virtual WAN, and the subnet's name.
  • 🧭 Points at its own successor. azurerm_route_server builds the same thing in a single record and is the better choice for anything new.

πŸ’‘ Why it matters: the resource is named for what it is technically β€” an IP configuration β€” and not for what it does. A reader who does not know it is a Route Server will size the subnet wrong, name it wrong, and attach a network security group to it. Every one of those plans cleanly and is refused at apply. This module's job is to say so before you run it.


❀️ Support this project

If this module saved you time:


πŸ—ΊοΈ Where this fits in the family

flowchart TB
    HUB["terraform-azurerm-virtual-hub -- Standard SKU, NO virtual_wan_id"]
    HIP["terraform-azurerm-virtual-hub-ip"]
    PIP["terraform-azurerm-public-ip -- Standard, Static"]
    VNET["terraform-azurerm-virtual-network -- a RouteServerSubnet"]
    RS["terraform-azurerm-route-server -- the newer single-resource form"]
    NVA["a network virtual appliance -- BGP peer"]

    HUB -->|"id to virtual_hub_id -- and the hub is LOCKED for every write"| HIP
    PIP -->|"id to public_ip_address_id -- REQUIRED since September 2021"| HIP
    VNET -->|"subnet id to subnet_id -- an ORDINARY network, named RouteServerSubnet"| HIP
    HIP -->|"together these two records ARE an Azure Route Server"| NVA
    RS -.->|"builds the same thing in ONE record -- prefer it for anything new"| HIP

    classDef self fill:#0078D4,stroke:#004578,color:#ffffff
    classDef keystone fill:#004578,stroke:#002B4A,color:#ffffff
    classDef ext fill:#F3F2F1,stroke:#8A8886,color:#201F1E
    class HIP self
    class HUB keystone
    class PIP,VNET,RS,NVA ext
Loading

The dashed edge is the one to read first: terraform-azurerm-route-server builds the same construct in a single record, taking the public IP and the subnet directly and needing no hub at all. This module is for estates already using the two-resource shape, and for hubs that exist for other reasons and are given an address as well.


🧬 What this module builds

flowchart TB
    IN1["name"]
    IN2["virtual_hub_id"]
    IN3["public_ip_address_id"]
    IN4["subnet_id"]
    IN5["private_ip_address / private_ip_allocation_method"]
    RES["azurerm_virtual_hub_ip.this"]
    OUT1["id -- derived from the hub plus this name"]
    OUT2["private_ip_address -- Azure's choice under Dynamic"]
    OUT3["virtual_network_id / subnet_name -- derived, never passed"]
    OUT4["posture flags -- the unchecked allocation pairing, the hub lock"]

    IN1 --> RES
    IN2 --> RES
    IN3 --> RES
    IN4 --> RES
    IN5 --> RES
    RES --> OUT1
    RES --> OUT2
    RES --> OUT3
    RES --> OUT4

    classDef self fill:#0078D4,stroke:#004578,color:#ffffff
    classDef ext fill:#F3F2F1,stroke:#8A8886,color:#201F1E
    class RES self
    class IN1,IN2,IN3,IN4,IN5,OUT1,OUT2,OUT3,OUT4 ext
Loading
Resource Name Cardinality
azurerm_virtual_hub_ip this single

One resource, six arguments, and four of the six force-new. Everything else this module ships is derivation and reporting of the things the provider accepts without checking.


βœ… Provider / Versions

Requirement Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0
Provider block None in this module β€” the caller configures provider "azurerm" { features {} }, including authentication

Schema notes that bite

  • πŸ”΄ This resource is a Route Server. The provider's documentation states it outright. A Standard virtual hub with no Virtual WAN, plus this IP configuration, is an Azure Route Server.
  • πŸ”΄ The hub must be Standard SKU and belong to no Virtual WAN. Documented by the provider, checked by nothing β€” neither fact is visible from a Resource ID, so a hub that fails either condition plans cleanly and is refused at apply.
  • πŸ”΄ azurerm_route_server is the newer form of the same thing. Prefer it for anything built fresh.
  • πŸ”΄ Four of the six arguments are force-new: name, virtual_hub_id, public_ip_address_id, subnet_id. Only private_ip_address and private_ip_allocation_method update in place.
  • 🟠 public_ip_address_id is REQUIRED, and the provider records why: the option became mandatory on 1 September 2021. There is no private-only form of this resource.
  • 🟠 The subnet is an ordinary virtual network's, and Azure wants it named RouteServerSubnet, a minimum of /26 (so /26, /25, /24 …), with no network security group attached. The provider checks none of the three. A /27 is refused, despite a good deal of older material saying otherwise.
  • 🟠 private_ip_allocation_method and private_ip_address are never cross-checked. Static with no address is refused by the service; Dynamic with an address is silently ignored.
  • 🟠 The provider locks the virtual hub for every operation, so several IP configurations against one hub run in series within a Terraform run β€” with hour-long default timeouts.
  • 🟑 private_ip_address must be IPv4. The provider's check is IPv4-only β€” and it accepts the IPv4-mapped IPv6 form ::ffff:10.60.0.5, so this module accepts it too rather than refusing a value the provider allows. A genuine IPv6 address is refused by both.
  • 🟑 name carries a non-empty check and nothing else β€” no length limit, no character rule.
  • 🟑 No tags, no location, no resource_group_name. All three are inherited from the hub.
  • βœ… An import guard exists, which is not universal in this provider, and all four timeouts are honoured by four separate functions.

πŸ”‘ Required Azure RBAC Roles / Permissions

Least-privilege. Three separate records are touched, in three potentially different resource groups β€” which references_span_resource_groups reports.

Permission Scope Why
Microsoft.Network/virtualHubs/read the virtual hub The IP configuration is addressed through the hub.
Microsoft.Network/virtualHubs/write the virtual hub Writes under the hub's path.
Microsoft.Network/virtualHubs/ipConfigurations/read the virtual hub Refresh.
Microsoft.Network/virtualHubs/ipConfigurations/write the virtual hub Create and update.
Microsoft.Network/virtualHubs/ipConfigurations/delete the virtual hub Destroy.
Microsoft.Network/publicIPAddresses/join/action the public IP Attaching a public IP requires join, not read. The permission most often missing.
Microsoft.Network/virtualNetworks/subnets/join/action the subnet Taking an address from a subnet likewise requires join.

Network Contributor at a scope covering all three records covers every line above. Where the records live in different resource groups the assignment must cover all of them, or the apply fails partway with a join denial rather than at plan.

⚠️ What this resource does once it exists is broader than the table suggests. Applying this configuration completes a Route Server, which then learns routes from any network virtual appliance that peers with it and programs them into the virtual network's effective routes. Nobody needs a further Azure permission for that β€” the routing change is the resource working as intended. Describe it that way in the change record, not as "adding an IP address".


Azure Prerequisites

  • The Microsoft.Network resource provider registered in the target subscription.
  • An existing virtual hub that is Standard SKU and belongs to no Virtual WAN.
  • An existing public IP β€” Standard SKU, statically allocated, in the hub's region.
  • An existing subnet in an ordinary virtual network, which Azure Route Server requires to be:
    • named exactly RouteServerSubnet β€” reported by subnet_is_named_route_server_subnet;
    • a minimum of /26 (or a shorter prefix such as /25 or /24) β€” a /27 is rejected;
    • carrying no network security group.
  • Quota for the public IP β€” the public IP module's concern rather than this one's.

πŸ“ Module Structure

terraform-azurerm-virtual-hub-ip/
β”œβ”€β”€ providers.tf    # required_version >= 1.12.0, azurerm ~> 4.0, no provider block
β”œβ”€β”€ variables.tf    # 7 inputs, 6 validations -- the hub, the public IP, the subnet, the addressing
β”œβ”€β”€ main.tf         # one keystone `this`; a dynamic timeouts block
β”œβ”€β”€ outputs.tf      # 34 outputs -- id first, then the parsed references, then the posture
β”œβ”€β”€ README.md       # this file
β”œβ”€β”€ SCOPE.md        # the cross-module contract
β”œβ”€β”€ LICENSE         # MIT
└── .gitignore

βš™οΈ Quick Start

provider "azurerm" {
  features {}
}

module "hub_ip" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-hub-ip.git?ref=v1.0.0"

  name                 = "ipconfig1"
  virtual_hub_id       = module.virtual_hub.id
  public_ip_address_id = module.route_server_pip.id
  subnet_id            = module.hub_vnet.subnet_ids["RouteServerSubnet"]
}

The caller configures the provider, its authentication and the mandatory features {} block β€” this module declares none of them.


πŸ”Œ Cross-Module Contract

Consumes

Input Type Source
name string the caller
virtual_hub_id string terraform-azurerm-virtual-hub β†’ id β€” Standard SKU, no virtual_wan_id
public_ip_address_id string terraform-azurerm-public-ip β†’ id
subnet_id string terraform-azurerm-virtual-network β†’ subnet_ids["RouteServerSubnet"]
private_ip_address string the caller β€” optional
private_ip_allocation_method string the caller β€” optional, defaults to Dynamic
timeouts object(...) the caller

Emits

Output Description Consumed by
id Resource ID of the IP configuration anything referencing it
private_ip_address The address in effect β€” Azure's choice under Dynamic an appliance's BGP peer configuration
virtual_network_id Derived from the subnet ID peering checks, access reviews
subnet_is_named_route_server_subnet Whether the subnet carries the required name pre-apply review
references_span_resource_groups Whether the three records share a resource group RBAC planning
the posture flags Route Server identity, the newer resource, the hub rule, the lock review

πŸ“š Example Library

1 Β· The smallest real call
module "hub_ip" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-hub-ip.git?ref=v1.0.0"

  name                 = "ipconfig1"
  virtual_hub_id       = module.virtual_hub.id
  public_ip_address_id = module.route_server_pip.id
  subnet_id            = module.hub_vnet.subnet_ids["RouteServerSubnet"]
}

ℹ️ Four required arguments. private_ip_allocation_method defaults to Dynamic, so Azure chooses the address from the subnet.

2 Β· Pinning a specific private address
module "hub_ip" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-hub-ip.git?ref=v1.0.0"

  name                 = "ipconfig1"
  virtual_hub_id       = module.virtual_hub.id
  public_ip_address_id = module.route_server_pip.id
  subnet_id            = module.hub_vnet.subnet_ids["RouteServerSubnet"]

  private_ip_allocation_method = "Static"
  private_ip_address           = "10.5.1.18"
}

⚠️ The two fields are not cross-checked by anything. Static with no address plans cleanly and is refused by the service; this module reports that through allocation_is_static_without_an_address rather than refusing it.

ℹ️ Nothing offline can verify that 10.5.1.18 is inside the subnet, is free, or avoids the subnet's own reserved range. All three are the service's to refuse.

3 Β· Checking the subnet name before you apply
module "hub_ip" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-hub-ip.git?ref=v1.0.0"

  name                 = "ipconfig1"
  virtual_hub_id       = module.virtual_hub.id
  public_ip_address_id = module.route_server_pip.id
  subnet_id            = module.hub_vnet.subnet_ids["snet-shared"] # <-- wrong name
}

output "route_server_subnet_check" {
  value = module.hub_ip.subnet_is_named_route_server_subnet ? "OK" : "Subnet must be named RouteServerSubnet"
}

⚠️ This plans cleanly and fails at apply. The name is the only one of Azure Route Server's three subnet requirements visible from a Resource ID, which is why this module reports it and says nothing about the other two.

πŸ’‘ It is an output rather than a validation {} on purpose: the rule is the service's, not the provider's, and a validation failure would block terraform destroy as well as apply.

4 Β· The hub this resource actually needs
module "virtual_hub" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-hub.git?ref=v1.0.0"

  name                = "vhub-routeserver"
  resource_group_name = module.rg.name
  location            = module.rg.location

  # Both of these matter to the IP configuration below.
  sku = "Standard"
  # virtual_wan_id deliberately unset -- a hub with a Virtual WAN cannot carry
  # an IP configuration.

  tags = module.rg.tags
}

module "hub_ip" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-hub-ip.git?ref=v1.0.0"

  name                 = "ipconfig1"
  virtual_hub_id       = module.virtual_hub.id
  public_ip_address_id = module.route_server_pip.id
  subnet_id            = module.hub_vnet.subnet_ids["RouteServerSubnet"]
}

⚠️ Neither condition is visible from a Resource ID, so neither can be checked here. A Basic hub, or a hub belonging to a Virtual WAN, produces a clean plan and an apply-time failure.

5 Β· The public IP this resource actually needs
module "route_server_pip" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-public-ip.git?ref=v1.0.0"

  name                = "pip-routeserver"
  resource_group_name = module.rg.name
  location            = module.rg.location

  sku               = "Standard"
  allocation_method = "Static"

  tags = module.rg.tags
}

πŸ”’ The public IP is required β€” the provider records that the option became mandatory on 1 September 2021 β€” so there is no private-only form of this resource and no secure-by-default choice for this module to make. What the address is reachable for is governed by the hub's routing, not by this resource.

⚠️ Standard SKU and Static allocation are both service requirements this resource cannot see.

6 Β· The subnet, sized and named the way Azure wants
module "hub_vnet" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-network.git?ref=v1.0.0"

  name                = "vnet-hub-eastus"
  resource_group_name = module.rg.name
  location            = module.rg.location
  address_space       = ["10.5.0.0/16"]

  subnets = {
    # The name is not a convention -- Azure Route Server requires it.
    RouteServerSubnet = {
      address_prefixes = ["10.5.1.0/26"] # /26 is the minimum Azure accepts.
      # No network_security_group_id: Route Server refuses a subnet that has one.
    }

    snet-workloads = {
      address_prefixes = ["10.5.10.0/24"]
    }
  }

  tags = module.rg.tags
}

⚠️ Do not attach a network security group to RouteServerSubnet. Azure refuses the deployment, and nothing in the plan warns you.

7 Β· Reading the address back for an appliance's BGP configuration
module "hub_ip" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-hub-ip.git?ref=v1.0.0"

  name                 = "ipconfig1"
  virtual_hub_id       = module.virtual_hub.id
  public_ip_address_id = module.route_server_pip.id
  subnet_id            = module.hub_vnet.subnet_ids["RouteServerSubnet"]
}

output "route_server_peer_address" {
  description = "The address a network virtual appliance peers with over BGP."
  value       = module.hub_ip.private_ip_address
}

πŸ’‘ Under Dynamic allocation this address is chosen by Azure and read back, so it appears in state without ever having been configured. That is exactly why it is emitted: an appliance configuration needs the value, and there is no other way to learn it.

8 Β· Two IP configurations on one hub, and why they are slow
module "hub_ip" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-hub-ip.git?ref=v1.0.0"
  for_each = {
    ipconfig1 = var.route_server_public_ip_ids["a"]
    ipconfig2 = var.route_server_public_ip_ids["b"]
  }

  name                 = each.key
  virtual_hub_id       = module.virtual_hub.id
  public_ip_address_id = each.value
  subnet_id            = module.hub_vnet.subnet_ids["RouteServerSubnet"]
}

⚠️ The provider locks the hub for every operation. These two do not apply in parallel β€” they apply in series, each with a one-hour default timeout. A for_each over many configurations takes as long as the sum of its parts.

ℹ️ Keying on each.key rather than on a Resource ID keeps the map stable; re-keying would replace every configuration.

9 Β· Longer timeouts for a slow hub
module "hub_ip" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-hub-ip.git?ref=v1.0.0"

  name                 = "ipconfig1"
  virtual_hub_id       = module.virtual_hub.id
  public_ip_address_id = module.route_server_pip.id
  subnet_id            = module.hub_vnet.subnet_ids["RouteServerSubnet"]

  timeouts = {
    create = "90m"
    delete = "90m"
  }
}

ℹ️ All four timeouts are genuinely honoured here, by four separate functions. That is worth checking rather than assuming β€” elsewhere in this library a resource takes the create deadline on an update, and one has no update operation at all.

⚠️ A misspelled key is silently discarded. Terraform's object-type conversion drops attributes the type does not declare, without any error, so creat = "90m" simply does nothing.

10 Β· Auditing where the three referenced records live
module "hub_ip" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-hub-ip.git?ref=v1.0.0"

  name                 = "ipconfig1"
  virtual_hub_id       = module.virtual_hub.id
  public_ip_address_id = var.shared_public_ip_id # in a shared networking resource group
  subnet_id            = module.hub_vnet.subnet_ids["RouteServerSubnet"]
}

output "hub_ip_placement" {
  value = {
    hub_resource_group     = module.hub_ip.resource_group_name
    virtual_network        = module.hub_ip.virtual_network_name
    spans_resource_groups  = module.hub_ip.references_span_resource_groups
  }
}

πŸ’‘ references_span_resource_groups exists because the RBAC requirements depend on it: the join permissions sit on the public IP and the subnet, not on the hub. When it is true, a role assignment scoped to the hub's resource group is not enough, and the apply fails partway rather than at plan.

ℹ️ virtual_network_id and virtual_network_name are derived by trimming the /subnets/... suffix off the configured subnet ID. This resource never names the network, and an access review needs it.

11 Β· What NOT to do
# WRONG -- a Virtual WAN hub cannot carry an IP configuration.
module "virtual_hub" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-hub.git?ref=v1.0.0"

  name                = "vhub-wan"
  resource_group_name = module.rg.name
  location            = module.rg.location
  sku                 = "Standard"
  virtual_wan_id      = var.virtual_wan_id # <-- this is the problem
}

⚠️ This plans cleanly and is refused at apply. The provider documents the restriction and checks none of it β€” a Virtual WAN membership is not visible from a hub's Resource ID.

# WRONG -- a public IP PREFIX is not a public IP.
public_ip_address_id = var.public_ip_prefix_id

ℹ️ This module refuses that at terraform plan, offline and without credentials. The two Resource ID types differ only in the type segment (publicIPPrefixes versus publicIPAddresses) and are easy to confuse, so the check is anchored.

# WRONG -- a virtual network ID where a subnet ID belongs.
subnet_id = module.hub_vnet.id

ℹ️ Also refused at terraform plan, offline, and for the same reason: parent and child IDs extend the same path, so an unanchored check would accept the wrong resource in either direction.

12 Β· Reading the posture outputs before committing to this shape
module "hub_ip" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-hub-ip.git?ref=v1.0.0"

  name                 = "ipconfig1"
  virtual_hub_id       = module.virtual_hub.id
  public_ip_address_id = module.route_server_pip.id
  subnet_id            = module.hub_vnet.subnet_ids["RouteServerSubnet"]
}

output "hub_ip_posture" {
  value = {
    this_is_a_route_server = module.hub_ip.this_configuration_is_an_azure_route_server
    newer_resource_exists  = module.hub_ip.a_dedicated_route_server_resource_exists_and_is_the_newer_shape
    hub_must_be_wan_less   = module.hub_ip.the_hub_must_be_standard_sku_and_have_no_virtual_wan
    subnet_rules_invisible = module.hub_ip.the_subnet_requirements_are_the_service_s_and_are_invisible_here
    hub_is_locked_serially = module.hub_ip.the_provider_locks_the_virtual_hub_for_every_write
    force_new              = module.hub_ip.force_new_fields
  }
}

πŸ’‘ a_dedicated_route_server_resource_exists_and_is_the_newer_shape is an output that recommends against its own module. It is here because azurerm_route_server removes an entire resource from the caller's configuration for the same result β€” which is a categorically better position, not merely a different one.

13 Β· πŸ—οΈ End-to-end composition
provider "azurerm" {
  features {}
}

module "rg" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"

  name     = "rg-hub-eastus"
  location = "eastus"

  tags = {
    environment = "production"
    workload    = "hub-networking"
  }
}

module "hub_vnet" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-network.git?ref=v1.0.0"

  name                = "vnet-hub-eastus"
  resource_group_name = module.rg.name
  location            = module.rg.location
  address_space       = ["10.5.0.0/16"]

  subnets = {
    # Required name; /26 minimum; no network security group.
    RouteServerSubnet = {
      address_prefixes = ["10.5.1.0/26"]
    }

    snet-workloads = {
      address_prefixes = ["10.5.10.0/24"]
    }
  }

  tags = module.rg.tags
}

module "route_server_pip" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-public-ip.git?ref=v1.0.0"

  name                = "pip-routeserver-eastus"
  resource_group_name = module.rg.name
  location            = module.rg.location

  sku               = "Standard"
  allocation_method = "Static"

  tags = module.rg.tags
}

# Standard SKU, and deliberately NOT attached to a Virtual WAN.
module "virtual_hub" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-hub.git?ref=v1.0.0"

  name                = "vhub-routeserver-eastus"
  resource_group_name = module.rg.name
  location            = module.rg.location

  sku = "Standard"

  tags = module.rg.tags
}

module "hub_ip" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-hub-ip.git?ref=v1.0.0"

  name                 = "ipconfig1"
  virtual_hub_id       = module.virtual_hub.id
  public_ip_address_id = module.route_server_pip.id
  subnet_id            = module.hub_vnet.subnet_ids["RouteServerSubnet"]

  private_ip_allocation_method = "Static"
  private_ip_address           = "10.5.1.18"
}

output "route_server" {
  value = {
    hub               = module.virtual_hub.id
    hub_asn           = module.virtual_hub.virtual_router_asn
    peer_address      = module.hub_ip.private_ip_address
    public_address    = module.route_server_pip.ip_address
    subnet_named_well = module.hub_ip.subnet_is_named_route_server_subnet
  }
}

πŸ’‘ Terraform derives the whole ordering from the Resource ID references β€” module.virtual_hub.id, module.route_server_pip.id, module.hub_vnet.subnet_ids[...] β€” which is why no depends_on appears anywhere above.

⚠️ This composition builds a Route Server. Once applied, it learns routes from any appliance that peers with it and programs them into the virtual network's effective routes. module.virtual_hub.virtual_router_asn is the ASN that appliance peers against.

πŸ’‘ For a Route Server being built fresh, terraform-azurerm-route-server does all of the above in one module block, without the hub.


πŸ“₯ Inputs

Required: name, virtual_hub_id, public_ip_address_id, subnet_id Optional: private_ip_address, private_ip_allocation_method, timeouts

There is no tags variable and no location β€” the resource exposes neither, inheriting both from the hub.

Full input schemas
variable "name" {
  type = string
  # REQUIRED, FORCE-NEW. Non-empty is the provider's ONLY check -- no length
  # limit, no character rule -- and this module adds nothing beyond it.
}

variable "virtual_hub_id" {
  type = string
  # REQUIRED, FORCE-NEW. Anchored to
  #   /subscriptions/<s>/resourceGroups/<rg>/providers/
  #     Microsoft.Network/virtualHubs/<hub>
  # The hub must ALSO be Standard SKU with no Virtual WAN -- documented by the
  # provider, visible from no Resource ID, checked by nothing.
}

variable "public_ip_address_id" {
  type = string
  # REQUIRED, FORCE-NEW. Anchored to .../publicIPAddresses/<ip>, so a public IP
  # PREFIX is refused. Mandatory since 1 September 2021 -- there is no
  # private-only form of this resource.
}

variable "subnet_id" {
  type = string
  # REQUIRED, FORCE-NEW. Anchored to .../virtualNetworks/<vnet>/subnets/<subnet>.
  # Azure Route Server wants it named RouteServerSubnet, a /26 minimum, with no
  # network security group -- reported, never enforced.
}

variable "private_ip_address" {
  type    = string
  default = null
  # OPTIONAL, in-place. IPv4 only, mirroring the provider's own check --
  # including the IPv4-MAPPED form ::ffff:10.60.0.5, which it accepts.
  # Never cross-checked against the allocation method.
}

variable "private_ip_allocation_method" {
  type    = string
  default = "Dynamic"
  # OPTIONAL, in-place. "Dynamic" | "Static", case-sensitive.
  # The provider's default, kept -- this is an addressing decision, not a
  # security one.
}

variable "timeouts" {
  type = object({
    create = optional(string)
    read   = optional(string)
    update = optional(string)
    delete = optional(string)
  })
  default = null
  # All four honoured, by four separate functions.
  # Provider defaults: 60m create, 5m read, 60m update, 60m delete.
}

🧾 Outputs

Output Type Notes
id string .../virtualHubs/<hub>/ipConfigurations/<name>
name string As configured; force-new
virtual_hub_id string As configured
virtual_hub_name string Parsed
resource_group_name string Parsed β€” there is no argument for it
subscription_id string Parsed
public_ip_address_id string As configured
public_ip_address_name string Parsed
subnet_id string As configured
subnet_name string Parsed
virtual_network_name string Derived from the subnet ID
virtual_network_id string Derived by trimming /subnets/...
private_ip_address string Azure's choice under Dynamic
private_ip_allocation_method string As reported
subnet_is_named_route_server_subnet bool The one Route Server subnet rule visible offline
uses_static_allocation bool Whether the address is pinned
allocation_is_static_without_an_address bool The combination the service refuses
address_supplied_under_dynamic_allocation bool The combination the service ignores
references_span_resource_groups bool Drives the join permission scoping
this_configuration_is_an_azure_route_server bool Constant true
a_dedicated_route_server_resource_exists_and_is_the_newer_shape bool Constant true
the_hub_must_be_standard_sku_and_have_no_virtual_wan bool Constant true
the_subnet_requirements_are_the_service_s_and_are_invisible_here bool Constant true
nothing_cross_checks_the_allocation_method_against_the_address bool Constant true
only_the_address_and_its_allocation_method_change_in_place bool Constant true
the_private_address_must_be_ipv4 bool Constant true
the_provider_locks_the_virtual_hub_for_every_write bool Constant true
an_import_guard_exists_on_this_resource bool Constant true
all_four_timeouts_are_honoured_here bool Constant true
force_new_fields list(string) Four of the six arguments
fields_that_can_change_after_creation list(string) The other two
fields_azure_returns_on_read list(string) Where drift is detectable
this_resource_supports_no_azure_resource_tags bool Constant true
no_secret_is_accepted_or_emitted_by_this_module bool Constant true

🧠 Architecture Notes

Start with what it is. The provider's documentation for azurerm_virtual_hub_ip opens: "Manages a Virtual Hub IP. This resource is also known as a Route Server." Everything else follows. The subnet wants a particular name because Route Server wants it. The public IP is not optional because Route Server needs one. The hub must be Standard and WAN-less because that is the shape a Route Server takes. A reader who treats this as "an IP address on a hub" will get all three wrong, and all three plan cleanly.

A newer resource does the same job in one record. azurerm_route_server takes the public IP and the subnet directly and creates no separate hub. For a Route Server being built fresh it is the better choice, and this module says so in an output rather than only in prose. The two-resource form remains correct for an estate already using it, and for a hub that exists for other reasons and is given an address as well.

Four of six arguments are force-new. name, virtual_hub_id, public_ip_address_id and subnet_id all replace the configuration. Only private_ip_address and private_ip_allocation_method update in place β€” which is a narrow window, and worth knowing before proposing a change to any of the other four.

The Resource ID is composed, not assigned. It is built from the hub's subscription, resource group and name plus this configuration's name. That is why there is no resource_group_name and no location argument, why the module parses both out of the hub's ID instead, and why renaming replaces the record.

Three requirements are invisible from a Resource ID. The hub's SKU, the hub's Virtual WAN membership, and the subnet's size and security-group attachment cannot be seen from the IDs this module receives. The subnet's name can, so it is reported. The rest are documented and left to the service, because inventing a check for something unverifiable would reject legal input β€” and a validation {} failure blocks terraform destroy as well as apply.

The allocation pairing is unchecked in both directions. The provider sends private_ip_allocation_method and private_ip_address on every write regardless of one another. Static with no address is refused by Azure; Dynamic with an address is silently ignored β€” and the second is the more misleading, because it produces a working resource carrying an address nobody asked for. Both are emitted as flags.

The hub lock serialises everything. Create, update and delete each take an internal lock on the virtual hub's name. Several IP configurations against one hub run in series within a Terraform run, with hour-long default timeouts on each. Size the expectation accordingly.

Under Dynamic, state carries a value that was never configured. Azure chooses the private address and the provider reads it back. That is not drift β€” it is the intended behaviour, and it is the value an appliance's BGP configuration needs.

lifecycle is not valid inside a module block. A caller cannot add prevent_destroy here. Where deletion protection matters, apply a CanNotDelete management lock to the hub β€” noting that a lock prevents deletion, not replacement, and four of this resource's six arguments force replacement.


🧱 Design Principles

Concern This module's position What the caller must type to change it
Public exposure No choice exists β€” public_ip_address_id is required by the provider, so the module states that plainly rather than implying a secure default it is required; there is nothing to opt out of
Allocation method The provider's Dynamic default is kept β€” neither value is more exposed, and this is an addressing decision set private_ip_allocation_method = "Static"
Route Server subnet rules Reported, never enforced β€” the name via subnet_is_named_route_server_subnet, the size and security-group rules documented β€”
Hub SKU / Virtual WAN Reported, never enforced β€” neither is visible from a Resource ID β€”
Resource-ID inputs Anchored to the exact resource type, so a WAN, a prefix or a virtual network is refused at plan β€”
Inventing a name rule Refused β€” the provider checks only that name is non-empty, and this module matches it β€”
Recommending a different resource Emitted as an output, because azurerm_route_server is categorically simpler for the same result β€”
tags Absent by design β€” the resource exposes none tag the hub

πŸ”’ This module has no security toggle to default safely. Its posture is entirely in the reporting: the three service requirements it cannot check, and the join permissions its RBAC table names.


πŸš€ Runbook

terraform init -backend=false
terraform validate
terraform fmt -check

Pin the module by tag β€” ?ref=v1.0.0 β€” never a branch.

Plan-only: this repository performs no cloud apply. A human applies from CI.


πŸ§ͺ Testing

What terraform validate covers, offline and without credentials:

  • The non-empty name check.
  • All three anchored Resource-ID checks β€” a Virtual WAN, a public IP prefix and a virtual network are each refused where a hub, a public IP and a subnet belong.
  • The IPv4-only check on private_ip_address.
  • The case-sensitive Dynamic / Static enum.
  • The timeouts object's shape.

What only terraform plan exercises (credentials required):

  • That the hub, the public IP and the subnet all exist.
  • That the identity holds join on the public IP and the subnet.

What only terraform apply reveals:

  • The hub's SKU and Virtual WAN membership.
  • The subnet's size and whether a network security group is attached.
  • Whether a Static address is inside the subnet, free, and outside its reserved range.
  • Whether Static was supplied without an address.

ℹ️ terraform console fires root-module variable validations, unlike terraform validate on a calling configuration. It is the honest offline harness for the checks above β€” including the derived flags, which are ordinary locals and can be driven directly.


πŸ’¬ Example Output

Outputs:

id                                  = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-hub-eastus/providers/Microsoft.Network/virtualHubs/vhub-routeserver-eastus/ipConfigurations/ipconfig1"
name                                = "ipconfig1"
virtual_hub_name                    = "vhub-routeserver-eastus"
resource_group_name                 = "rg-hub-eastus"
public_ip_address_name              = "pip-routeserver-eastus"
subnet_name                         = "RouteServerSubnet"
virtual_network_name                = "vnet-hub-eastus"
virtual_network_id                  = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-hub-eastus/providers/Microsoft.Network/virtualNetworks/vnet-hub-eastus"
private_ip_address                  = "10.5.1.18"
private_ip_allocation_method        = "Static"
subnet_is_named_route_server_subnet = true
references_span_resource_groups     = false
force_new_fields                    = [
  "name",
  "virtual_hub_id",
  "public_ip_address_id",
  "subnet_id",
]
this_configuration_is_an_azure_route_server = true

ℹ️ virtual_network_id and virtual_network_name do not correspond to any argument. They are derived from the subnet ID, because this resource takes a subnet and never names the network it belongs to.


πŸ” Troubleshooting

Symptom Cause Fix
virtual_hub_id must be a virtual hub Resource ID A Virtual WAN ID, a virtual network ID, or a child of the hub Use the virtual-hub module's id; the check is anchored to virtualHubs/<hub> exactly
public_ip_address_id must be a public IP Resource ID A public IP prefix ID publicIPPrefixes and publicIPAddresses differ only in the type segment β€” use the public-IP module's id
subnet_id must be a subnet Resource ID A virtual network ID Use subnet_ids["RouteServerSubnet"], not the network's id
private_ip_address must be an IPv4 address An IPv6 address, or a truncated one The provider's check is IPv4-only; this resource has no dual-stack form
private_ip_allocation_method must be exactly "Dynamic" or "Static" Lowercase, or a value from another resource's enum The provider's check is case-sensitive
Apply fails on the hub with a SKU or Virtual WAN error The hub is Basic, or belongs to a Virtual WAN Neither is visible from a Resource ID. Set the hub's sku = "Standard" and leave virtual_wan_id unset
Apply fails on the subnet The subnet is not named RouteServerSubnet, is smaller than /26, or has a network security group attached Check subnet_is_named_route_server_subnet for the first; the other two are invisible here. Note a subnet cannot be resized while resources occupy it, so an undersized one usually means recreating the subnet
Apply fails with an authorization error mid-run Missing join on the public IP or the subnet Those permissions are not on the hub. Check references_span_resource_groups and scope the role assignment to cover all three records
Static allocation applies but Azure rejects the address No private_ip_address was supplied, or it is outside the subnet allocation_is_static_without_an_address reports the first; the rest is the service's to refuse
A configured private_ip_address has no effect The allocation method is Dynamic, so Azure chose its own address_supplied_under_dynamic_allocation reports this β€” set Static if you meant to pin it
Several IP configurations take far longer than expected The provider locks the hub for every operation, so they run in series Expected. Budget the sum of the per-resource timeouts, not the maximum
A change to the name or any referenced ID proposes a replacement Four of the six arguments are force-new Expected β€” see force_new_fields

πŸ”— Related Docs

  • azurerm_virtual_hub_ip β€” provider reference, including the Route Server note
  • azurerm_route_server β€” the newer, single-resource form
  • azurerm_virtual_hub β€” the hub this configuration attaches to
  • Azure Route Server documentation β€” the subnet requirements and BGP peering behaviour
  • Sibling modules: terraform-azurerm-virtual-hub, terraform-azurerm-route-server, terraform-azurerm-public-ip, terraform-azurerm-virtual-network, terraform-azurerm-subnet, terraform-azurerm-resource-group
  • This module's SCOPE.md β€” the cross-module contract

πŸ’™ "Infrastructure as Code should be standardized, consistent, and secure."