Add Docker Swarm HA cluster: ha-docker-1 and ha-docker-2

Two new NixOS Proxmox VMs (VMIDs 202/203) forming a dual-manager Docker
Swarm on dedicated vmbr3 (192.168.30.0/24, VLAN 30) for gossip and VXLAN,
with NFS via the storage-client network (vmbr2) from the existing HA cluster.

- nixos/variables.nix: add ha-docker IP/interface/port vars and swarm CIDR
- nixos/modules/build-types/ha-docker.nix: new build type — Docker 29,
  NFS mounts, beszel-agent, health monitoring, swarm firewall rules with
  checkReversePath = "loose" for VXLAN routing mesh
- nixos/hosts/ha-docker-{1,2}/host.nix: per-host identity — three NICs
  (LAN, storage, swarm), IPA dyndns pinned to LAN interface
- nixos/flake.nix: add proxmox-ha-docker-{1,2} targets; build-validated
  with nix build --dry-run (169 derivations, no errors)
- nixos/docs/ip-addressing.md: document VLAN 30 / swarm.home zone,
  ha-docker IP allocations across all three subnets
- nixos/scripts/docker-swarm/deploy.sh: 10-phase lifecycle script
  (bridge, keys, IPA, VMs, swarm init, DNS, verify); modelled on
  scripts/ha/deploy.sh with --destroy mode
- nixos/docs/internal/docker-swarm-cutover.md: service-by-service
  migration guide covering Traefik log rotation, Nextcloud cron sidecar,
  docker-health-to-gotify swarm awareness updates, Passbolt/Gitea steps,
  DNS cutover, and CT 105 decommission checklist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DASH15okNvWeY1rVJmyJoJ
This commit is contained in:
2026-07-30 19:01:17 +10:00
co-authored by Claude Sonnet 4.6
parent 43314b6aa3
commit c96752e5d0
8 changed files with 1162 additions and 5 deletions
+330
View File
@@ -0,0 +1,330 @@
# Docker Swarm Cutover Plan
Migration guide for moving containerised services from the existing single-host
Docker LXC container (CT 105, `docker.sweet.home`, 192.168.2.225) to the new
Docker Swarm cluster (`ha-docker-1` / `ha-docker-2`, 192.168.2.230231).
CT 105 stays running throughout. Services migrate one stack at a time.
Roll back any stack by restarting it on CT 105 if anything goes wrong.
---
## Prerequisites
- Swarm cluster deployed and healthy (`scripts/docker-swarm/deploy.sh`).
- Both nodes show `Ready / Active / Manager` in `docker node ls`.
- NFS mounts healthy on both swarm nodes (`/mnt/docker/config`, `/mnt/docker/databases`, `/mnt/docker/volumes`).
- Access to FreeIPA DNS admin to update A records during cutover.
---
## 1. Traefik — switch to Docker log rotation
**Current state (CT 105):** Traefik writes access logs to the NFS volume at
`/mnt/docker/volumes/traefik-data/logs/`. `modules/traefik/rotate-logs.nix`
rotates those files via `logrotate`.
**Swarm approach:** Remove file-based access logging from Traefik's static
config and rely on Docker's json-file log driver with built-in rotation.
Traefik container logs (including access events) then live under
`/var/lib/docker/containers/<id>/` on the node running Traefik.
### Steps
**1a.** In the Traefik stack definition, add logging config to the service:
```yaml
services:
traefik:
logging:
driver: "json-file"
options:
max-size: "100m"
max-file: "20"
```
**1b.** In `traefik.yml` (Traefik's static config), remove the `accessLog`
file path if present. To keep structured access logs, use Traefik's
`accessLog.format: json` with no `filePath` — logs then go to stdout and are
captured by the json-file driver above.
**1c.** Deploy Traefik to the swarm:
```bash
# On either swarm manager:
docker stack deploy -c /mnt/docker/config/traefik/docker-compose.yml traefik
```
Traefik should be deployed as a **global mode** service so it runs on all
swarm nodes and handles ingress on whichever node a request arrives at:
```yaml
services:
traefik:
deploy:
mode: global
placement:
constraints:
- node.role == manager
```
**1d.** After confirming Traefik works on the swarm, remove
`traefik/rotate-logs.nix` from the `docker` build type in
`modules/build-types/docker.nix` and rebuild CT 105.
**DNS:** Update `docker.sweet.home` and any service FQDNs that point at
192.168.2.225 to a swarm VIP or round-robin A records once Traefik is running
on the swarm. See section 8 (DNS cutover).
---
## 2. Nextcloud — migrate cron job to sidecar container
**Current state (CT 105):** `modules/docker/nextcloud-cron-job.nix` runs a
systemd timer every 5 minutes that calls:
```bash
docker exec nextcloud-webapp php ./cron.php
```
**Swarm problem:** `docker exec` only works against the local daemon. If
Nextcloud is scheduled on the other swarm node, the exec fails silently and
cron never runs.
**Swarm approach:** Add a `nextcloud-cron` sidecar container to the Nextcloud
stack definition, pinned to the same node as the main Nextcloud container via
placement constraints.
### Steps
**2a.** Choose which swarm node will host Nextcloud (e.g. `ha-docker-1`).
Label that node:
```bash
# On either swarm manager:
docker node update --label-add nextcloud=true ha-docker-1
```
**2b.** In the Nextcloud stack compose file, add the sidecar and pin both
services to the labelled node:
```yaml
services:
nextcloud-webapp:
image: nextcloud:production # pin same version as CT 105
deploy:
replicas: 1
placement:
constraints:
- node.labels.nextcloud == true
# ... existing volumes, env, networks ...
nextcloud-cron:
image: nextcloud:production # same image, different entrypoint
entrypoint: /cron.sh
deploy:
replicas: 1
placement:
constraints:
- node.labels.nextcloud == true # must co-locate with webapp
volumes:
# Same data volume as nextcloud-webapp so cron sees the same files.
- nextcloud-data:/var/www/html
# No ports exposed — cron only runs PHP inside the container.
```
`/cron.sh` is Nextcloud's built-in cron entrypoint. It runs
`php -f /var/www/html/cron.php` in a loop, sleeping for 5 minutes between
runs — identical to the current systemd timer.
**2c.** Migrate Nextcloud's data volume to the swarm:
```
/mnt/docker/volumes/nextcloud-data/ → already on NFS, no migration needed
/mnt/docker/databases/nextcloud/ → already on NFS, no migration needed
```
The NFS paths are identical on the swarm nodes (`mount-data.nix` mounts the
same shares from the same VIP). Stop Nextcloud on CT 105, deploy on the
swarm, confirm it starts cleanly.
**2d.** Remove `nextcloud-cron-job.nix` from `modules/build-types/docker.nix`
and rebuild CT 105 after confirming Nextcloud works on the swarm.
---
## 3. docker-health-to-gotify — update for swarm awareness
**Current state (CT 105):** The script at
`/home/nixos/docker/monitoring/gotify/docker-health-to-gotify.sh` runs every
minute, calls `docker ps --filter health=unhealthy`, and notifies Gotify.
**Swarm behaviour:** The same script runs on both swarm nodes independently,
each monitoring its own local Docker daemon. This gives per-node coverage
across the swarm.
**Changes needed in the script** (edit the copy on the NFS volume — it takes
effect on both nodes simultaneously on the next timer fire):
### 3a. Strip the Swarm task suffix from service names
In swarm mode, `docker ps --format '{{.Names}}'` returns names like
`nextcloud-webapp.1.abc123xyz`. The notification should show `nextcloud-webapp`,
not the full task name.
```bash
# Before:
CONTAINER_NAME=$(docker ps --format '{{.Names}}' ...)
# After:
CONTAINER_NAME=$(docker ps --format '{{.Names}}' ... | cut -d. -f1)
```
### 3b. Include the reporting node in the Gotify message
Add `$(hostname)` to the notification payload so you know which swarm node
detected the problem:
```bash
MESSAGE="[$(hostname)] ${CONTAINER_NAME} is unhealthy"
```
### 3c. Extend to catch swarm service replica failures
`docker ps` only shows what's running locally. If a service has zero healthy
replicas (task crash-looping) it may not show up on either node's `docker ps`
at the same moment. Add a swarm-level check:
```bash
# Run only on managers (both ha-docker nodes are managers):
if docker info --format '{{.Swarm.ControlAvailable}}' 2>/dev/null | grep -q true; then
# Find services where running replicas < desired replicas
docker service ls --format '{{.Name}}\t{{.Replicas}}' | \
awk -F'\t' '$2 !~ /^[0-9]+\/[0-9]+$/ || split($2,a,"/") && a[1] < a[2] { print $1, $2 }' | \
while read -r svc_name replicas; do
# Send Gotify notification for degraded service
curl -s -X POST "${GOTIFY_URL}/message" \
-H "X-Gotify-Key: ${GOTIFY_TOKEN}" \
-d "title=Swarm service degraded" \
-d "message=[$(hostname)] ${svc_name}: ${replicas} replicas"
done
fi
```
This catches the case where a service's desired replicas are not running
(e.g. OOM kill, image pull failure) — a failure mode that doesn't produce a
Docker health event on any node.
---
## 4. Passbolt migration
Passbolt has strict data integrity requirements. Migrate with care:
1. **Backup first**`docker exec passbolt-webapp php /usr/share/php/passbolt/bin/cake passbolt export_keys` and a database dump.
2. Database is on NFS (`/mnt/docker/databases/passbolt/`) — no data copy needed.
3. Pin Passbolt to a specific node: `docker node update --label-add passbolt=true ha-docker-1`
4. Add placement constraint `node.labels.passbolt == true` to the Passbolt stack.
5. Stop on CT 105, deploy on swarm, verify login works.
6. Test email delivery and 2FA.
---
## 5. Gitea migration
Gitea's data directory is on NFS (`/mnt/docker/volumes/gitea-data/`).
1. Stop Gitea on CT 105: `docker stop gitea`
2. Deploy to swarm with placement constraint (pin to `ha-docker-1` initially).
3. Verify web UI and SSH clone/push work.
4. Update DNS: `gitea.lan.ddnsgeek.com` → swarm Traefik endpoint.
5. Update the flake remote URL in `variables.nix` (`giteaDomain`) if the address changes.
---
## 6. Other services
Deploy remaining services (Grafana, InfluxDB, NodeRed, Prometheus, etc.)
as swarm stacks. Most have no special migration concern — they use NFS
volumes already on the shared storage.
Services with stateful databases (PostgreSQL, MariaDB) should follow the
pattern: stop on CT 105, confirm NFS database directory is intact, deploy on
swarm, verify.
---
## 7. Monitoring — Beszel
The Beszel hub runs on CT 105 (`docker.sweet.home:8090`). Both swarm nodes
run `beszel-agent` (from `modules/beszel/enable-agent.nix`), pointing at the
existing hub URL.
No migration needed for Beszel itself during the container migration. Once
all services are on the swarm, you may wish to move the Beszel hub too (as a
swarm service with a placement constraint) but this is optional.
---
## 8. DNS cutover
When a service is confirmed working on the swarm, update the FreeIPA DNS
A record from the CT 105 IP (192.168.2.225) to a swarm node IP or, when a
shared Traefik frontend is in place, to a round-robin record across both nodes.
**Recommended approach — Traefik as the single entry point:**
```
service.lan.ddnsgeek.com → Traefik on swarm (global mode)
docker.sweet.home → keep as 192.168.2.225 (CT 105) until fully decommissioned
```
For LAN-only services using `*.sweet.home` names, update FreeIPA directly:
```bash
# On domain-controller (or via SSH):
ipa dnsrecord-mod sweet.home nextcloud --a-rec=192.168.2.230
# Add 192.168.2.231 as a second A record for round-robin (optional):
ipa dnsrecord-add sweet.home nextcloud --a-rec=192.168.2.231
```
Services behind Traefik don't need their own DNS updates — only Traefik's
own entry point IPs need to change.
---
## 9. NixOS cleanup — CT 105
Once all services are migrated:
**Remove from `modules/build-types/docker.nix`:**
- `../docker/nextcloud-cron-job.nix` — replaced by sidecar container
- `../traefik/rotate-logs.nix` — replaced by Docker log driver
**Keep in `modules/build-types/docker.nix` until CT 105 is decommissioned:**
- `../docker/docker-health-to-gotify.nix` — still monitors CT 105's own daemon
- Everything else
**When decommissioning CT 105:**
1. Confirm all NFS volumes are in use only by swarm services (not CT 105).
2. Stop CT 105: `pct stop 105` on pve1.
3. Archive/remove the `lxc-docker` and `proxmox-docker` targets from `flake.nix`.
4. Remove `hosts/docker/`, `modules/build-types/docker.nix`, and `modules/docker/`.
5. Update `variables.nix` to remove `dockerIp`, `dockerStorageIp`, `dockerHost`
(or reassign `dockerHost` to point at a swarm node for Beszel hub resolution).
---
## Rollback
Any stack can be rolled back to CT 105 independently:
```bash
# On CT 105:
docker start <service-name>
# Update DNS A record back to 192.168.2.225
ipa dnsrecord-mod sweet.home <service> --a-rec=192.168.2.225
```
CT 105 remains running throughout the cutover. Only decommission it after
every service is confirmed stable on the swarm and you have run one full
backup cycle from the new hosts.
+47 -5
View File
@@ -7,6 +7,10 @@
| LAN | 2 (native/untagged) | `192.168.2.0/24` | General LAN — clients and infrastructure | Yes (gateway .254) | | LAN | 2 (native/untagged) | `192.168.2.0/24` | General LAN — clients and infrastructure | Yes (gateway .254) |
| Cluster | 10 | `192.168.10.224/29` | HA file server DRBD replication + Corosync heartbeat | No — internal `vmbr1` only, no uplink | | Cluster | 10 | `192.168.10.224/29` | HA file server DRBD replication + Corosync heartbeat | No — internal `vmbr1` only, no uplink |
| Storage client | 20 | `192.168.20.0/24` | HA file server NFS (and iSCSI if needed) — docker and swarm nodes mount from VIP here | No — internal `vmbr2` only, no uplink | | Storage client | 20 | `192.168.20.0/24` | HA file server NFS (and iSCSI if needed) — docker and swarm nodes mount from VIP here | No — internal `vmbr2` only, no uplink |
| Swarm cluster | 30 | `192.168.30.0/24` | Docker Swarm gossip (TCP/UDP 7946) + VXLAN overlay (UDP 4789) | No — internal `vmbr3` only, no uplink |
When expanded to a second Proxmox node, VLAN 10 (cluster), VLAN 20 (storage-client), and VLAN 30 (swarm) all share
the same inter-node trunk NIC via 802.1q VLAN tagging — different VLAN IDs, same physical cable.
The cluster and storage-client subnets never leave pve1. `vmbr1` and `vmbr2` are Proxmox Linux The cluster and storage-client subnets never leave pve1. `vmbr1` and `vmbr2` are Proxmox Linux
bridges with no physical port attached; traffic between guests on each bridge stays in-kernel. bridges with no physical port attached; traffic between guests on each bridge stays in-kernel.
@@ -27,9 +31,9 @@ is always `.228`: `192.168.2.228` (LAN), `192.168.10.228` (cluster), `192.168.20
## DNS Zones ## DNS Zones
FreeIPA (domain-controller.sweet.home) is authoritative for all zones. Three FreeIPA (domain-controller.sweet.home) is authoritative for all zones.
zones correspond to the three subnets — one per VLAN. All zones are internal
only; no external delegation. Four zones correspond to the four subnets. All zones are internal only; no external delegation.
### sweet.home — VLAN 2 (192.168.2.x) ### sweet.home — VLAN 2 (192.168.2.x)
@@ -132,12 +136,14 @@ All VMs and LXC containers run on pve1.
| `192.168.2.228` | ha-node1 | HA file server node 1 — management NIC | Active | | `192.168.2.228` | ha-node1 | HA file server node 1 — management NIC | Active |
| `192.168.2.227` | ha-node2 | HA file server node 2 — management NIC | Active | | `192.168.2.227` | ha-node2 | HA file server node 2 — management NIC | Active |
| `192.168.2.226` | server | Former NFS/ZFS file server — decommissioned | Removed from flake | | `192.168.2.226` | server | Former NFS/ZFS file server — decommissioned | Removed from flake |
| `192.168.2.225` | docker | Docker / Traefik stack | Active | | `192.168.2.225` | docker | Docker / Traefik stack (CT 105 — existing single-host) | Active |
| `192.168.2.224` | nix-cache | Nix binary cache + remote builder | Active | | `192.168.2.224` | nix-cache | Nix binary cache + remote builder | Active |
| `192.168.2.223` | pxe-boot | PXE / TFTP / HTTP netboot server | Active | | `192.168.2.223` | pxe-boot | PXE / TFTP / HTTP netboot server | Active |
| `192.168.2.222` | tailscale-router | Tailscale exit node / router | Active | | `192.168.2.222` | tailscale-router | Tailscale exit node / router | Active |
| `192.168.2.221` | tor-relay | Tor relay | Active | | `192.168.2.221` | tor-relay | Tor relay | Active |
| `192.168.2.220` | pdm | Proxmox Deploy Manager | Active | | `192.168.2.220` | pdm | Proxmox Deploy Manager | Active |
| `192.168.2.231` | ha-docker-2 | Docker Swarm node 2 — management NIC | Active |
| `192.168.2.230` | ha-docker-1 | Docker Swarm node 1 — management NIC | Active |
### Client DHCP pool (.10.59) ### Client DHCP pool (.10.59)
@@ -175,7 +181,9 @@ Internal to pve1 only. Proxmox bridge `vmbr2`, no physical NIC attached.
| `192.168.20.228` | ha-node1 | Storage-client NIC (ens20 / vmbr2) | | `192.168.20.228` | ha-node1 | Storage-client NIC (ens20 / vmbr2) |
| `192.168.20.227` | ha-node2 | Storage-client NIC (ens20 / vmbr2) | | `192.168.20.227` | ha-node2 | Storage-client NIC (ens20 / vmbr2) |
| `192.168.20.226` | server | Storage-client NIC (ens19 / vmbr2) — decommissioned | | `192.168.20.226` | server | Storage-client NIC (ens19 / vmbr2) — decommissioned |
| `192.168.20.225` | docker | Storage-client NIC (eth1 / vmbr2) — NFS client | | `192.168.20.225` | docker | Storage-client NIC (eth1 / vmbr2) — NFS client (CT 105) |
| `192.168.20.231` | ha-docker-2 | Storage-client NIC (ens19 / vmbr2) — NFS client |
| `192.168.20.230` | ha-docker-1 | Storage-client NIC (ens19 / vmbr2) — NFS client |
| — | no gateway | Isolated — not routed to LAN or internet | | — | no gateway | Isolated — not routed to LAN or internet |
NFS clients mount from `192.168.20.229` (surviving failover transparently via the VIP). NFS clients mount from `192.168.20.229` (surviving failover transparently via the VIP).
@@ -184,3 +192,37 @@ cannot reach either service on this VIP. The `vip-storage` endpoint is not reach
from the workstation directly (internal bridge only); health checks proxy through the from the workstation directly (internal bridge only); health checks proxy through the
active HA node. active HA node.
---
## Swarm cluster network — VLAN 30 — 192.168.30.0/24
Internal to pve1 only. Proxmox bridge `vmbr3`, no physical NIC attached.
Carries Docker Swarm inter-node traffic only: Raft consensus (TCP 2377),
Serf gossip (TCP/UDP 7946), and VXLAN overlay data path (UDP 4789).
Docker Swarm is initialised with `--advertise-addr` and `--data-path-addr`
both pointing to this subnet so all cluster traffic stays on `vmbr3` and
never crosses the LAN.
| IP | Hostname | Interface / role |
|---|---|---|
| `192.168.30.231` | ha-docker-2 | Swarm cluster NIC (ens20 / vmbr3) |
| `192.168.30.230` | ha-docker-1 | Swarm cluster NIC (ens20 / vmbr3) |
| — | no gateway | Isolated — not routed to LAN or internet |
### DNS zone: `swarm.home` — VLAN 30 (192.168.30.x)
| Hostname | A record | Notes |
|---|---|---|
| `ha-docker-1.swarm.home` | `192.168.30.230` | Swarm NIC — debugging only |
| `ha-docker-2.swarm.home` | `192.168.30.231` | Swarm NIC — debugging only |
Operators reach the Docker API on the LAN IPs (`192.168.2.230`/`.231`), not these addresses.
The `swarm.home` records exist for diagnostic convenience (e.g. confirming `vmbr3` routing).
### Multi-node Proxmox expansion
When a second Proxmox node (pve2) is added, VLAN 10 (cluster), VLAN 20 (storage-client),
and VLAN 30 (swarm) all extend to pve2 via 802.1q VLAN tagging on the inter-node trunk
link. All three internal networks share the same physical NIC between hypervisors —
VLAN tags provide the logical separation.
+3
View File
@@ -130,6 +130,9 @@
proxmox-ha-server-1 = mkTarget { platform = "proxmox"; buildType = "ha-server"; hostPath = ./hosts/ha-server-1/host.nix; nameSuffix = "-1"; }; proxmox-ha-server-1 = mkTarget { platform = "proxmox"; buildType = "ha-server"; hostPath = ./hosts/ha-server-1/host.nix; nameSuffix = "-1"; };
proxmox-ha-server-2 = mkTarget { platform = "proxmox"; buildType = "ha-server"; hostPath = ./hosts/ha-server-2/host.nix; nameSuffix = "-2"; }; proxmox-ha-server-2 = mkTarget { platform = "proxmox"; buildType = "ha-server"; hostPath = ./hosts/ha-server-2/host.nix; nameSuffix = "-2"; };
proxmox-ha-docker-1 = mkTarget { platform = "proxmox"; buildType = "ha-docker"; hostPath = ./hosts/ha-docker-1/host.nix; nameSuffix = "-1"; };
proxmox-ha-docker-2 = mkTarget { platform = "proxmox"; buildType = "ha-docker"; hostPath = ./hosts/ha-docker-2/host.nix; nameSuffix = "-2"; };
}; };
# Auto-install environments (migrated from the former nix-auto-installer # Auto-install environments (migrated from the former nix-auto-installer
+34
View File
@@ -0,0 +1,34 @@
{ vars, ... }:
{
networking = {
hostName = vars.haDocker1Host;
hostId = "a1d0c4e1";
useDHCP = false;
interfaces = {
# ens18 — LAN management NIC (vmbr0, 192.168.2.0/24)
${vars.vmLanInterface}.ipv4.addresses = [{
address = vars.haDocker1Ip;
prefixLength = vars.lanPrefixLength;
}];
# ens19 — storage-client NIC (vmbr2, 192.168.20.0/24) — NFS from HA cluster
${vars.haDockerStorageInterface}.ipv4.addresses = [{
address = vars.haDocker1StorageIp;
prefixLength = vars.haClientPrefixLength;
}];
# ens20 — swarm cluster NIC (vmbr3, 192.168.30.0/24) — Docker gossip + VXLAN
${vars.haDockerSwarmInterface}.ipv4.addresses = [{
address = vars.haDocker1SwarmIp;
prefixLength = vars.haDockerSwarmPrefixLength;
}];
};
defaultGateway = { address = vars.lanGateway; interface = vars.vmLanInterface; };
nameservers = [ vars.domainControllerIp ];
};
# Only register the LAN IP with IPA DNS. Without this, sssd dyndns
# would also register Docker bridge IPs (172.x.x.x) and the storage/swarm
# NIC IPs as A records for ha-docker-1.sweet.home.
security.ipa.dyndns.interface = vars.vmLanInterface;
system.stateVersion = "26.05";
}
+32
View File
@@ -0,0 +1,32 @@
{ vars, ... }:
{
networking = {
hostName = vars.haDocker2Host;
hostId = "a2d0c4e2";
useDHCP = false;
interfaces = {
# ens18 — LAN management NIC (vmbr0, 192.168.2.0/24)
${vars.vmLanInterface}.ipv4.addresses = [{
address = vars.haDocker2Ip;
prefixLength = vars.lanPrefixLength;
}];
# ens19 — storage-client NIC (vmbr2, 192.168.20.0/24) — NFS from HA cluster
${vars.haDockerStorageInterface}.ipv4.addresses = [{
address = vars.haDocker2StorageIp;
prefixLength = vars.haClientPrefixLength;
}];
# ens20 — swarm cluster NIC (vmbr3, 192.168.30.0/24) — Docker gossip + VXLAN
${vars.haDockerSwarmInterface}.ipv4.addresses = [{
address = vars.haDocker2SwarmIp;
prefixLength = vars.haDockerSwarmPrefixLength;
}];
};
defaultGateway = { address = vars.lanGateway; interface = vars.vmLanInterface; };
nameservers = [ vars.domainControllerIp ];
};
# Only register the LAN IP with IPA DNS — same reasoning as ha-docker-1.
security.ipa.dyndns.interface = vars.vmLanInterface;
system.stateVersion = "26.05";
}
+84
View File
@@ -0,0 +1,84 @@
# Docker Swarm node build type.
#
# Produces NixOS hosts that form a Docker Swarm manager cluster. Two nodes
# (ha-docker-1, ha-docker-2) are both managers so either can accept Docker
# API and `docker stack` commands.
#
# Key differences from the existing `docker` build type (used by CT 105):
# - nextcloud-cron-job.nix is EXCLUDED — `docker exec` breaks in swarm
# because the target container may be on the other node. The cron job
# is replaced by a nextcloud-cron sidecar in the Nextcloud stack.
# See docs/internal/docker-swarm-cutover.md.
# - traefik/rotate-logs.nix is EXCLUDED — log rotation moves to Docker's
# json-file log driver (max-size/max-file on the Traefik service
# definition). See docs/internal/docker-swarm-cutover.md.
# - raspi/mount-data.nix is EXCLUDED — specific to CT 105's backup role.
# - Swarm firewall ports (2377/tcp, 7946/tcp+udp, 4789/udp) are opened
# on the swarm NIC (ens20/vmbr3) only.
# - checkReversePath = "loose" is required for the Swarm ingress routing
# mesh: VXLAN return traffic is asymmetric (arrives ens20, exits ens18).
# - beszel-agent is enabled for host-level monitoring.
{ pkgs, vars, ... }:
{
# Pin Docker Engine to version 29, matching CT 105, so image layers cached
# on NFS volumes remain compatible across old and new hosts.
nixpkgs.overlays = [
(final: prev: {
docker = prev.docker_29;
docker_cli = prev.docker_29;
})
];
imports = [
../docker/enable-service.nix
../docker/mount-data.nix
../docker/docker-health-to-gotify.nix
../beszel/enable-agent.nix
../services/enable-rpcbind.nix
];
environment.systemPackages = with pkgs; [
nfs-utils
];
boot.supportedFilesystems = [ "nfs" ];
systemd.tmpfiles.rules = [
# Symlink ~/docker → NFS config mount so the docker-health-to-gotify
# script (and operator convenience) resolves ~/docker/... correctly.
"L+ /home/${vars.primaryUser}/docker - - - - ${vars.nfsShares.dockerConfig.mountpoint}"
"d /mnt/docker 0755 ${vars.primaryUser} users -"
];
users.users.${vars.primaryUser}.extraGroups = [ "docker" ];
networking.firewall = {
# LAN-facing service ports — same as the existing docker build type.
allowedTCPPorts = [
vars.ports.dockerHttp
vars.ports.dockerHttps
vars.ports.dockerExtra
vars.ports.beszelHub
];
# Swarm inter-node ports restricted to the swarm NIC (ens20/vmbr3).
# vmbr3 is an isolated internal bridge — no LAN reachability.
interfaces.${vars.haDockerSwarmInterface} = {
allowedTCPPorts = [
vars.ports.dockerSwarmMgmt # 2377 — Raft + cluster management
vars.ports.dockerSwarmDisc # 7946 — Serf gossip (TCP half)
];
allowedUDPPorts = [
vars.ports.dockerSwarmDisc # 7946 — Serf gossip (UDP half)
vars.ports.dockerSwarmVxlan # 4789 — VXLAN overlay data path
];
};
# Docker Swarm ingress routing mesh creates asymmetric routes: a request
# arrives on ens18 (LAN) for a container that lives on ens20's VXLAN
# overlay; the return path differs from the incoming interface. Strict
# rp_filter drops these packets. "loose" allows them.
checkReversePath = "loose";
};
}
+585
View File
@@ -0,0 +1,585 @@
#!/usr/bin/env bash
# deploy.sh — Full lifecycle management for the Docker Swarm HA cluster.
#
# Provisions two NixOS Proxmox VMs (ha-docker-1, ha-docker-2) as dual-manager
# Docker Swarm nodes sharing NFS storage from the existing HA file-server
# cluster. Both nodes are managers so either can accept Docker API and
# `docker stack` commands.
#
# Usage:
# scripts/docker-swarm/deploy.sh [options]
# scripts/docker-swarm/deploy.sh --destroy [options]
#
# Phases (all run by default; skip any with --skip-<phase>):
# 1. ensure-bridge Create vmbr3 (swarm cluster bridge) on the Proxmox node.
# 2. sync-keys Generate SSH host keys for both nodes (clan vars).
# 3. ipa-hosts Create IPA host objects + sops-encrypted keytabs.
# 4. create-vms Build NixOS disk images and create VMs via create-proxmox-resource.sh.
# 5. add-hardware Attach vmbr2 (storage) and vmbr3 (swarm) NICs; start VMs.
# 6. boot-wait Wait for SSH on both LAN IPs.
# 7. refresh-sops-keys Detect disko key drift; re-encrypt secrets; commit.
# 8. init-swarm docker swarm init on node1; manager join on node2; label nodes.
# 9. dns Register storage.home and swarm.home A records in FreeIPA.
# 10. verify docker node ls; NFS mount check; swarm health.
#
# Options:
# --node <host> Proxmox host (default: pve1.sweet.home)
# --vmid1 <n> VMID for ha-docker-1 (default: 202)
# --vmid2 <n> VMID for ha-docker-2 (default: 203)
# --storage <pool> Proxmox storage pool (default: local-zfs)
# --swarm-bridge <br> Bridge for Docker Swarm cluster network (default: vmbr3)
# --storage-bridge <br> Bridge for NFS storage network (default: vmbr2)
# --memory <MB> RAM per node (default: 4096)
# --cores <n> vCPUs per node (default: 4)
# --skip-ensure-bridge Skip vmbr3 creation/check
# --skip-sync-keys Skip sync-host-keys.sh (clan vars already exist)
# --skip-ipa-hosts Skip IPA host account creation (keytabs already exist)
# --skip-create-vms Skip VM creation (VMs already exist)
# --skip-add-hardware Skip NIC attachment (already attached)
# --skip-boot-wait Skip boot/SSH wait (VMs already running)
# --skip-refresh-sops-keys Skip sops host-key drift fix
# --skip-init-swarm Skip swarm initialisation (already initialised)
# --skip-dns Skip FreeIPA DNS record creation
# --skip-verify Skip post-deploy health checks
# --force-rebuild Pass --force-rebuild to create-proxmox-resource.sh
# --destroy Stop and delete both VMs (skip all other phases)
# --dry-run Print what would run without executing
# -h|--help Show this message
#
# Prerequisites:
# - SSH access to the Proxmox node as $PROXMOX_SSH_USER (wayne).
# - sops age key in the standard location (used by sync-host-keys.sh).
# - SSH access to domain-controller.sweet.home as $PROXMOX_SSH_USER for DNS phase.
# - For --skip-sync-keys: clan vars already in vars/per-machine/proxmox-ha-docker-{1,2}/.
# - For --skip-ipa-hosts: secrets/ha-docker-{1,2}.keytab already exist and are committed.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
# shellcheck source=../env.sh
source "${REPO_ROOT}/scripts/env.sh"
# ── Defaults ──────────────────────────────────────────────────────────────────
NODE="${PVE1_HOST}" # deploy.sh targets pve1 by default (authorised for this cluster)
VMID1=202
VMID2=203
STORAGE="${PROXMOX_STORAGE:-local-zfs}"
SWARM_BRIDGE="vmbr3"
STORAGE_BRIDGE="vmbr2"
MEMORY_MB=4096
CORES=4
SKIP_ENSURE_BRIDGE=false
SKIP_SYNC_KEYS=false
SKIP_IPA_HOSTS=false
SKIP_CREATE_VMS=false
SKIP_ADD_HARDWARE=false
SKIP_BOOT_WAIT=false
SKIP_REFRESH_SOPS_KEYS=false
SKIP_INIT_SWARM=false
SKIP_DNS=false
SKIP_VERIFY=false
FORCE_REBUILD=false
DESTROY=false
DRY_RUN=false
# ── Variables from repo (mirrors variables.nix) ───────────────────────────────
NODE1_HOST="ha-docker-1"
NODE2_HOST="ha-docker-2"
NODE1_LAN_IP="192.168.2.230"
NODE2_LAN_IP="192.168.2.231"
NODE1_SWARM_IP="192.168.30.230"
NODE2_SWARM_IP="192.168.30.231"
NODE1_STORAGE_IP="192.168.20.230"
NODE2_STORAGE_IP="192.168.20.231"
SWARM_CIDR="192.168.30.0/24"
STORAGE_CIDR="192.168.20.0/24"
STORAGE_ZONE="storage.home"
SWARM_ZONE="swarm.home"
SSH_USER="${PROXMOX_SSH_USER:-wayne}"
DC_HOST="${IPA_SERVER:-domain-controller.sweet.home}"
# ── Argument parsing ──────────────────────────────────────────────────────────
usage() {
sed -n '/^# Usage:/,/^[^#]/{ /^#/{ s/^# \?//; p } }' "$0"
exit "${1:-0}"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--node) NODE="$2"; shift 2 ;;
--vmid1) VMID1="$2"; shift 2 ;;
--vmid2) VMID2="$2"; shift 2 ;;
--storage) STORAGE="$2"; shift 2 ;;
--swarm-bridge) SWARM_BRIDGE="$2"; shift 2 ;;
--storage-bridge) STORAGE_BRIDGE="$2"; shift 2 ;;
--memory) MEMORY_MB="$2"; shift 2 ;;
--cores) CORES="$2"; shift 2 ;;
--skip-ensure-bridge) SKIP_ENSURE_BRIDGE=true; shift ;;
--skip-sync-keys) SKIP_SYNC_KEYS=true; shift ;;
--skip-ipa-hosts) SKIP_IPA_HOSTS=true; shift ;;
--skip-create-vms) SKIP_CREATE_VMS=true; shift ;;
--skip-add-hardware) SKIP_ADD_HARDWARE=true; shift ;;
--skip-boot-wait) SKIP_BOOT_WAIT=true; shift ;;
--skip-refresh-sops-keys) SKIP_REFRESH_SOPS_KEYS=true; shift ;;
--skip-init-swarm) SKIP_INIT_SWARM=true; shift ;;
--skip-dns) SKIP_DNS=true; shift ;;
--skip-verify) SKIP_VERIFY=true; shift ;;
--force-rebuild) FORCE_REBUILD=true; shift ;;
--destroy) DESTROY=true; shift ;;
--dry-run) DRY_RUN=true; shift ;;
-h|--help) usage 0 ;;
*) echo "Unknown option: $1" >&2; usage 1 ;;
esac
done
# ── Helpers ───────────────────────────────────────────────────────────────────
log() { echo "==> $*"; }
logn() { echo " $*"; }
err() { echo "ERROR: $*" >&2; exit 1; }
run() {
if $DRY_RUN; then
echo "[dry-run] $*"
else
"$@"
fi
}
pve() {
if $DRY_RUN; then
echo "[dry-run] ssh ${SSH_USER}@${NODE} sudo $*"
else
ssh -i ~/.ssh/id_ed25519 "${SSH_USER}@${NODE}" "sudo $*"
fi
}
pve_check() {
# Read-only probe — always executes even in dry-run.
ssh -i ~/.ssh/id_ed25519 "${SSH_USER}@${NODE}" "sudo $*"
}
SWARM_USER="nixos"
n1() {
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=5 \
"${SWARM_USER}@${NODE1_LAN_IP}" sudo "$@" 2>/dev/null
}
n2() {
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=5 \
"${SWARM_USER}@${NODE2_LAN_IP}" sudo "$@" 2>/dev/null
}
dc() {
# Run ipa commands on domain-controller as $SSH_USER.
if $DRY_RUN; then
echo "[dry-run] ssh ${SSH_USER}@${DC_HOST} $*"
return 0
fi
ssh -i ~/.ssh/id_ed25519 "${SSH_USER}@${DC_HOST}" "$@"
}
wait_for_ssh() {
local ip="$1" label="$2"
if $DRY_RUN; then
logn "[dry-run] Skipping SSH wait for ${label} (${ip})"
return 0
fi
local deadline=$(( $(date +%s) + 300 ))
log "Waiting for SSH on ${label} (${ip}) — up to 5 min..."
while [[ $(date +%s) -lt $deadline ]]; do
if ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=3 \
-o BatchMode=yes "${SWARM_USER}@${ip}" true 2>/dev/null; then
logn "${label} is up."
return 0
fi
sleep 5
done
err "Timed out waiting for SSH on ${label} (${ip})"
}
# ── Destroy mode ──────────────────────────────────────────────────────────────
if $DESTROY; then
log "Destroying Docker Swarm VMs (${VMID1}=${NODE1_HOST}, ${VMID2}=${NODE2_HOST}) on ${NODE}"
for vmid in "$VMID1" "$VMID2"; do
STATUS=$(pve "qm status ${vmid} 2>/dev/null" 2>/dev/null || true)
if echo "$STATUS" | grep -q "running"; then
log "Stopping VMID ${vmid}..."
pve "qm stop ${vmid} --skiplock 1"
sleep 5
fi
if $DRY_RUN || pve "qm config ${vmid} >/dev/null 2>&1"; then
log "Deleting VMID ${vmid}..."
run pve "qm destroy ${vmid} --purge 1"
else
logn "VMID ${vmid} not found — already gone."
fi
done
log "Done — swarm VMs destroyed."
exit 0
fi
# ── Phase 1: Ensure swarm bridge ──────────────────────────────────────────────
if ! $SKIP_ENSURE_BRIDGE; then
log "Phase 1: Ensuring swarm bridge ${SWARM_BRIDGE} on ${NODE}"
if pve_check "test -d /sys/class/net/${SWARM_BRIDGE}" &>/dev/null; then
logn "${SWARM_BRIDGE} already exists — skipping."
else
logn "Creating isolated internal bridge ${SWARM_BRIDGE} (no upstream port, ${SWARM_CIDR})"
BRIDGE_CONF="auto ${SWARM_BRIDGE}
iface ${SWARM_BRIDGE} inet manual
bridge-ports none
bridge-stp off
bridge-fd 0"
if $DRY_RUN; then
echo "[dry-run] Would write /etc/network/interfaces.d/${SWARM_BRIDGE}.conf and ifup it"
else
ssh -i ~/.ssh/id_ed25519 "${SSH_USER}@${NODE}" \
"echo '${BRIDGE_CONF}' | sudo tee /etc/network/interfaces.d/${SWARM_BRIDGE}.conf > /dev/null && sudo ifup ${SWARM_BRIDGE}"
logn "${SWARM_BRIDGE} created and brought up."
fi
fi
fi
# ── Phase 2: Sync host keys ───────────────────────────────────────────────────
if ! $SKIP_SYNC_KEYS; then
log "Phase 2: Syncing SSH host keys for both swarm targets"
for target in proxmox-ha-docker-1 proxmox-ha-docker-2; do
CLAN_DIR="${REPO_ROOT}/vars/per-machine/${target}/openssh"
if [[ -d "$CLAN_DIR" ]]; then
logn "Clan vars for ${target} already exist — skipping."
else
logn "Generating host keys for ${target}..."
run bash "${REPO_ROOT}/scripts/secrets/sync-host-keys.sh" "$target"
fi
done
fi
# ── Phase 3: IPA host accounts ────────────────────────────────────────────────
if ! $SKIP_IPA_HOSTS; then
log "Phase 3: Creating IPA host accounts and keytabs"
IPA_SCRIPT="${REPO_ROOT}/scripts/ipa/create-nixos-ipa-host-account.sh"
for host in "${NODE1_HOST}" "${NODE2_HOST}"; do
KEYTAB="${REPO_ROOT}/secrets/${host}.keytab"
if [[ -f "$KEYTAB" ]]; then
logn "Keytab for ${host} already exists — skipping."
else
logn "Creating IPA host account and keytab for ${host}..."
run bash "$IPA_SCRIPT" "$host"
fi
done
if ! $DRY_RUN; then
# Keytabs must be committed and pushed before VMs rebuild from Gitea.
CURRENT_BRANCH="$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD)"
logn "Committing keytabs and pushing to Gitea (branch: ${CURRENT_BRANCH})..."
(cd "${REPO_ROOT}" && \
git add secrets/ha-docker-1.keytab secrets/ha-docker-2.keytab .sops.yaml && \
git commit -m "secrets(ha-docker): add IPA keytabs for ha-docker-1 and ha-docker-2" || true && \
git push origin "${CURRENT_BRANCH}")
logn "Pushed."
fi
fi
# ── Phase 3.5: Prepare Proxmox node for building ─────────────────────────────
if ! $SKIP_CREATE_VMS && ! $DRY_RUN; then
CURRENT_BRANCH="$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD)"
local_ssh() { ssh -i ~/.ssh/id_ed25519 "${SSH_USER}@${NODE}" "$*"; }
if local_ssh "test -d /nix" &>/dev/null && ! local_ssh "test -w /nix" &>/dev/null; then
logn "/nix exists but not writable by ${SSH_USER} — fixing ownership with sudo..."
local_ssh "sudo chown -R ${SSH_USER} /nix"
logn "Done."
fi
unset -f local_ssh
REMOTE_REPO="/home/${SSH_USER}/nixos"
if pve_check "test -d ${REMOTE_REPO}/.git" &>/dev/null; then
REMOTE_BRANCH=$(ssh -i ~/.ssh/id_ed25519 "${SSH_USER}@${NODE}" \
"cd ${REMOTE_REPO} && git rev-parse --abbrev-ref HEAD 2>/dev/null")
if [[ "$REMOTE_BRANCH" != "$CURRENT_BRANCH" ]]; then
logn "Remote clone is on '${REMOTE_BRANCH}', switching to '${CURRENT_BRANCH}'..."
ssh -i ~/.ssh/id_ed25519 "${SSH_USER}@${NODE}" \
"cd ${REMOTE_REPO} && git fetch origin && git checkout '${CURRENT_BRANCH}' && git pull --ff-only"
logn "Done."
fi
fi
fi
# ── Phase 4: Create VMs ───────────────────────────────────────────────────────
if ! $SKIP_CREATE_VMS; then
log "Phase 4: Building and creating swarm VMs on ${NODE}"
CREATE="${REPO_ROOT}/scripts/proxmox/create-proxmox-resource.sh"
REBUILD_FLAG=""
$FORCE_REBUILD && REBUILD_FLAG="--force-rebuild"
for spec in "${VMID1}:${NODE1_HOST}:proxmox-ha-docker-1" "${VMID2}:${NODE2_HOST}:proxmox-ha-docker-2"; do
IFS=: read -r vmid host_name flake_target <<< "$spec"
log "Creating ${flake_target} (VMID ${vmid}) on ${NODE}..."
# --force-rebuild is always passed: create-proxmox-resource.sh only calls
# sync_remote_host_keys (which bakes the clan-var SSH key into the disk
# image) when it actually builds. Reusing a cached image skips that step
# and leaves the VM unable to decrypt sops secrets on first boot.
run bash "$CREATE" \
--type vm \
--host "$host_name" \
--vmid "$vmid" \
--node "$NODE" \
--storage "$STORAGE" \
--memory "$MEMORY_MB" \
--cores "$CORES" \
--force-rebuild
done
fi
# ── Phase 5: Add NICs and start VMs ──────────────────────────────────────────
if ! $SKIP_ADD_HARDWARE; then
log "Phase 5: Attaching storage (${STORAGE_BRIDGE}) and swarm (${SWARM_BRIDGE}) NICs"
for vmid in "$VMID1" "$VMID2"; do
logn "VMID ${vmid}: stopping to add NICs..."
pve "qm stop ${vmid} --skiplock 1 2>/dev/null; sleep 3" || true
logn "Adding net1 (${STORAGE_BRIDGE} — NFS storage)..."
pve "qm set ${vmid} --net1 virtio,bridge=${STORAGE_BRIDGE},firewall=0"
logn "Adding net2 (${SWARM_BRIDGE} — Docker Swarm)..."
pve "qm set ${vmid} --net2 virtio,bridge=${SWARM_BRIDGE},firewall=0"
logn "Starting VMID ${vmid}..."
pve "qm start ${vmid}"
done
fi
# ── Phase 6: Wait for SSH ─────────────────────────────────────────────────────
if ! $SKIP_BOOT_WAIT; then
log "Phase 6: Waiting for both nodes to come up on LAN IPs"
wait_for_ssh "$NODE1_LAN_IP" "$NODE1_HOST"
wait_for_ssh "$NODE2_LAN_IP" "$NODE2_HOST"
logn "Both nodes are SSHable."
sleep 10 # let systemd finish activation
fi
# ── Phase 7: Refresh sops host-key registrations ─────────────────────────────
#
# Disko builds raw disk images: each new VM boots with a freshly-generated SSH
# host key, not the one pre-seeded in clan vars. Scan the running VMs; if
# their ed25519 keys differ from the clan var, update the clan var, rewrite
# the .sops.yaml anchor, and re-encrypt all affected sops files.
if ! $SKIP_REFRESH_SOPS_KEYS; then
if $DRY_RUN; then
logn "[dry-run] Would scan VM host keys and refresh .sops.yaml / secrets if needed"
else
log "Phase 7: Refreshing sops host-key registrations (disko key drift fix)"
SOPS_UPDATED=false
for spec in \
"${NODE1_LAN_IP}:proxmox-ha-docker-1:${NODE1_HOST}" \
"${NODE2_LAN_IP}:proxmox-ha-docker-2:${NODE2_HOST}"; do
IFS=: read -r node_ip flake_target host_name <<< "$spec"
CLAN_PUB="${REPO_ROOT}/vars/per-machine/${flake_target}/openssh/ssh_host_ed25519_key.pub/value"
logn "Scanning ed25519 host key from ${host_name} (${node_ip})..."
RAW=$(ssh-keyscan -t ed25519 "${node_ip}" 2>/dev/null | grep -v "^#") || true
if [[ -z "$RAW" ]]; then
logn "WARNING: no ed25519 key returned for ${node_ip} — skipping"
continue
fi
SCANNED_TYPE=$(awk '{print $2}' <<< "$RAW")
SCANNED_KEY=$(awk '{print $3}' <<< "$RAW")
SCANNED_PUBKEY="${SCANNED_TYPE} ${SCANNED_KEY} ${host_name}"
CURRENT=$(tr -d '\n' < "$CLAN_PUB" 2>/dev/null || true)
if [[ "$SCANNED_PUBKEY" == "$CURRENT" ]]; then
logn "${host_name}: clan var matches running key — no update needed"
continue
fi
logn "${host_name}: key drift detected — updating clan var"
logn " old: ${CURRENT}"
logn " new: ${SCANNED_PUBKEY}"
echo "$SCANNED_PUBKEY" > "$CLAN_PUB"
SOPS_UPDATED=true
ANCHOR="${flake_target}"
NEW_AGE=$(echo "$SCANNED_PUBKEY" | \
nix run --quiet --no-warn-dirty nixpkgs#ssh-to-age 2>/dev/null)
[[ -z "$NEW_AGE" ]] && err "ssh-to-age produced no output for ${host_name}"
logn " new age key: ${NEW_AGE}"
sed -i "/&${ANCHOR} /s| age[a-z0-9]*$| ${NEW_AGE}|" "${REPO_ROOT}/.sops.yaml"
done
if $SOPS_UPDATED; then
logn "Running sops updatekeys on affected secrets..."
SOPS="nix run --quiet --no-warn-dirty nixpkgs#sops --"
(cd "${REPO_ROOT}" && \
$SOPS updatekeys -y secrets/common.yaml && \
$SOPS updatekeys -y secrets/ha-docker-1.keytab && \
$SOPS updatekeys -y secrets/ha-docker-2.keytab)
logn "Committing refreshed host keys and re-encrypted secrets..."
(cd "${REPO_ROOT}" && \
git add \
vars/per-machine/proxmox-ha-docker-1/openssh/ssh_host_ed25519_key.pub/value \
vars/per-machine/proxmox-ha-docker-2/openssh/ssh_host_ed25519_key.pub/value \
.sops.yaml \
secrets/common.yaml \
secrets/ha-docker-1.keytab \
secrets/ha-docker-2.keytab && \
git commit -m "secrets(ha-docker): refresh sops host-key registrations for new VM instances" || true)
logn "Sops keys refreshed and committed."
fi
fi
fi
# ── Phase 8: Initialise Docker Swarm ─────────────────────────────────────────
if ! $SKIP_INIT_SWARM; then
log "Phase 8: Initialising Docker Swarm"
if $DRY_RUN; then
logn "[dry-run] Would run: docker swarm init --advertise-addr ${NODE1_SWARM_IP} --data-path-addr ${NODE1_SWARM_IP} on ${NODE1_HOST}"
logn "[dry-run] Would join ${NODE2_HOST} as manager"
logn "[dry-run] Would label both nodes"
else
# Check if node1 is already a swarm manager.
if n1 "docker info --format '{{.Swarm.LocalNodeState}}'" 2>/dev/null | grep -q "active"; then
logn "${NODE1_HOST} is already in a swarm — skipping init."
else
logn "Initialising swarm on ${NODE1_HOST} (advertise: ${NODE1_SWARM_IP})..."
n1 "docker swarm init \
--advertise-addr ${NODE1_SWARM_IP} \
--data-path-addr ${NODE1_SWARM_IP}"
logn "Swarm initialised on ${NODE1_HOST}."
fi
# Check if node2 is already joined.
if n2 "docker info --format '{{.Swarm.LocalNodeState}}'" 2>/dev/null | grep -q "active"; then
logn "${NODE2_HOST} is already in the swarm — skipping join."
else
logn "Fetching manager join token from ${NODE1_HOST}..."
JOIN_TOKEN=$(n1 "docker swarm join-token manager -q")
[[ -z "$JOIN_TOKEN" ]] && err "Failed to get swarm manager join token from ${NODE1_HOST}"
logn "Joining ${NODE2_HOST} as manager (advertise: ${NODE2_SWARM_IP})..."
n2 "docker swarm join \
--token ${JOIN_TOKEN} \
--advertise-addr ${NODE2_SWARM_IP} \
--data-path-addr ${NODE2_SWARM_IP} \
${NODE1_SWARM_IP}:2377"
logn "${NODE2_HOST} joined as manager."
fi
# Label nodes for service placement constraints.
logn "Labelling swarm nodes..."
n1 "docker node update --label-add node=${NODE1_HOST} ${NODE1_HOST}" || true
n1 "docker node update --label-add node=${NODE2_HOST} ${NODE2_HOST}" || true
logn "Labels applied."
fi
fi
# ── Phase 9: DNS registration ─────────────────────────────────────────────────
if ! $SKIP_DNS; then
log "Phase 9: Registering DNS records in FreeIPA"
if $DRY_RUN; then
logn "[dry-run] Would create/verify ${SWARM_ZONE} zone and add A records"
else
# Check for and create the swarm.home zone if absent.
if ! dc "ipa dnszone-show ${SWARM_ZONE}" >/dev/null 2>&1; then
logn "Creating ${SWARM_ZONE} DNS zone..."
dc "ipa dnszone-add ${SWARM_ZONE} \
--name-server=${DC_HOST}. \
--admin-email=hostmaster@${SWARM_ZONE}"
# Reverse zone for 192.168.30.x
dc "ipa dnszone-add 30.168.192.in-addr.arpa \
--name-server=${DC_HOST}. \
--admin-email=hostmaster@${SWARM_ZONE}" 2>/dev/null || \
logn " (reverse zone 30.168.192.in-addr.arpa already exists or skipped)"
else
logn "${SWARM_ZONE} zone already exists."
fi
# storage.home A records (zone already exists from HA cluster setup).
for spec in "${NODE1_HOST}:${NODE1_STORAGE_IP}" "${NODE2_HOST}:${NODE2_STORAGE_IP}"; do
IFS=: read -r hostname ip <<< "$spec"
logn "Adding ${hostname}.${STORAGE_ZONE}${ip}"
dc "ipa dnsrecord-add ${STORAGE_ZONE} ${hostname} --a-rec=${ip} --a-create-reverse" 2>/dev/null || \
logn " (record already exists or reverse zone missing — continuing)"
done
# swarm.home A records.
for spec in "${NODE1_HOST}:${NODE1_SWARM_IP}" "${NODE2_HOST}:${NODE2_SWARM_IP}"; do
IFS=: read -r hostname ip <<< "$spec"
logn "Adding ${hostname}.${SWARM_ZONE}${ip}"
dc "ipa dnsrecord-add ${SWARM_ZONE} ${hostname} --a-rec=${ip} --a-create-reverse" 2>/dev/null || \
logn " (record already exists — continuing)"
done
fi
fi
# ── Phase 10: Verify ──────────────────────────────────────────────────────────
if ! $SKIP_VERIFY; then
log "Phase 10: Verifying swarm health"
if $DRY_RUN; then
logn "[dry-run] Would verify swarm node list and NFS mounts"
else
logn "Swarm node list:"
n1 "docker node ls" || err "docker node ls failed on ${NODE1_HOST}"
logn "Checking swarm state on both nodes..."
for spec in "${NODE1_LAN_IP}:${NODE1_HOST}" "${NODE2_LAN_IP}:${NODE2_HOST}"; do
IFS=: read -r ip hostname <<< "$spec"
STATE=$(ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no \
"${SWARM_USER}@${ip}" "sudo docker info --format '{{.Swarm.LocalNodeState}}'" 2>/dev/null)
if [[ "$STATE" != "active" ]]; then
err "${hostname} swarm state is '${STATE}', expected 'active'"
fi
logn " ${hostname}: swarm=${STATE}"
done
logn "Checking NFS mounts on both nodes..."
for spec in "${NODE1_LAN_IP}:${NODE1_HOST}" "${NODE2_LAN_IP}:${NODE2_HOST}"; do
IFS=: read -r ip hostname <<< "$spec"
NFS_OK=$(ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no \
"${SWARM_USER}@${ip}" "df -h /mnt/docker/config 2>/dev/null | grep -c nfs || echo 0" 2>/dev/null)
if [[ "$NFS_OK" -ge 1 ]]; then
logn " ${hostname}: /mnt/docker/config NFS mount ✓"
else
logn " WARNING: ${hostname}: /mnt/docker/config does not appear to be NFS-mounted"
logn " (automount may still be pending — try: ssh nixos@${ip} 'ls /mnt/docker/config')"
fi
done
logn "Checking overlay network..."
NETWORKS=$(n1 "docker network ls --filter driver=overlay --format '{{.Name}}'")
if echo "$NETWORKS" | grep -q "ingress"; then
logn " ingress overlay network present ✓"
else
logn " WARNING: ingress overlay network not found — swarm may not be fully initialised"
fi
fi
fi
log "Deploy complete. Both nodes are ready for 'docker stack deploy'."
log "Connect to either manager:"
log " ssh nixos@${NODE1_LAN_IP} (${NODE1_HOST})"
log " ssh nixos@${NODE2_LAN_IP} (${NODE2_HOST})"
+47
View File
@@ -188,6 +188,46 @@ rec {
# the data disk; drive-scsi0 is the OS disk. # the data disk; drive-scsi0 is the OS disk.
haServerDrbdDisk = "/dev/disk/by-id/scsi-0QEMU_QEMU_HARDDISK_drive-scsi1"; haServerDrbdDisk = "/dev/disk/by-id/scsi-0QEMU_QEMU_HARDDISK_drive-scsi1";
# ── Docker Swarm cluster ──────────────────────────────────────────────────
#
# Three network segments, all internal to pve1:
# LAN vmbr0 192.168.2.0/24 — management; SSH + external service traffic
# Storage-client vmbr2 192.168.20.0/24 — NFS from HA cluster VIP (shared with HA nodes)
# Swarm cluster vmbr3 192.168.30.0/24 — Docker Swarm gossip + VXLAN overlay
#
# Host octet consistent across subnets: node1 = .230, node2 = .231.
# IPs from the .230.239 expansion buffer documented in docs/ip-addressing.md.
#
# Docker Swarm uses --advertise-addr and --data-path-addr on the swarm NIC
# (ens20/vmbr3) so all inter-node cluster traffic stays on the isolated
# internal bridge and never crosses the LAN.
#
# When expanding to a second Proxmox node, vmbr3 (VLAN 30) and vmbr1
# (VLAN 10) share the same inter-node trunk NIC via VLAN tagging — same
# physical wire, different VLAN IDs.
haDocker1Host = "ha-docker-1";
haDocker2Host = "ha-docker-2";
haDocker1Ip = "192.168.2.230"; # LAN management NIC (ens18, vmbr0)
haDocker2Ip = "192.168.2.231";
haDocker1StorageIp = "192.168.20.230"; # storage-client NIC (ens19, vmbr2)
haDocker2StorageIp = "192.168.20.231";
haDocker1SwarmIp = "192.168.30.230"; # swarm cluster NIC (ens20, vmbr3)
haDocker2SwarmIp = "192.168.30.231";
haDockerSwarmCidr = "192.168.30.0/24";
haDockerSwarmPrefixLength = 24;
# NIC names for ha-docker VMs. ens19/ens20 occupy the same guest bus
# positions as vmStorageInterface/vmStorageClientInterface on ha-server VMs
# but are attached to different bridges — storage (vmbr2) and swarm (vmbr3)
# respectively. Kept as named variables to avoid bare literals in modules.
haDockerStorageInterface = "ens19"; # vmbr2 — NFS client
haDockerSwarmInterface = "ens20"; # vmbr3 — Docker Swarm gossip + VXLAN
# ── Storage / NFS ───────────────────────────────────────────────────────── # ── Storage / NFS ─────────────────────────────────────────────────────────
# NFS share definitions — used by ha-server.nix (exports), docker/mount-data.nix, # NFS share definitions — used by ha-server.nix (exports), docker/mount-data.nix,
@@ -263,6 +303,13 @@ rec {
dockerHttps = 443; dockerHttps = 443;
dockerExtra = 8080; dockerExtra = 8080;
# Docker Swarm inter-node ports (modules/build-types/ha-docker.nix).
# Firewalled to haDockerSwarmCidr only — vmbr3 is an isolated bridge
# with no physical uplink, so these ports are unreachable from LAN.
dockerSwarmMgmt = 2377; # TCP — Raft consensus + cluster management
dockerSwarmDisc = 7946; # TCP+UDP — Serf gossip (container network discovery)
dockerSwarmVxlan = 4789; # UDP — VXLAN overlay data path
# Beszel monitoring hub on docker.sweet.home, reached by every agent # Beszel monitoring hub on docker.sweet.home, reached by every agent
# (modules/beszel/enable-agent.nix, hosts/nixos/home.nix) # (modules/beszel/enable-agent.nix, hosts/nixos/home.nix)
beszelHub = 8090; beszelHub = 8090;