## Summary - Rewrites the UniFi Pulumi plan doc to use filipowm/unifi Terraform provider via `pulumi package add terraform-provider` (replaces pulumiverse_unifi approach) - Adds network segmentation goals (main/guest/IoT WiFi zones) and API key auth - Creates UniFi reference card (`docs/reference/infrastructure/unifi.md`) with topology diagram - Updates all documentation indexes (plans.md, how-to.md, hosts.md, reference.md) ## What's Deferred Actual stack scaffolding (`pulumi/unifi/`), mise tasks, and `pulumi import` are blocked on switch purchase and cabling. The plan doc captures everything needed for a future execution session. ## Verification - `docs-check-links` passes (all wiki-links resolve) - `docs-check-index` passes (unifi.md referenced in reference.md) - Pre-commit hooks pass Reviewed-on: https://forge.ops.eblu.me/eblume/blumeops/pulls/145
9.4 KiB
| title | tags | ||||
|---|---|---|---|---|---|
| Plan: Add UniFi Pulumi Stack |
|
Plan: Add UniFi Pulumi Stack
Status: Planned (not yet executed) Blocked by: Ethernet switch purchase and cabling
Background
The UniFi Express 7 (UX7) is the home WiFi router, currently unmanaged. This plan adds a Pulumi stack (pulumi/unifi/) to bring it under IaC control, following the same conventions as pulumi/tailscale/ and pulumi/gandi/.
Why IaC for the Router?
- Reproducibility — WiFi networks, firewall rules, and DHCP settings are declared in code
- Audit trail — changes go through PR review like all other infrastructure
- Consistency — joins the existing Pulumi stacks for Tailscale ACLs and DNS
- Network segmentation — declare main/guest/IoT WiFi networks with proper firewall zones
Why This Is Blocked
The UX7 has one LAN port, currently connected to sifaka. Modifying WiFi settings over WiFi would sever the management connection mid-apply. We need:
- Two switches (UniFi Switch Flex Mini recommended) daisy-chained:
- Switch A by the router: connects UX7, sifaka
- Switch B on the desk (~12ft cable): connects indri, ringtail, and optionally gilbert
- Cat6 Ethernet cables: one ~12ft run between switches, plus short cables for each device
UniFi Express 7 [LAN port]
└── Switch A (by router/sifaka)
├── sifaka (short cable)
└── ~12ft Cat6 ──→ Switch B (on desk)
├── indri (Cat6)
├── ringtail (Cat6)
└── (gilbert via USB-C adapter, optional)
Daisy-chaining is standard Layer 2 networking — no speed loss per device, no subnet impact. The only shared bottleneck is the 1 Gbps uplink between the two switches, which is more than adequate for homelab use. UniFi Flex Minis will appear in the UX7's controller for monitoring and eventual Pulumi management.
Prerequisites
Before starting the execution session:
- Purchase 2x UniFi Switch Flex Mini (USW-Flex-Mini)
- Purchase Cat6 cables: 1x ~12ft, 3-4x short (~3ft)
- Cable everything up and verify all devices have network connectivity
- Verify indri has an active wired Ethernet connection:
networksetup -getinfo "Ethernet"should show an IP address - Create an API key on the UX7 via
https://192.168.1.1→ Settings → Control Plane → API (preferred over username/password for IaC) - Store UniFi credentials in 1Password: vault
blumeops, itemunifi - blumeops, fieldsapi_keyandurl - Verify Pulumi CLI version is >= v3.147.0 (
pulumi version)
Provider: filipowm/unifi via pulumi package add
We use pulumi package add terraform-provider filipowm/unifi 1.0.0 to consume the filipowm fork of the UniFi Terraform provider directly from Pulumi. This approach:
- Generates a local Python SDK in
./sdks/unifi/and adds a package reference toPulumi.yaml - Supports zone-based firewalls — needed for main/guest/IoT WiFi segmentation and a dedicated blumeops services subnet
- Supports API key authentication — cleaner than username/password for IaC
- Actively maintained — v1.0.0, 520+ commits, not the abandoned upstream
- Uses Pulumi Cloud state — same as existing stacks, free tier
- No fork/bridge maintenance — just re-run
pulumi package addon new provider versions
Why Not pulumiverse_unifi?
The pulumiverse_unifi PyPI package bridges from paultyng/terraform-provider-unifi, which is in maintenance mode. It lacks zone-based firewalls, API key auth, and many newer resource types. The filipowm fork is the active community successor.
Network Segmentation Goals
Once the stack is operational, we plan to configure these network zones:
| Network | VLAN | Subnet | Purpose |
|---|---|---|---|
| Default LAN | 1 | 192.168.1.0/24 |
Main network (indri, gilbert, ringtail, sifaka) |
| Guest | TBD | 192.168.2.0/24 |
Guest WiFi, internet-only |
| IoT | TBD | 192.168.3.0/24 |
Smart devices, isolated from LAN |
Zone-based firewall rules will enforce:
- Guest → Internet only (no LAN, no IoT)
- IoT → Internet + limited LAN access (e.g., mDNS)
- LAN → full access
These will be declared after the initial import is stable.
Pulumi Stack Structure
Following the conventions of pulumi/tailscale/ and pulumi/gandi/:
pulumi/unifi/
├── Pulumi.yaml # name: blumeops-unifi, python runtime, uv toolchain
│ # includes parameterized package reference for filipowm/unifi
├── Pulumi.home-network.yaml # Stack config: router_url, site
├── pyproject.toml # Python >=3.11, pulumi>=3.0.0
├── sdks/unifi/ # Generated Python SDK from pulumi package add
│ └── ... # (auto-generated, committed to repo)
├── __main__.py # Main program with safety guard
├── .gitignore # .venv/, __pycache__/, *.py[cod]
└── uv.lock # Generated by uv sync, committed
Provider Configuration
Authentication (via environment variables in mise tasks):
| Variable | Value | Notes |
|---|---|---|
UNIFI_API_KEY |
from 1Password | API key created in UX7 control plane |
UNIFI_API |
https://192.168.1.1:443 |
No /api suffix — SDK auto-discovers /proxy/network for UniFi OS |
UNIFI_INSECURE |
true |
UX7 uses a self-signed TLS certificate |
Safety Guard
The __main__.py must fail fast before creating any Pulumi resources if:
- Wrong host —
platform.node()is notindri - No wired connection —
networksetup -getinfo "Ethernet"shows no active Ethernet
This prevents accidentally running the stack from gilbert over WiFi.
Execution Steps
Step 1: Create Stack Directory and Install Provider
mkdir -p pulumi/unifi
cd pulumi/unifi
# Create Pulumi.yaml, pyproject.toml, .gitignore, __main__.py
pulumi package add terraform-provider filipowm/unifi 1.0.0
# This generates sdks/unifi/ and updates Pulumi.yaml with package reference
pulumi install
uv sync
Step 2: Create Stack Files
Create Pulumi.yaml, Pulumi.home-network.yaml, pyproject.toml, .gitignore, and __main__.py. The main program should declare:
- Ethernet safety guard (hostname + wired connection check)
- Default LAN network resource (corporate,
192.168.1.0/24, DHCP) - WiFi WLAN resources (commented out initially — need SSID names and IDs from the controller)
- Exports for router IP, network ID, subnet
Step 3: Create Mise Tasks
Create mise-tasks/unifi-preview and mise-tasks/unifi-up following the pattern from tailnet-up/dns-up:
UNIFI_API_KEY=$(op read "op://blumeops/unifi - blumeops/api_key")
export UNIFI_API="https://192.168.1.1:443"
export UNIFI_INSECURE="true"
Step 4: Initialize Stack (on indri)
cd pulumi/unifi
uv sync
pulumi stack init home-network
Step 5: Import Existing Resources
Discover resource IDs from the UniFi controller API or web UI, then import:
# Import default network
pulumi import unifi:index/network:Network default-lan <network-id>
# Later, import WLANs
pulumi import unifi:index/wlan:Wlan home-wifi <wlan-id>
Adjust __main__.py resource properties to match the actual controller state until pulumi preview shows no diff.
Step 6: Documentation Updates
- Create
docs/reference/infrastructure/unifi.mdreference card - Update
docs/reference/infrastructure/hosts.md— link UniFi row to[[unifi|Details]] - Update
docs/reference/reference.md— add[[unifi]]to Infrastructure section - Add changelog fragment
Step 7: Verify
mise run unifi-previewshows no unexpected diffs- Pre-commit hooks pass
docs-check-linksanddocs-check-indexpass
Known Limitations
- macOS-specific guard — the
networksetupcheck only works on macOS, which is fine since indri is permanently macOS - User group ID discovery — the provider may not expose a
get_user_groupdata source. Must be discovered manually from the controller API (/proxy/network/api/s/default/rest/usergroup) and hardcoded
Future Considerations
- Firewall rules — declare zone-based firewall rules after the initial import is stable
- UnPoller — add Prometheus metrics exporter for UniFi gear, integrates with existing Grafana stack
- Switch management — manage the USW-Flex-Minis via the same Pulumi stack once adopted into the UX7 controller
- Provider updates — re-run
pulumi package add terraform-provider filipowm/unifi <new-version>to update
Reference Pattern Files
| File | Purpose |
|---|---|
pulumi/tailscale/__main__.py |
Pulumi program pattern (resources, exports, data sources) |
pulumi/gandi/__main__.py |
Config resolution pattern (pulumi.Config().require()) |
pulumi/tailscale/Pulumi.yaml |
Project definition pattern |
pulumi/gandi/Pulumi.eblu-me.yaml |
Stack config pattern |
mise-tasks/tailnet-up |
Mise task credential pattern (op read) |
docs/reference/infrastructure/gandi.md |
Infrastructure reference card pattern |