feat: multi-site scalability, locals refactor, README

This commit is contained in:
Xavier Lario
2026-04-20 10:16:49 +02:00
parent 881d0ac5b8
commit 8ff53503db
23 changed files with 1155 additions and 740 deletions
+643
View File
@@ -0,0 +1,643 @@
# EQT Network — Meraki Infrastructure as Code
All Meraki network configuration is managed as Infrastructure as Code using [Terraform](https://www.terraform.io/) with the [`CiscoDevNet/meraki`](https://registry.terraform.io/providers/CiscoDevNet/meraki/latest) provider (v1.9.0). Every change is deployed through a GitHub Actions CI/CD pipeline — **no one runs `terraform apply` locally**. Terraform state is stored remotely in an S3 bucket with DynamoDB locking to prevent concurrent modifications.
---
## TL;DR
> [!CAUTION]
> **Never run `terraform apply` locally.** All applies go through the GitHub Actions pipeline to ensure auditability and prevent state drift.
### Modifying an existing site
**1. Find the right file** in `sites/<SITE>/`:
| What you want to change | File |
|------------------------|------|
| VLAN IDs, subnets, gateway IPs, DHCP | `vlans.tf` |
| Wi-Fi SSIDs | `ssids.tf` |
| Firewall rules | `firewall.tf` |
| Switch ports, stacks, 802.1X policies | `switch.tf` |
| MX LAN ports | `appliance.tf` |
| WAN IPs, Warm Spare (HA) | `wan.tf` |
| Organization or network name | `main.tf` (top `locals` block) |
**2.** Edit the value inside the `locals { }` block. All device names (stacks, switches, MX) must match the **exact display name** in the Meraki Dashboard.
**3. Commit, push and open a PR** — see [Step by step — VS Code](#step-by-step--vs-code) or [Step by step — CLI](#step-by-step--cli).
GitHub Actions runs `terraform plan` automatically and posts the output as a PR comment. Review the plan, then merge — `terraform apply` runs automatically on merge.
---
### Adding a new site
```bash
cp -r sites/BCN01-LAB sites/MAD01
```
Then edit only these values in the copied files:
**`main.tf`** — module name, `organization_name` and `network_name`:
```hcl
locals {
organization_name = "..." # exact org name in Meraki Dashboard
network_name = "MAD01" # exact network name in Meraki Dashboard
}
module "mad01" { # rename to match the new site
source = "../../modules/meraki-site"
# everything else stays the same
}
```
**`vlans.tf`** — replace subnets and gateway IPs with the new site's IP ranges.
**`ssids.tf`** — update RADIUS server IPs if different.
**`firewall.tf`** — update any CIDRs that reference site-specific subnets.
**`switch.tf`** — replace stack/switch names (`bcn01-lab-stack01` → actual name in MAD01's Dashboard).
**`appliance.tf`** and **`wan.tf`** — update MX device names and WAN IPs.
**`variables.tf`** — do not touch. It is identical across all sites.
Open a PR — the workflow detects `sites/MAD01/` automatically, no workflow changes needed.
---
## Table of Contents
1. [Repository Structure](#1-repository-structure)
2. [How It Works — Architecture Overview](#2-how-it-works--architecture-overview)
3. [Change Workflow](#3-change-workflow)
4. [Making a Change to an Existing Site](#4-making-a-change-to-an-existing-site)
5. [Adding a New Site](#5-adding-a-new-site)
6. [Configuration Reference](#6-configuration-reference)
7. [GitHub Actions Workflows](#7-github-actions-workflows)
8. [Sensitive Variables and Secrets](#8-sensitive-variables-and-secrets)
9. [Running Terraform Locally (plan only)](#9-running-terraform-locally-plan-only)
10. [Manual Steps — Provider Limitations](#10-manual-steps--provider-limitations)
---
## 1. Repository Structure
```
.
├── backend.hcl # Shared S3 backend config (bucket, region, DynamoDB table)
├── modules/
│ └── meraki-site/ # Reusable module — all Meraki resource logic lives here
│ ├── main.tf # Resource definitions (VLANs, SSIDs, firewall, switches, WAN, HA)
│ ├── variables.tf # All input variable declarations with types and defaults
│ └── outputs.tf # Exported values (network_id, vlan_ids, stack_ids, device_serials)
├── sites/
│ └── BCN01-LAB/ # One directory per physical site
│ ├── main.tf # Terraform backend + organization/network locals + module call
│ ├── variables.tf # Only two sensitive vars: radius_secret, wifi_password_psk
│ ├── vlans.tf # locals: VLAN definitions (IDs, subnets, DHCP)
│ ├── ssids.tf # locals: Wireless SSID configuration
│ ├── firewall.tf # locals: L3 firewall rules
│ ├── switch.tf # locals: Switch ports, stacks, 802.1X policies
│ ├── appliance.tf # locals: MX LAN port configuration
│ ├── wan.tf # locals: WAN uplinks and Warm Spare (HA)
│ └── MANUAL_STEPS.md # Steps that cannot be automated (provider limitations)
└── .github/
└── workflows/
├── plan.yml # Runs terraform plan on Pull Requests
└── apply.yml # Runs terraform apply on merge to main
```
> **One directory per site.** Each directory under `sites/` is a fully independent Terraform root module with its own remote state. Sites share the `modules/meraki-site` module but have no shared state between them.
>
> **No variable boilerplate.** Site configuration lives in `locals {}` blocks — no need to re-declare types and defaults that already exist in the module. The only `variables.tf` in a site holds the two sensitive variables that must arrive via `TF_VAR_*` environment variables.
---
## 2. How It Works — Architecture Overview
### Module pattern
The `modules/meraki-site` module encapsulates all Meraki resource logic. A site directory is a thin wrapper that calls the module with site-specific locals and declares the remote backend:
```
sites/BCN01-LAB/
*.tf (locals) ──► main.tf ──► module "meraki-site" ──► Meraki API
│
modules/meraki-site/
main.tf (resources)
variables.tf
```
### Dynamic resource resolution
Terraform never needs device serials hardcoded. At plan time, the module:
- Calls `data "meraki_network_devices"` to build a `name → serial` map for MX and standalone switches
- Calls `data "meraki_switch_stacks"` to resolve stack names to their member serials
This means you reference devices by their **Dashboard display name** in all configuration files.
### Port range expansion
Switch port configuration accepts ranges like `"1-24"`, `"47-48"`, or `"1-3,5,47"`. The module expands these into individual port resources at plan time. A single config entry can configure dozens of ports.
### VLAN and L3 gateway
The module creates L3 VLAN interfaces on the MX for every VLAN with a `subnet` defined. VLANs without a subnet (e.g. a pure-switching WAN VLAN) are created as L2-only and excluded from the MX gateway resources.
### Firewall rules
`meraki_appliance_l3_firewall_rules` **replaces the entire rule set** on every apply. The list in `firewall.tf` is authoritative. Rules are evaluated top-down; always end the list with an explicit deny-all rule.
### SSID split
The Meraki API rejects the `wpa_encryption_mode` attribute for SSIDs with `auth_mode = "open"`. The module handles this internally by splitting SSIDs into two resources — one for open SSIDs and one for all others. No action needed from the operator.
---
## 3. Change Workflow
> **Never run `terraform apply` locally.** All applies go through GitHub Actions to ensure auditability and prevent state drift.
Every change follows this Git-based process:
```
1. Create a feature branch
2. Edit the relevant .tf file under sites/<site>/
3. Commit the changes
4. Push the branch and open a Pull Request
5. GitHub Actions runs terraform plan and posts the output as a PR comment
6. Team member reviews the plan output in the PR
7. Approve & merge → GitHub Actions runs terraform apply automatically
```
### Branch and commit naming
| Type | Pattern | Example |
|------|---------|---------|
| Branch | `feature/<site>-<description>` | `feature/BCN01-LAB-add-iot-vlan` |
| Commit | `feat(<site>): <description>` | `feat(BCN01-LAB): add IoT VLAN 112` |
| Bugfix branch | `fix/<site>-<description>` | `fix/BCN01-LAB-ssid-visible` |
| Bugfix commit | `fix(<site>): <description>` | `fix(BCN01-LAB): set EQT-CORPO-OWE-OK to hidden` |
### Step by step — VS Code
1. Click the branch name in the bottom-left status bar → **Create new branch** → enter `feature/<site>-<description>`
2. Edit the relevant file(s) under `sites/<site>/`
3. Open the **Source Control** panel (`Ctrl+Shift+G` / `Cmd+Shift+G`)
4. Click **`+`** next to each changed file (or next to "Changes" to stage all)
5. Type the commit message in the text box and click **Commit**
6. Click **Publish Branch** — this pushes the branch to GitHub
7. Open a PR:
- **Option A** — GitHub will show a banner in the repo: *"Compare & pull request"*. Click it.
- **Option B** — Install the [GitHub Pull Requests](https://marketplace.visualstudio.com/items?itemName=GitHub.vscode-pull-request-github) extension and create the PR directly from VS Code without opening the browser.
### Step by step — CLI
```bash
# 1. Create the branch
git checkout -b feature/<site>-<description>
# 2. Edit files, then stage and commit
git add sites/<site>/<file>.tf
git commit -m "feat(<site>): <description>"
# 3. Push the branch
git push origin feature/<site>-<description>
# 4. Open a PR (interactive) or directly in the browser
gh pr create --title "feat(<site>): <description>"
gh pr create --web
```
> The `gh` CLI must be installed and authenticated (`gh auth login`).
---
## 4. Making a Change to an Existing Site
For a quick reference on which file to edit, see the [TL;DR](#tldr) at the top. The examples below show the syntax for the most common changes.
### Example: adding a firewall rule
Edit `sites/<site>/firewall.tf`. The `src_port` and `dest_port` fields default to `"any"` and can be omitted:
```hcl
locals {
firewall_rules = [
# ... existing rules ...
{
comment = "Allow IoT to NTP server"
policy = "allow"
protocol = "udp"
src_cidr = "10.2.60.0/24" # IoT VLAN
dest_cidr = "10.2.56.10/32" # NTP server
dest_port = "123"
},
{
comment = "Deny all other traffic"
policy = "deny"
protocol = "any"
src_cidr = "any"
dest_cidr = "any"
},
]
}
```
### Example: adding a VLAN
Edit `sites/<site>/vlans.tf`. The map key is the VLAN ID:
```hcl
locals {
switch_vlans = {
# ... existing VLANs ...
"112" = {
name = "IOT"
subnet = "10.2.60.0/24"
appliance_ip = "10.2.60.1"
reserved_ip_ranges = [
{ comment = "Static reserved", id = "static", start = "10.2.60.1", end = "10.2.60.49" }
]
}
}
}
```
### Example: configuring switch ports
Edit `sites/<site>/switch.tf`. Use `switch_stack_port_configs` to apply a config to all members of a stack, or `switch_named_port_configs` to target a specific switch by name:
```hcl
locals {
# Apply to all members of the stack
switch_stack_port_configs = [
{
stack_name = "bcn01-lab-stack01" # exact name from Dashboard
port_range = "1-44"
type = "access"
vlan = 100 # fallback VLAN if RADIUS doesn't assign one
access_policy_type = "Custom access policy"
access_policy_number = 1 # references the DOT1X-CORPO policy
},
]
# Target a specific stack member by display name
switch_named_port_configs = [
{
switch_name = "bcn01-lab-sw01"
port_range = "45-48"
type = "trunk"
vlan = 109 # native (untagged) VLAN
allowed_vlans = "all"
},
]
}
```
`port_range` supports single ports (`"1"`), ranges (`"1-24"`), and mixed (`"1-3,5,47"`).
### Example: adding a wireless SSID
Edit `sites/<site>/ssids.tf`. Meraki numbers SSIDs from 0 to 14:
```hcl
locals {
wireless_ssids = [
# ... existing SSIDs ...
{
number = 3
name = "EQT-IOT"
enabled = true
auth_mode = "psk"
encryption_mode = "wpa"
wpa_encryption_mode = "WPA3 Transition Mode"
# Password is injected via TF_VAR_wifi_password_psk (GitHub Secret)
ip_assignment_mode = "Bridge mode"
use_vlan_tagging = true
default_vlan_id = 112
},
]
}
```
**Auth mode reference:**
| `auth_mode` | Use case | Notes |
|-------------|----------|-------|
| `"open"` | Open network | `wpa_encryption_mode` must be omitted |
| `"open-enhanced"` | OWE (Opportunistic Wireless Encryption) | Use with `wpa_encryption_mode = "WPA3 only"` |
| `"psk"` | WPA2/WPA3 with shared password | Requires `encryption_mode = "wpa"` |
| `"8021x-radius"` | Enterprise 802.1X | Requires `radius_servers` list |
---
## 5. Adding a New Site
Adding a new site requires creating one new directory. The GitHub Actions workflows detect it automatically — no workflow changes needed.
### Step 1 — Copy an existing site
```bash
cp -r sites/BCN01-LAB sites/MAD01
```
### Step 2 — Update `sites/MAD01/main.tf`
Change the module name and the two locals at the top:
```hcl
locals {
organization_name = "..." # exact org name in Meraki Dashboard
network_name = "MAD01" # exact network name in Meraki Dashboard
}
module "mad01" { # rename to match the new site
source = "../../modules/meraki-site"
# everything else stays the same
}
```
The S3 state key is derived automatically from the directory name (`MAD01/terraform.tfstate`) — no manual backend configuration needed.
### Step 3 — Update the config files
Replace BCN01-LAB-specific values with the new site's actual configuration. See the [TL;DR](#adding-a-new-site) for the per-file summary, and the [Configuration Reference](#6-configuration-reference) for the full schema of each block.
> Device names (`stack_name`, `switch_name`, MX names) must match the **exact display names** in the Meraki Dashboard for that network.
### Step 4 — Open a PR
See [Step by step — VS Code](#step-by-step--vs-code) or [Step by step — CLI](#step-by-step--cli).
GitHub Actions detects the new `sites/MAD01/` directory, runs `terraform plan`, and posts the output as a PR comment. Review the plan, then merge to apply.
### Step 5 — Review `MANUAL_STEPS.md`
After the initial apply, check `sites/MAD01/MANUAL_STEPS.md` for any Dashboard steps that could not be automated. See [Section 10](#10-manual-steps--provider-limitations) for known provider limitations.
---
## 6. Configuration Reference
### VLANs (`vlans.tf`)
```hcl
switch_vlans = {
"<vlan_id>" = {
name = string # Display name
subnet = optional string # CIDR, e.g. "10.2.32.0/21". Null = L2 only (no MX gateway)
appliance_ip = optional string # MX gateway IP within the subnet
dhcp_handling = optional string # "Run a DHCP server" (default)
# "Relay DHCP to another server"
# "Do not respond to DHCP requests"
reserved_ip_ranges = optional list of {
comment = string
id = string # unique identifier, e.g. "static"
start = string # first IP to reserve
end = string # last IP to reserve
}
}
}
```
### Firewall rules (`firewall.tf`)
Rules are applied **in order**. The last rule should always be an explicit deny-all. The entire list replaces the Dashboard rules on every apply.
```hcl
firewall_rules = [
{
comment = string # Human-readable description
policy = "allow" | "deny"
protocol = "any" | "tcp" | "udp" | "icmp"
src_cidr = string # CIDR or "any"
src_port = optional string # Port or "any" (default: "any")
dest_cidr = string # CIDR or "any"
dest_port = optional string # Port or "any" (default: "any")
syslog_enabled = optional bool # default: false
},
]
```
### Wireless SSIDs (`ssids.tf`)
```hcl
wireless_ssids = [
{
number = number # Meraki SSID slot (0–14)
name = string
enabled = optional bool # default: true
visible = optional bool # false = hidden SSID. default: true
auth_mode = string # "open", "open-enhanced", "psk", "8021x-radius"
encryption_mode = optional string # "wpa" required for psk; null otherwise
wpa_encryption_mode = optional string # "WPA3 only", "WPA3 Transition Mode". Null for open
splash_page = optional string # default: "None"
ip_assignment_mode = optional string # default: "Bridge mode"
use_vlan_tagging = optional bool # default: false
default_vlan_id = optional number
radius_servers = optional list of {
host = string # RADIUS server IP
port = number
# secret is injected from TF_VAR_radius_secret — never put it here
}
},
]
```
### Switch access policies (`switch.tf`)
```hcl
switch_access_policies = [
{
name = string # referenced by access_policy_number in port configs
access_policy_type = optional string # "802.1x" (default), "Hybrid authentication"
host_mode = optional string # "Multi-Auth" (default)
radius_failed_auth_vlan_id = optional number # fallback VLAN if RADIUS unreachable
radius_re_authentication_interval = optional number # seconds. 0 = disabled
radius_servers = list of {
host = string
port = number
}
},
]
```
### Switch port configs (`switch.tf`)
Three methods — use whichever fits:
```hcl
# 1. By explicit serial
switch_port_configs = [
{
serial = "XXXX-XXXX-XXXX"
port_range = "1-24"
type = "access" | "trunk"
vlan = optional number # access VLAN (access) or native VLAN (trunk)
allowed_vlans = optional string # trunk only. default: "all"
access_policy_type = optional string # "Open" (default) or "Custom access policy"
access_policy_number = optional number # index of the policy in switch_access_policies
},
]
# 2. By stack name — applies to ALL members of the stack
switch_stack_port_configs = [
{
stack_name = "bcn01-lab-stack01" # exact Dashboard name
port_range = "1-44"
# ... same fields as above ...
},
]
# 3. By switch display name — resolves serial dynamically
switch_named_port_configs = [
{
switch_name = "bcn01-lab-sw01" # exact Dashboard name
port_range = "1,2,3"
# ... same fields as above ...
},
]
```
### MX WAN and HA (`wan.tf`)
```hcl
mx_wan_uplinks = [
{
name = "BCN01-F04-MX01" # exact Dashboard device name
wan1_static_ip = "x.x.x.x"
wan1_static_subnet_mask = "255.255.255.240"
wan1_static_gateway_ip = "x.x.x.x"
wan1_static_dns = ["8.8.8.8", "8.8.4.4"]
},
]
mx_warm_spare = {
enabled = true
spare_name = "BCN01-F04-MX02" # exact Dashboard device name
uplink_mode = "virtual"
virtual_ip1 = "x.x.x.x" # floating VIP on WAN1
virtual_ip2 = "x.x.x.x" # floating VIP on WAN2 (if applicable)
}
```
---
## 7. GitHub Actions Workflows
Both workflows use a `detect-sites` job that dynamically determines which sites to plan or apply based on which files changed.
| Trigger | Workflow | Action |
|---------|----------|--------|
| Pull Request → `main` | `plan.yml` | Runs `terraform plan` for each changed site, posts output as PR comment |
| Push to `main` (merge) | `apply.yml` | Runs `terraform apply` for each changed site, serialized |
**Site detection logic:**
- `modules/` or `backend.hcl` changed → all sites planned/applied
- Only `sites/<name>/` changed → only that site planned/applied
- No relevant files changed → workflow skips entirely
### `plan.yml` — Pull Request
```
PR opened/updated
│
▼
detect-sites (ubuntu-latest) — reads git diff, builds site matrix
│
▼
plan (self-hosted, matrix per site, parallel)
├── terraform init
├── terraform plan → plan_output.txt
└── Post plan as PR comment
```
### `apply.yml` — Merge to main
```
Merge to main
│
▼
detect-sites (ubuntu-latest)
│
▼
apply (self-hosted, matrix per site, max-parallel: 1)
├── terraform init
└── terraform apply -auto-approve
```
`max-parallel: 1` serializes applies across sites to avoid DynamoDB lock contention.
### Backend initialization
```bash
terraform init \
-backend-config=../../backend.hcl \ # shared: bucket, region, dynamodb_table
-backend-config="key=<site>/terraform.tfstate" # site-specific state path
```
---
## 8. Sensitive Variables and Secrets
Two variables must **never** appear in any `.tf` file. They are injected at runtime via environment variables:
| Variable | GitHub Secret | Injected as |
|----------|--------------|-------------|
| `radius_secret` | `RADIUS_SECRET` | `TF_VAR_radius_secret` |
| `wifi_password_psk` | `WIFI_PASSWORD_PSK` | `TF_VAR_wifi_password_psk` |
All other required GitHub Secrets:
| Secret | Purpose |
|--------|---------|
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | S3 backend (state storage) |
| `MERAKI_DASHBOARD_API_KEY` | Meraki API authentication |
The RADIUS secret is shared across all RADIUS servers (SSIDs and switch 802.1X policies). If a site requires a different secret, a new GitHub Secret and a separate variable must be added.
---
## 9. Running Terraform Locally (plan only)
Local `terraform plan` is useful for debugging. `terraform apply` must never be run locally.
```bash
export MERAKI_DASHBOARD_API_KEY="your-api-key"
export AWS_ACCESS_KEY_ID="..."
export AWS_SECRET_ACCESS_KEY="..."
export AWS_REGION="us-east-1"
export TF_VAR_radius_secret="..."
export TF_VAR_wifi_password_psk="..."
cd sites/BCN01-LAB
terraform init \
-backend-config=../../backend.hcl \
-backend-config="key=BCN01-LAB/terraform.tfstate"
terraform plan
```
> The first `terraform init` downloads the provider binary into `.terraform/`. This directory is gitignored.
---
## 10. Manual Steps — Provider Limitations
Some Meraki features are not yet supported by the `CiscoDevNet/meraki` provider v1.9.0 and must be configured directly in the Meraki Dashboard. Each site directory should include a `MANUAL_STEPS.md` documenting any steps that cannot be automated for that site.
For `BCN01-LAB`, see [`sites/BCN01-LAB/MANUAL_STEPS.md`](sites/BCN01-LAB/MANUAL_STEPS.md).
| Feature | Status | Notes |
|---------|--------|-------|
| Client VPN (L2TP/IPSec) | Manual | No resource exists in provider v1.9.0 |
| OWE initial activation | Warning | Provider manages `auth_mode = "open-enhanced"` correctly; a one-time Dashboard confirmation may be needed after the very first apply |
When a previously manual step becomes supported by the provider, migrate it to the appropriate `.tf` config file and remove it from `MANUAL_STEPS.md`.