feat: multi-site scalability, locals refactor, README
This commit is contained in:
@@ -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`.
|
||||
Reference in New Issue
Block a user