Tiered Subnet Calculator in Terraform

I’ve been thinking about adding support for building tiered subnets of any valid size for the next iteration of the Dynamic VPC Module. It occurred to me that auto subnet generation inside the module actually makes the subnetting less dynamic.

Furthermore, auto subnet calculation should be in assistance to the process of allocating subnets and should not be fed directly as input to the VPC module. This is due to the fact that order matters only for the subnetting calculation. So when tiers or AZs are changed, added or removed, the calculation will shift for none, some or all tiers.

Removing the subnet generation will simplify the module itself and it will reinforce the notion that engineers should know their subnetting when architecting networks. With all that in mind, I created a Tiered Subnet Calculator module to assist with allocating subnets per AZ per network tier.

tiers.auto.tfvars

base_cidr_block = "10.0.0.0/16"

tiers = [
  {
    name   = "app"
    acl    = "public"
    newbit = 4
  },
  {
    name   = "db"
    acl    = "private"
    newbit = 4
  },
  {
    name   = "worker"
    acl    = "private"
    newbit = 4
  },
  {
    name   = "lbs"
    acl    = "public"
    newbit = 4
  }
]

az_newbits = {
  a = 4
  b = 4
  c = 4
  d = 4
}

variables.tf

variable "base_cidr_block" {
  type = string
}

variable "tiers" {
  type = set(object({
    name   = string
    acl    = string
    newbit = number
  }))
}

variable "az_newbits" {
  type = map(number)
}

main.tf

locals {
  # generate top level networks for each tier based on tier newbit + base_cidr_block mask ie /4 + /16 = /20
  tier_networks = zipmap(var.tiers[*].name, cidrsubnets(var.base_cidr_block, var.tiers[*].newbit...))

  # generate a subnet based on each az newbit (in azs_new_bits map) per tier network ie /4 + /20 = /24
  tier_subnets = { for t, n in local.tier_networks : t => cidrsubnets(n, values(var.az_newbits)...) }

  # generate azs to subnet map per tier
  tier_az_subnets = { for t, s in local.tier_subnets : t => zipmap(keys(var.az_newbits), s) }

  # build new tiers list with their associated network and az to subnets map
  tiers_with_subnets_per_az = [
    for t in var.tiers : {
      name    = t.name
      acl     = t.acl
      network = lookup(local.tier_networks, t.name)
      azs     = lookup(local.tier_az_subnets, t.name)
  }]

}

output "calculated_tiers" {
  value = local.tiers_with_subnets_per_az
}

terraform refresh

$ terraform refresh

Empty or non-existent state file.

Refresh will do nothing. Refresh does not error or return an erroneous
exit status because many automation scripts use refresh, plan, then apply
and may not have a state file yet for the first run.


Outputs:

calculated_tiers = [
  {
    "acl" = "public"
    "azs" = {
      "a" = "10.0.0.0/24"
      "b" = "10.0.1.0/24"
      "c" = "10.0.2.0/24"
      "d" = "10.0.3.0/24"
    }
    "name" = "app"
    "network" = "10.0.0.0/20"
  },
  {
    "acl" = "private"
    "azs" = {
      "a" = "10.0.16.0/24"
      "b" = "10.0.17.0/24"
      "c" = "10.0.18.0/24"
      "d" = "10.0.19.0/24"
    }
    "name" = "db"
    "network" = "10.0.16.0/20"
  },
  {
    "acl" = "private"
    "azs" = {
      "a" = "10.0.32.0/24"
      "b" = "10.0.33.0/24"
      "c" = "10.0.34.0/24"
      "d" = "10.0.35.0/24"
    }
    "name" = "worker"
    "network" = "10.0.32.0/20"
  },
  {
    "acl" = "public"
    "azs" = {
      "a" = "10.0.48.0/24"
      "b" = "10.0.49.0/24"
      "c" = "10.0.50.0/24"
      "d" = "10.0.51.0/24"
    }
    "name" = "lbs"
    "network" = "10.0.48.0/20"
  },
]

If you want to see each tier transform you can open the terraform console and call them to see their output.

$ terraform console
> local.tier_networks
{
  "app" = "10.0.0.0/20"
  "db" = "10.0.16.0/20"
  "lbs" = "10.0.48.0/20"
  "worker" = "10.0.32.0/20"
}
> local.tier_subnets
{
  "app" = [
    "10.0.0.0/24",
    "10.0.1.0/24",
    "10.0.2.0/24",
    "10.0.3.0/24",
  ]
  "db" = [
    "10.0.16.0/24",
    "10.0.17.0/24",
    "10.0.18.0/24",
    "10.0.19.0/24",
  ]
  "lbs" = [
    "10.0.48.0/24",
    "10.0.49.0/24",
    "10.0.50.0/24",
    "10.0.51.0/24",
  ]
  "worker" = [
    "10.0.32.0/24",
    "10.0.33.0/24",
    "10.0.34.0/24",
    "10.0.35.0/24",
  ]
}
> local.tier_az_subnets
{
  "app" = {
    "a" = "10.0.0.0/24"
    "b" = "10.0.1.0/24"
    "c" = "10.0.2.0/24"
    "d" = "10.0.3.0/24"
  }
  "db" = {
    "a" = "10.0.16.0/24"
    "b" = "10.0.17.0/24"
    "c" = "10.0.18.0/24"
    "d" = "10.0.19.0/24"
  }
  "lbs" = {
    "a" = "10.0.48.0/24"
    "b" = "10.0.49.0/24"
    "c" = "10.0.50.0/24"
    "d" = "10.0.51.0/24"
  }
  "worker" = {
    "a" = "10.0.32.0/24"
    "b" = "10.0.33.0/24"
    "c" = "10.0.34.0/24"
    "d" = "10.0.35.0/24"
  }
}

Filtering tiers easy too.

locals {
  private_tiers = [for t in local.tier_subnets_per_az : t if t.acl == "private"]
}

Now I can start tweaking the tiers object set and az_newbits map to generate different tiered network configurations.

tiers.auto.tfvars

base_cidr_block = "10.0.0.0/16"

tiers = [
  {
    name   = "app"
    acl    = "public"
    newbit = 6
  },
  {
    name   = "db"
    acl    = "private"
    newbit = 6
  },
  {
    name   = "worker"
    acl    = "private"
    newbit = 4
  },
]

az_newbits = {
  b = 2
  c = 4
  d = 4
}
$ terraform refresh
Empty or non-existent state file.

Refresh will do nothing. Refresh does not error or return an erroneous
exit status because many automation scripts use refresh, plan, then apply
and may not have a state file yet for the first run.


Outputs:

calculated_tiers = [
  {
    "acl" = "public"
    "azs" = {
      "b" = "10.0.0.0/24"
      "c" = "10.0.1.0/26"
      "d" = "10.0.1.64/26"
    }
    "name" = "app"
    "network" = "10.0.0.0/22"
  },
  {
    "acl" = "private"
    "azs" = {
      "b" = "10.0.4.0/24"
      "c" = "10.0.5.0/26"
      "d" = "10.0.5.64/26"
    }
    "name" = "db"
    "network" = "10.0.4.0/22"
  },
  {
    "acl" = "private"
    "azs" = {
      "b" = "10.0.16.0/22"
      "c" = "10.0.20.0/24"
      "d" = "10.0.21.0/24"
    }
    "name" = "worker"
    "network" = "10.0.16.0/20"
  },
]

I’m able to take this output, add or remove AZs and subnets that may have not been in the original calculation. I can chop up networks as I see fit.

tiers = [
  {
    "acl" = "public"
    "azs" = {
      "b" = "10.0.0.0/24"
      "c" = "10.0.1.0/26"
    }
    "name" = "app"
    "network" = "10.0.0.0/22"
  },
  {
    "acl" = "private"
    "azs" = {
      "c" = "10.0.5.0/26"
      "d" = "10.0.5.64/26"
    }
    "name" = "db"
    "network" = "10.0.4.0/22"
  },
  {
    "acl" = "private"
    "azs" = {
      "b" = "10.0.16.0/22"
      "d" = "10.0.21.0/24"
      "c" = "10.0.22.0/24"
    }
    "name" = "worker"
    "network" = "10.0.16.0/20"
  },
]

Also, I can further validate tiered network ranges with ipcalc.

$ ipcalc 10.0.16.0/20

Address:   10.0.16.0            00001010.00000000.0001 0000.00000000
Netmask:   255.255.240.0 = 20   11111111.11111111.1111 0000.00000000
Wildcard:  0.0.15.255           00000000.00000000.0000 1111.11111111
=>
Network:   10.0.16.0/20         00001010.00000000.0001 0000.00000000
HostMin:   10.0.16.1            00001010.00000000.0001 0000.00000001
HostMax:   10.0.31.254          00001010.00000000.0001 1111.11111110
Broadcast: 10.0.31.255          00001010.00000000.0001 1111.11111111
Hosts/Net: 4094                  Class A, Private Internet

A more detailed break down of subnets within a tiered network.

$ ipcalc 10.0.16.0/20 /24

Address:   10.0.16.0            00001010.00000000.0001 0000.00000000
Netmask:   255.255.240.0 = 20   11111111.11111111.1111 0000.00000000
Wildcard:  0.0.15.255           00000000.00000000.0000 1111.11111111
=>
Network:   10.0.16.0/20         00001010.00000000.0001 0000.00000000
HostMin:   10.0.16.1            00001010.00000000.0001 0000.00000001
HostMax:   10.0.31.254          00001010.00000000.0001 1111.11111110
Broadcast: 10.0.31.255          00001010.00000000.0001 1111.11111111
Hosts/Net: 4094                  Class A, Private Internet

Subnets after transition from /20 to /24

Netmask:   255.255.255.0 = 24   11111111.11111111.11111111. 00000000
Wildcard:  0.0.0.255            00000000.00000000.00000000. 11111111

 1.
Network:   10.0.16.0/24         00001010.00000000.00010000. 00000000
HostMin:   10.0.16.1            00001010.00000000.00010000. 00000001
HostMax:   10.0.16.254          00001010.00000000.00010000. 11111110
Broadcast: 10.0.16.255          00001010.00000000.00010000. 11111111
Hosts/Net: 254                   Class A, Private Internet

 2.
Network:   10.0.17.0/24         00001010.00000000.00010001. 00000000
HostMin:   10.0.17.1            00001010.00000000.00010001. 00000001
HostMax:   10.0.17.254          00001010.00000000.00010001. 11111110
Broadcast: 10.0.17.255          00001010.00000000.00010001. 11111111
Hosts/Net: 254                   Class A, Private Internet

...

 15.
Network:   10.0.30.0/24         00001010.00000000.00011110. 00000000
HostMin:   10.0.30.1            00001010.00000000.00011110. 00000001
HostMax:   10.0.30.254          00001010.00000000.00011110. 11111110
Broadcast: 10.0.30.255          00001010.00000000.00011110. 11111111
Hosts/Net: 254                   Class A, Private Internet

 16.
Network:   10.0.31.0/24         00001010.00000000.00011111. 00000000
HostMin:   10.0.31.1            00001010.00000000.00011111. 00000001
HostMax:   10.0.31.254          00001010.00000000.00011111. 11111110
Broadcast: 10.0.31.255          00001010.00000000.00011111. 11111111
Hosts/Net: 254                   Class A, Private Internet


Subnets:   16
Hosts:     4064

The moral of the story is Know Thy Subnetting.

~jq1

Feedback

What did you think about this post? jude@jq1.io