Terraform map expansion - interface ranges

Typical terraform deployments use maps (and not - ideally never arrays/lists) to reflect multiples of provisioned resources, for example if you need five databases, you will just create a map and loop around the module.

This is simple, as long as you can easily count the number of instances.

I'm currently working on integrating our core network infrastructure into automation of the deployments. Typically this is done through ansible - in our case we prefer terraform for this task (if possible) due to the overall integration in the propagation of values.

Assume the following dimensions:

  • You have TOR switches with each 16 downlink and 2 uplink ports
  • Within this rack you need between 1024 and 2048 VLANs

The API (due to the possibility of allowing untagged VLANs on ports) needs to get each VLAN mapped seperately - and each port might have different configurations. Overall, building this as a map results in (16+2)*1024 - (16+2)*2048 map items, rougly 20k.

The main task is: Automate this stuff. My personal perception of automation is not to trade implementation for writing repetive configuration. Luckily - we don't need to.

If you've some networking past, you probably know that most switches allow doing commands in ranges. For example

# selection
interface range xg1-xg24
interface range te1/2/1-te1/2/24

# configuration
switchport trunk allowed vlans 42,73,1024-2047

range selection and configuration

This is a very concise and well readable form - perfect for humans to be able to focus on the actual configuration content.

So, I decided to go with such an approach:

ethernet_interfaces:
  ether1-ether24:
    mtu: 9216
  sfp-sfpplus1:
    mtu: 9216
  sfp-sfpplus2:
    mtu: 9216

range selection for eth

bridge_interfaces:
  br1:
    enabled: true
    mtu: 9216
    members:
      ether1-ether22:
        portMode: edge
        mode: access
        access:
          vlan_id: 2110
      ether24:
        portMode: edge
        mode: trunk
        trunk:
          native_vlan_id: 2110
          vlans:
          - 2104-2115
      sfp-sfpplus1:
        mode: trunk
        trunk:
          native_vlan_id: 2110
          vlans:
          - 2104-2115
      sfp-sfpplus2:
        mode: trunk
        trunk:
          native_vlan_id: 2110
          vlans:
          - 2104-2115

range selection for bridges

You can see - the ethernet interfaces are one dimension, each interface has a single set of values. Fairly simple.

The bridge interfaces are slightly more complex: you can have multiple bridges with multiple members with multiple vlans - that's three dimensions (we will focus on two of them).

Expand the ranges

So the idea is simple: Instead of using the variable directly, we will once pipe it through some unfolding logic and use the (local) variable then for the actual iteration.

  ethernet_interfaces_expanded = merge([
    for k, v in coalesce(var.ethernet_interfaces, {}) : (
      can(regex(var.interface_range_regex, k)) ?
      # Range case: expand into multiple interfaces
      {
        for i in range(
          tonumber(regex(var.interface_range_regex, k).from),
          tonumber(regex(var.interface_range_regex, k).to) + 1
        ) : format("%s%d", regex(var.interface_range_regex, k).prefix, i) => v
      } :
      # Single case: keep as-is
      { (k) = v }
    )
  ]...)

expand eth

You can see the approach here - we iterate over all interfaces, use some regex to match the pattern and then extract the relevant (named) groups to create our range object. The value of the original group will be just used on each key.

The regex for the expansion is as follows: ^(?P<prefix>[a-zA-Z][\w+-/]*[a-zA-Z+-/])(?P<from>\d+)-(?P<prefix2>[a-zA-Z][\w+-/]*[a-zA-Z+-/])(?P<to>\d+)$

This means we will virtually do this:

# variable version
ethernet_interfaces:
  ether1-ether3:
    mtu: 9216

# local version
ethernet_interfaces:
  ether1:
    mtu: 9216
  ether2:
    mtu: 9216
  ether3:
    mtu: 9216

expanded value

The bridge version is slightly more complex, here for your reference:

  bridge_interfaces = {
    for k, v in coalesce(var.bridge_interfaces, {}) : k =>
    provider::deepmerge::mergo(
      var.bridge_interfaces_defaults,
      { for mk, mv in v : mk => mv if mv != null },
      { members = null },
      { members = merge([
        for mk, mv in coalesce(v.members, {}) : (
          can(regex(var.interface_range_regex, mk)) ?
          # Range case: expand into multiple interfaces
          {
            for i in range(
              tonumber(regex(var.interface_range_regex, mk).from),
              tonumber(regex(var.interface_range_regex, mk).to) + 1
            ) : format("%s%d", regex(var.interface_range_regex, mk).prefix, i) => mv
          } :
          # Single case: keep as-is
          { (mk) = mv }
        )
        ]...)
      }
    )
  }

bridge expansion

You can see that we need to iterate over the bridges, then over the members. As we're merging the bridge values, we need to insert an explicit null on the members field to make sure to clear the original value.

Writing this kind of code is not the most straight forward way and it will (or might) take a few minutes. Still - the improvement on the format of resulting configuration is worth the effort.