Add plan and reference card for UniFi Express 7 Pulumi stack (#145)
## 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
This commit is contained in:
parent
f65d11d55b
commit
0dce806107
12 changed files with 314 additions and 7 deletions
1
docs/changelog.d/feature-unifi-pulumi.bugfix.md
Normal file
1
docs/changelog.d/feature-unifi-pulumi.bugfix.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
Replace `op item get --fields` with `op read` in all mise tasks (tailnet-up, tailnet-preview, dns-up, dns-preview) to prevent multi-line secret corruption.
|
||||
1
docs/changelog.d/feature-unifi-pulumi.doc.md
Normal file
1
docs/changelog.d/feature-unifi-pulumi.doc.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
Add plan and reference card for UniFi Express 7 Pulumi IaC management.
|
||||
|
|
@ -53,3 +53,4 @@ Migration and transition plans for upcoming infrastructure changes.
|
|||
|------|-------------|
|
||||
| [[plans]] | Index of all plans |
|
||||
| [[migrate-forgejo-from-brew]] | Transition Forgejo from Homebrew to source-built binary |
|
||||
| [[add-unifi-pulumi-stack]] | Add Pulumi IaC for UniFi Express 7 |
|
||||
|
|
|
|||
225
docs/how-to/plans/add-unifi-pulumi-stack.md
Normal file
225
docs/how-to/plans/add-unifi-pulumi-stack.md
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
---
|
||||
title: "Plan: Add UniFi Pulumi Stack"
|
||||
tags:
|
||||
- how-to
|
||||
- plans
|
||||
- networking
|
||||
- pulumi
|
||||
---
|
||||
|
||||
# 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:
|
||||
|
||||
1. **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
|
||||
2. **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`, item `unifi - blumeops`, fields `api_key` and `url`
|
||||
- [ ] 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](https://github.com/filipowm/terraform-provider-unifi) of the UniFi Terraform provider directly from Pulumi. This approach:
|
||||
|
||||
- **Generates a local Python SDK** in `./sdks/unifi/` and adds a package reference to `Pulumi.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 add` on 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:
|
||||
|
||||
1. **Wrong host** — `platform.node()` is not `indri`
|
||||
2. **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
|
||||
|
||||
```fish
|
||||
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`:
|
||||
|
||||
```bash
|
||||
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)
|
||||
|
||||
```fish
|
||||
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:
|
||||
|
||||
```fish
|
||||
# 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.md` reference 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-preview` shows no unexpected diffs
|
||||
- Pre-commit hooks pass
|
||||
- `docs-check-links` and `docs-check-index` pass
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- **macOS-specific guard** — the `networksetup` check only works on macOS, which is fine since indri is permanently macOS
|
||||
- **User group ID discovery** — the provider may not expose a `get_user_group` data 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 |
|
||||
|
||||
## Related
|
||||
|
||||
- [[hosts]] - Device inventory (UniFi Express 7)
|
||||
- [[unifi]] - Reference card
|
||||
- [[power]] - UPS power chain
|
||||
- [[indri]] - Server connected via Cat6 Ethernet
|
||||
- [[tailscale]] - Tailnet networking
|
||||
|
|
@ -14,3 +14,4 @@ Plans differ from regular how-to guides in that they describe work that has been
|
|||
| Plan | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| [[migrate-forgejo-from-brew]] | Planned | Transition Forgejo from Homebrew to source-built binary with LaunchAgent |
|
||||
| [[add-unifi-pulumi-stack]] | Planned | Add Pulumi IaC for UniFi Express 7 home network |
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ All devices connected via [Tailscale](https://login.tailscale.com/) tailnet `tai
|
|||
| **Gilbert** | MacBook Air M4, 2025 - Workstation | [[gilbert|Details]] |
|
||||
| **[[sifaka|Sifaka]]** | Synology NAS - Storage & backups | [[sifaka|Details]] |
|
||||
| **Mouse** | MacBook Air M2 - Allison's laptop | - |
|
||||
| **UniFi** | UniFi Express 7 - Home WiFi | - |
|
||||
| **UniFi** | UniFi Express 7 - Home WiFi | [[unifi|Details]] |
|
||||
| **Dwarf** | iPad Air - Employer-provided, off tailnet | - |
|
||||
|
||||
## Related
|
||||
|
|
|
|||
77
docs/reference/infrastructure/unifi.md
Normal file
77
docs/reference/infrastructure/unifi.md
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
---
|
||||
title: UniFi
|
||||
tags:
|
||||
- infrastructure
|
||||
- networking
|
||||
---
|
||||
|
||||
# UniFi
|
||||
|
||||
Home WiFi router and network controller, managed via Pulumi IaC.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Model** | UniFi Express 7 (UX7) |
|
||||
| **LAN IP** | `192.168.1.1` |
|
||||
| **Management URL** | `https://192.168.1.1` |
|
||||
| **IaC** | `pulumi/unifi/` (planned) |
|
||||
| **Stack** | `home-network` (planned) |
|
||||
| **Power** | Battery-backed via UPS (see [[power]]) |
|
||||
|
||||
## What It Does
|
||||
|
||||
The UX7 is the home WiFi access point and network gateway. It provides:
|
||||
|
||||
- WiFi (main, guest, IoT networks)
|
||||
- DHCP for `192.168.1.0/24`
|
||||
- Built-in UniFi controller for managing adopted devices (switches, APs)
|
||||
- Firewall and traffic management
|
||||
|
||||
## Network Topology
|
||||
|
||||
```
|
||||
ISP Modem
|
||||
└── UniFi Express 7 [WAN]
|
||||
└── [LAN port] ──→ Switch A (by router/sifaka)
|
||||
├── sifaka (Synology NAS)
|
||||
└── ~12ft Cat6 ──→ Switch B (on desk)
|
||||
├── indri (Mac Mini, primary server)
|
||||
├── ringtail (Raspberry Pi)
|
||||
└── (gilbert via USB-C adapter, optional)
|
||||
```
|
||||
|
||||
All wired devices share the `192.168.1.0/24` subnet. The two daisy-chained UniFi Switch Flex Minis provide enough ports for all devices while using the UX7's single LAN port.
|
||||
|
||||
## Pulumi Configuration (Planned)
|
||||
|
||||
The Pulumi program will live in `pulumi/unifi/`:
|
||||
|
||||
- `__main__.py` — declares networks, WLANs, and firewall zones
|
||||
- `Pulumi.home-network.yaml` — stack config (router URL, site)
|
||||
- `sdks/unifi/` — generated Python SDK from `pulumi package add terraform-provider filipowm/unifi`
|
||||
|
||||
Provider: [filipowm/terraform-provider-unifi](https://github.com/filipowm/terraform-provider-unifi) v1.0.0, consumed via `pulumi package add terraform-provider`.
|
||||
|
||||
See [[add-unifi-pulumi-stack]] for the full implementation plan.
|
||||
|
||||
## Operations
|
||||
|
||||
| Task | Command |
|
||||
|------|---------|
|
||||
| Preview changes | `mise run unifi-preview` (planned) |
|
||||
| Apply changes | `mise run unifi-up` (planned) |
|
||||
| Web management | `https://192.168.1.1` |
|
||||
|
||||
## Authentication
|
||||
|
||||
The provider uses an API key created in the UX7 control plane (Settings → Control Plane → API). The key is stored in 1Password (`op://blumeops/unifi - blumeops/api_key`) and injected via mise task environment variables.
|
||||
|
||||
## Related
|
||||
|
||||
- [[add-unifi-pulumi-stack]] - Implementation plan
|
||||
- [[hosts]] - Device inventory
|
||||
- [[power]] - UPS power chain
|
||||
- [[indri]] - Primary server (wired connection required for management)
|
||||
- [[tailscale]] - Tailnet networking
|
||||
|
|
@ -46,6 +46,7 @@ Host inventory and network configuration.
|
|||
- [[gilbert]] - Development workstation
|
||||
- [[tailscale]] - ACLs, groups, tags
|
||||
- [[gandi]] - DNS hosting for `eblu.me`
|
||||
- [[unifi]] - Home WiFi router (UniFi Express 7)
|
||||
- [[routing|Routing]] - DNS domains, port mappings
|
||||
- [[power]] - Battery-backed power chain
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
set -euo pipefail
|
||||
|
||||
GANDI_PERSONAL_ACCESS_TOKEN=$(op --vault vg6xf6vvfmoh5hqjjhlhbeoaie item get mco6ka3dc3rmw7zkg2dhia5d2m --fields pat --reveal)
|
||||
GANDI_PERSONAL_ACCESS_TOKEN=$(op read "op://blumeops/gandi - blumeops/pat")
|
||||
export GANDI_PERSONAL_ACCESS_TOKEN
|
||||
|
||||
cd "$(dirname "$0")/../pulumi/gandi"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
set -euo pipefail
|
||||
|
||||
GANDI_PERSONAL_ACCESS_TOKEN=$(op --vault vg6xf6vvfmoh5hqjjhlhbeoaie item get mco6ka3dc3rmw7zkg2dhia5d2m --fields pat --reveal)
|
||||
GANDI_PERSONAL_ACCESS_TOKEN=$(op read "op://blumeops/gandi - blumeops/pat")
|
||||
export GANDI_PERSONAL_ACCESS_TOKEN
|
||||
|
||||
cd "$(dirname "$0")/../pulumi/gandi"
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
|
||||
set -euo pipefail
|
||||
|
||||
TAILSCALE_OAUTH_CLIENT_ID=$(op --vault vg6xf6vvfmoh5hqjjhlhbeoaie item get wi6bkf7bcccwfy4eu776ab4p4u --fields client_id)
|
||||
TAILSCALE_OAUTH_CLIENT_ID=$(op read "op://blumeops/tailscale - blumeops/client_id")
|
||||
export TAILSCALE_OAUTH_CLIENT_ID
|
||||
TAILSCALE_OAUTH_CLIENT_SECRET=$(op --vault vg6xf6vvfmoh5hqjjhlhbeoaie item get wi6bkf7bcccwfy4eu776ab4p4u --fields client_secret --reveal)
|
||||
TAILSCALE_OAUTH_CLIENT_SECRET=$(op read "op://blumeops/tailscale - blumeops/client_secret")
|
||||
export TAILSCALE_OAUTH_CLIENT_SECRET
|
||||
export TAILSCALE_TAILNET="tail8d86e.ts.net"
|
||||
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
|
||||
set -euo pipefail
|
||||
|
||||
TAILSCALE_OAUTH_CLIENT_ID=$(op --vault vg6xf6vvfmoh5hqjjhlhbeoaie item get wi6bkf7bcccwfy4eu776ab4p4u --fields client_id)
|
||||
TAILSCALE_OAUTH_CLIENT_ID=$(op read "op://blumeops/tailscale - blumeops/client_id")
|
||||
export TAILSCALE_OAUTH_CLIENT_ID
|
||||
TAILSCALE_OAUTH_CLIENT_SECRET=$(op --vault vg6xf6vvfmoh5hqjjhlhbeoaie item get wi6bkf7bcccwfy4eu776ab4p4u --fields client_secret --reveal)
|
||||
TAILSCALE_OAUTH_CLIENT_SECRET=$(op read "op://blumeops/tailscale - blumeops/client_secret")
|
||||
export TAILSCALE_OAUTH_CLIENT_SECRET
|
||||
export TAILSCALE_TAILNET="tail8d86e.ts.net"
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue