feat: add gated Compose deploy and make inventory the host source of truth

Keep sanitized Compose sources in-repo with a confirmation-gated Ansible
playbook, add repo-wide validation, tighten runbook ownership/STOP/review
metadata, and archive stale research docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
windyboy
2026-08-17 17:36:39 +08:00
co-authored by Cursor
parent 885d977531
commit 343c5db415
35 changed files with 912 additions and 276 deletions
+48
View File
@@ -0,0 +1,48 @@
# compose/ — repo-owned Compose declarations
Non-secret Compose sources for the Docker hosts. Secrets are **never** in these
files: every secret is a `${VAR}` reference resolved from the **server-local
`.env`** (docker compose reads `.env` from the project directory automatically).
## Source-of-truth matrix
| Project | Host | Compose source | Mechanism |
|---------|------|----------------|-----------|
| `vaultwarden` | us2 (`/opt/vaultwarden`) | `compose/vaultwarden/compose.yml` | static file + `compose-deploy.yml` |
| `pdns` | hk2 (`/opt/pdns`) | `compose/pdns/compose.yml` | static file + `compose-deploy.yml` |
| `adguardhome` | dns.windy.lan (`/opt/adguardhome`) | — (待从 LAN 提取) | static file (pending) |
| `unifi` | ubnt (`/home/windy/unifi-9`) | — (待从 LAN 提取) | static file (pending) |
| `wireguard` | us4 (`/opt/wireguard`) | `ansible/templates/wireguard-compose.yml.j2` | role-rendered (inventory vars) |
| `rustdesk` | hk2 (`/opt/rustdesk`) | `ansible/roles/rustdesk/templates/compose.yml.j2` | role-rendered (inventory vars) |
| `mailcow` | mx2 (`/opt/mail`) | — (mailcow update generator owns it) | excluded by design |
Mechanism rule: **static** `compose/<project>/compose.yml` for declarations that
do not vary per host; **role-rendered j2** for declarations driven by inventory
vars (image pins, relay host). One mechanism per project; do not duplicate a
project in both.
## Deploying a static project
```bash
cd ansible
# Read-only diff + validation against the server .env (no writes)
ansible-playbook playbooks/compose-deploy.yml --limit vaultwarden --check --diff
# Apply: stage repo file → validate `docker compose config -q` → backup current
# file → promote → `docker compose up -d` (gated)
ansible-playbook playbooks/compose-deploy.yml --limit vaultwarden \
-e '{"compose_deploy_confirm": true}'
```
See [`../runbooks/ansible-operations.md`](../runbooks/ansible-operations.md).
## Adding a project
1. Sanitize the live compose so every secret is `${VAR}` from `.env`
(prefer `${VAR:?missing VAR}` for required keys).
2. Commit `compose/<project>/compose.yml` + `.env.example` (key names only).
3. Add `compose_repo_project` (+ `compose_remote_file` if not `compose.yml`) to
the host in `ansible/inventory/hosts.yml`, and allowlist the project in
`ansible/roles/compose_deploy/defaults/main.yml`.
4. Verify with `--check --diff` (zero diff) then a gated apply.
+39
View File
@@ -0,0 +1,39 @@
# .env.example — PowerDNS stack (hk2.chans.xyz, /opt/pdns)
#
# Non-secret key reference ONLY. Real values live in the server-local .env
# (never commit them). Compose requires the `:?`-marked keys to be present.
# Runtime
TZ=Asia/Shanghai
# Postgres superuser (db + backup + pgweb)
PGUSER=
PGPASSWORD=
DB_HOST=db
DB_PORT=5432
# Application database (auth / poweradmin / backup)
DB_NAME=pdns
DB_USER=pdns
DB_PASS=
ADMIN_DB=pdnsadmin
# Backups
CRON_SCHEDULE=0 3 * * *
RETENTION_DAYS=7
MAX_BACKUPS=7
DUMP_ROLES=true
# PowerDNS auth API
PDNS_API_KEY=
# Poweradmin (first-run admin + session)
PA_SESSION_KEY=
PA_ADMIN_USERNAME=
PA_ADMIN_PASSWORD=
PA_ADMIN_EMAIL=
PA_ADMIN_FULLNAME=
# pgweb debug profile
PGWEB_USER=
PGWEB_PASS=
+159
View File
@@ -0,0 +1,159 @@
networks:
frontend:
name: traefik
external: true
backend:
internal: true
edge:
services:
db:
image: postgres:16
container_name: pdns-db
environment:
POSTGRES_DB: postgres
POSTGRES_USER: ${PGUSER:?missing PGUSER}
POSTGRES_PASSWORD: ${PGPASSWORD:?missing PGPASSWORD}
TZ: ${TZ:-Asia/Shanghai}
PGTZ: ${TZ:-Asia/Shanghai}
volumes:
# Keep the existing mount path to avoid moving the current data directory.
- dbdata:/var/lib/postgresql
- ./db-init-generated:/docker-entrypoint-initdb.d:ro
- ./backup:/backup:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
interval: 10s
timeout: 5s
retries: 10
restart: unless-stopped
networks: [backend, edge]
auth:
image: powerdns/pdns-auth-50:5.0.6
container_name: pdns-auth
depends_on:
db:
condition: service_healthy
ports:
- "53:53/udp"
- "53:53/tcp"
- "127.0.0.1:8081:8081"
environment:
PDNS_API_KEY: ${PDNS_API_KEY:?missing PDNS_API_KEY}
DB_NAME: ${DB_NAME:?missing DB_NAME}
DB_USER: ${DB_USER:?missing DB_USER}
DB_PASS: ${DB_PASS:?missing DB_PASS}
TEMPLATE_FILES: secrets
volumes:
- ./auth/pdns.conf:/etc/powerdns/pdns.conf:ro
- ./auth/templates.d:/etc/powerdns/templates.d:ro
- ./auth/keys:/var/lib/powerdns
- ./auth/import:/import
- ./auth/export:/export
- ./auth/logs:/var/log/pdns
healthcheck:
test:
[
"CMD-SHELL",
"python3 -c \"import json, os, urllib.request; req = urllib.request.Request('http://127.0.0.1:8081/api/v1/servers/localhost', headers={'X-API-Key': os.environ['PDNS_API_KEY']}); data = json.load(urllib.request.urlopen(req, timeout=3)); assert data['daemon_type'] == 'authoritative'\""
]
interval: 10s
timeout: 5s
retries: 12
restart: unless-stopped
networks: [backend, edge]
poweradmin:
image: poweradmin/poweradmin:stable
container_name: poweradmin
depends_on:
db:
condition: service_healthy
auth:
condition: service_healthy
environment:
DB_TYPE: pgsql
DB_HOST: ${DB_HOST:-db}
DB_PORT: ${DB_PORT:-5432}
DB_NAME: ${DB_NAME:?missing DB_NAME}
DB_USER: ${DB_USER:?missing DB_USER}
DB_PASS: ${DB_PASS:?missing DB_PASS}
PA_PDNS_API_URL: http://auth:8081
PA_PDNS_API_KEY: ${PDNS_API_KEY:?missing PDNS_API_KEY}
PA_DNS_BACKEND: sql
PDNS_VERSION: ${PDNS_VERSION:-50}
DNS_NS1: ${DNS_NS1:-ns1.wsvc.info}
DNS_NS2: ${DNS_NS2:-ns2.wsvc.info}
DNS_HOSTMASTER: ${DNS_HOSTMASTER:-hostmaster.wsvc.info}
PA_APP_TITLE: ${PA_APP_TITLE:-Poweradmin}
PA_TIMEZONE: ${TZ:-Asia/Shanghai}
PA_SESSION_KEY: ${PA_SESSION_KEY:?missing PA_SESSION_KEY}
PA_CREATE_ADMIN: ${PA_CREATE_ADMIN:-1}
PA_ADMIN_USERNAME: ${PA_ADMIN_USERNAME:?missing PA_ADMIN_USERNAME}
PA_ADMIN_PASSWORD: ${PA_ADMIN_PASSWORD:?missing PA_ADMIN_PASSWORD}
PA_ADMIN_EMAIL: ${PA_ADMIN_EMAIL:?missing PA_ADMIN_EMAIL}
PA_ADMIN_FULLNAME: ${PA_ADMIN_FULLNAME:?missing PA_ADMIN_FULLNAME}
TRUSTED_PROXIES: private_ranges
DEBUG: "false"
restart: unless-stopped
networks: [backend, frontend]
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik"
- "traefik.http.routers.poweradmin.rule=Host(`pdns.wsvc.info`)"
- "traefik.http.routers.poweradmin.entrypoints=websecure"
- "traefik.http.routers.poweradmin.tls.certresolver=letsencrypt"
- "traefik.http.services.poweradmin.loadbalancer.server.port=80"
backup:
# Use postgres:16 so bash/pg_dump/flock exist without runtime package installs.
# backend is internal:true — Alpine apk at start cannot reach mirrors.
image: postgres:16
container_name: pdns-backup
depends_on:
db:
condition: service_healthy
environment:
TZ: ${TZ:-Asia/Shanghai}
DB_HOST: ${DB_HOST:-db}
DB_PORT: ${DB_PORT:-5432}
DB_USER: ${PGUSER:?missing PGUSER}
DB_PASS: ${PGPASSWORD:?missing PGPASSWORD}
DB_NAME: ${DB_NAME:?missing DB_NAME}
RETENTION_DAYS: ${RETENTION_DAYS:-7}
MAX_BACKUPS: ${MAX_BACKUPS:-7}
DUMP_ROLES: ${DUMP_ROLES:-true}
CRON_SCHEDULE: ${CRON_SCHEDULE:?missing CRON_SCHEDULE}
volumes:
- ./backup:/backup
- ./scripts:/scripts:ro
entrypoint: ["/bin/bash", "/scripts/backup-scheduler.sh"]
restart: unless-stopped
networks: [backend]
pgweb:
image: sosedoff/pgweb:0.16.2
container_name: pdns_pgweb
restart: unless-stopped
environment:
PGWEB_DATABASE_URL: "postgres://${PGUSER:?missing PGUSER}:${PGPASSWORD:?missing PGPASSWORD}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:?missing DB_NAME}?sslmode=disable"
PGWEB_AUTH_USER: ${PGWEB_USER:?missing PGWEB_USER}
PGWEB_AUTH_PASS: ${PGWEB_PASS:?missing PGWEB_PASS}
TZ: ${TZ:-Asia/Shanghai}
depends_on:
db:
condition: service_healthy
networks: [backend, frontend]
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik"
- "traefik.http.routers.pgweb.rule=Host(`pgweb.wsvc.info`)"
- "traefik.http.routers.pgweb.entrypoints=websecure"
- "traefik.http.routers.pgweb.tls.certresolver=letsencrypt"
- "traefik.http.services.pgweb.loadbalancer.server.port=8081"
volumes:
dbdata: {}
+38
View File
@@ -0,0 +1,38 @@
# .env.example — Vaultwarden (us2.wsvc.info, /opt/vaultwarden)
#
# Non-secret key reference ONLY. Real values live in the server-local .env
# (never commit them). Copy the keys below into the server .env if a key is
# missing; the compose file requires them via ${VAR} / env_file.
# Service identity
DOMAIN=https://auth.wsvc.info
TEMPLATES_FOLDER=
# Postgres (compose services vaultwarden / backup / pg / pgweb)
DB_HOST=pg
DB_PORT=5432
DB_NAME=vaultwarden
DB_USER=vaultwarden
DB_PASS=
# pgweb debug profile
PGWEB_USER=
PGWEB_PASS=
PGWEB_DATABASE_URL=
# SMTP (mailcow mx2.windy.me:587 starttls)
SMTP_HOST=mx2.windy.me
SMTP_PORT=587
SMTP_SECURITY=starttls
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM=
HELO_NAME=
# Admin console
ADMIN_TOKEN=
# Runtime
UID=1000
GID=1000
IP_HEADER=X-Forwarded-For
+107
View File
@@ -0,0 +1,107 @@
services:
vaultwarden:
image: vaultwarden/server:1.37.1
container_name: vaultwarden
restart: unless-stopped
env_file: ".env"
environment:
DOMAIN: "https://auth.wsvc.info"
DATABASE_URL: "postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${DB_NAME}"
volumes:
- ./vw-data:/data
extra_hosts:
- "mx2.windy.me:194.163.160.244"
networks:
- net
depends_on:
pg:
condition: service_healthy
labels:
- "traefik.enable=true"
- "traefik.docker.network=vw-net"
- "traefik.http.routers.vaultwarden.rule=Host(`auth.wsvc.info`)"
- "traefik.http.routers.vaultwarden.entrypoints=websecure"
- "traefik.http.routers.vaultwarden.tls=true"
- "traefik.http.routers.vaultwarden.tls.certresolver=letsencrypt"
- "traefik.http.services.vaultwarden.loadbalancer.server.port=80"
backup:
build:
context: .
dockerfile: Dockerfile.backup
container_name: vaultwarden-backup
restart: unless-stopped
volumes:
- ./backups:/backup
- ./scripts:/scripts
#user: "${UID:-1000}:${GID:-1000}"
environment:
DB_HOST: ${DB_HOST}
DB_PORT: ${DB_PORT}
DB_USER: ${DB_USER}
DB_NAME: ${DB_NAME}
DB_PASS: ${DB_PASS}
BACKUP_UID: ${UID:-0}
BACKUP_GID: ${GID:-0}
TZ: Asia/Shanghai
entrypoint: >
/bin/sh -ec "
umask 077 &&
printf '%s:%s:*:%s:%s\n' \"$$DB_HOST\" \"$$DB_PORT\" \"$$DB_USER\" \"$$DB_PASS\" > /root/.pgpass &&
chmod 600 /root/.pgpass &&
touch /backup/backup.log &&
crontab /scripts/crontab.txt &&
echo '[INFO] Backup cron installed' &&
echo '[INFO] Starting crond...' &&
crond -f -l 8
"
networks: [net]
pg:
image: postgres:16
container_name: vw-db
restart: unless-stopped
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASS}
TZ: Asia/Shanghai
PGTZ: Asia/Shanghai
volumes:
- vwdata:/var/lib/postgresql/data
- ./backups:/backup # to import existing dump
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 10s
timeout: 5s
retries: 10
networks: [net]
pgweb:
profiles: ["debug"]
image: sosedoff/pgweb:0.16.2
container_name: vaultwarden-pgweb
restart: unless-stopped
environment:
# 用 Vaultwarden 的数据库参数拼接连接串
#DATABASE_URL: "postgres://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=disable"
PGWEB_AUTH_USER: ${PGWEB_USER}
PGWEB_AUTH_PASS: ${PGWEB_PASS}
TZ: Asia/Shanghai
#ports:
# - "8082:8081" # 本地访问 http://localhost:8082
depends_on:
pg:
condition: service_healthy
networks: [net]
networks:
net:
name: vw-net
external: true
volumes:
vwdata: {}