diff --git a/cc-ci-plan/plan-domain-migration-ci-autonomic-zone.md b/cc-ci-plan/plan-domain-migration-ci-autonomic-zone.md new file mode 100644 index 0000000..86e63bd --- /dev/null +++ b/cc-ci-plan/plan-domain-migration-ci-autonomic-zone.md @@ -0,0 +1,172 @@ +# Plan: migrate cc-ci domains from `*.commoninternet.net` to `ci.autonomic.zone` + +**Date:** 2026-09-21 · **Status:** DRAFT — blocked only on operator DNS access (Gandi zone `autonomic.zone`) +**Principle:** same host, same IP (195.201.88.249), same services — only names change. Dual-run new +alongside old; old names retire last; rollback at any point is "change nothing further" because the +old DNS zone keeps answering until Phase 4. + +--- + +## 1. Inventory — what answers on what today + +| Name | Served by | Router/config source | +|---|---|---| +| `ci.commoninternet.net` | results dashboard + bridge `/hook` | `cc-ci/nix/modules/dashboard.nix`, `bridge.nix` (traefik Host rules) | +| `report.ci.commoninternet.net` | weekly report static site | `cc-ci/nix/modules/reports.nix` | +| `drone.ci.commoninternet.net` | Drone server | `cc-ci/nix/modules/drone.nix` (abra app; env `DOMAIN`, `DRONE_SERVER_HOST`); runner RPC: `drone-runner.nix` | +| `oc.ci.commoninternet.net` | opencode UI (traefik → host nginx basic auth) | `cc-ci-orch/nix/modules/orchestrator-host.nix` (`opencodeUiHost`), set in `nix/hosts/cc-ci/configuration.nix` | +| `traefik.ci.commoninternet.net` | traefik stack identity + deploy-proxy health gate | `cc-ci/nix/modules/proxy.nix` | +| `warm-*.ci.commoninternet.net` | warm canonical recipe stacks | `warm-keycloak.nix`, warm-bluesky-pds (caddy labels) | +| `-<6hex>.ci.commoninternet.net` | per-run recipe test deploys | `cc-ci/runner/harness/naming.py` `app_domain()` — **resolves via public DNS** (containers use 1.1.1.1/8.8.8.8) | +| `backups.ci.commoninternet.net` | backup-bot-two — stack name only, no web route | `backupbot.nix` | +| `acme.commoninternet.net` (NS `ns-acme`) | local acme-dns (systemd, port 53) | `cc-ci/nix/modules/acme-dns.nix` | + +**Cert chain today:** nixos `security.acme` (lego) issues `ci.commoninternet.net` + `*.ci.commoninternet.net` +via DNS-01 through the local acme-dns; Gandi delegates `_acme-challenge.ci.commoninternet.net` CNAME → +`9d3f0428-eb97-4b14-bb07-da2f0f6c2073.acme.commoninternet.net`. `postRun` fires +`cc-ci-acme-traefik-handoff.service` → installs cert into `/var/lib/ci-certs/live` → restarts +`deploy-proxy.service`, whose reconciler re-derives the `traefik_ci_commoninternet_net_ssl_{cert,key}_v` +swarm secrets (traefik runs co-op-cloud wildcard/no-ACME mode off those secrets). +**DNS:** both zones on Gandi. IPv4-only (no AAAA records anywhere; none needed). + +**External references:** Gitea OAuth app for Drone (redirect URI → drone.ci…); per-repo webhooks → +`/hook` — **only `recipe-maintainers/ghost` and `recipe-maintainers/discourse` have any** (everything +else is bridge-driven via the Drone API); ~60 doc references across cc-ci-orch (AGENTS.md, README, +plan.md, skills). + +## 2. Target naming + +| Old | New | +|---|---| +| ci.commoninternet.net | ci.autonomic.zone | +| report.ci.commoninternet.net | report.ci.autonomic.zone | +| drone.ci.commoninternet.net | drone.ci.autonomic.zone | +| oc.ci.commoninternet.net | oc.ci.autonomic.zone | +| traefik.ci.commoninternet.net | traefik.ci.autonomic.zone | +| *.ci.commoninternet.net (warm-\*, per-run apps, backups identity) | *.ci.autonomic.zone | +| acme.commoninternet.net | **stays** through Phases 1–2 (infra, not a front door); optional Phase 3 | + +Swarm **stack names** (`traefik_ci_commoninternet_net`, `ccci-*`, warm stacks) are identifiers, not +domains — keep them where possible to avoid churn in keep-lists/prune filters, EXCEPT abra-managed +apps whose name **is** the domain (drone, backup-bot, per-run apps). + +## 3. DNS records to set (zone `autonomic.zone`, Gandi — operator) + +| # | Name | Type | Value | TTL | Purpose | +|---|---|---|---|---|---| +| 1 | `ci.autonomic.zone` | A | `195.201.88.249` | 300 | apex front door (wildcard does NOT cover the apex) | +| 2 | `*.ci.autonomic.zone` | A | `195.201.88.249` | 300 | report/drone/oc/traefik/warm-\*/`-<6hex>` run apps | +| 3 | `_acme-challenge.ci.autonomic.zone` | CNAME | `9d3f0428-eb97-4b14-bb07-da2f0f6c2073.acme.commoninternet.net.` | 300 | DNS-01 for the new wildcard — **reuses the existing restricted acme-dns account** (`disable_registration=true`; the lego *account* authenticates, not the FQDN, so one acme-dns subdomain serves both zones) | + +Also: lower TTL to 300 on the old `ci`/`*.ci` records a few days ahead (fast rollback later). +Phase 3 (optional) adds: `acme.ci.autonomic.zone` + `ns-acme.ci.autonomic.zone` A records. + +**DNS verification (before touching the host):** +```bash +dig +short ci.autonomic.zone A @ns-187-a.gandi.net # 195.201.88.249 +dig +short report.ci.autonomic.zone @1.1.1.1 # 195.201.88.249 (via wildcard) +dig +short drone.ci.autonomic.zone @8.8.8.8 # 195.201.88.249 +dig +short _acme-challenge.ci.autonomic.zone CNAME # 9d3f0428-….acme.commoninternet.net. +``` + +## 4. Phase 1a — certificate (issue BEFORE any routing change) + +1. `cc-ci/nix/modules/acme-dns.nix`: add a second `security.acme.certs."ci.autonomic.zone"` entry + (domain + `*.ci.autonomic.zone` SAN, `dnsProvider = "acmedns"`, `dnsResolver = "1.1.1.1:53"`). + Split the lego env per cert — today `ACME_DNS_STORAGE_PATH` is baked to + `/var/lib/acme/ci.commoninternet.net/…`; generate one env file per cert so each keeps its own + acme-dns account file. +2. Extend `cc-ci-acme-traefik-handoff` to stage the new cert alongside the old + (`/var/lib/ci-certs/live` holds both, e.g. `fullchain.pem` = new + `fullchain-old.pem`). +3. Extend the **deploy-proxy reconciler** (`proxy.nix`) for **dual-cert SNI**: new wildcard pair as + `ssl_cert/ssl_key` (default store) + old pair as a second secret pair wired through a small + file-provider `[[tls.certificates]]` so `*.ci.commoninternet.net` keeps serving during the bake + window. This is the one fiddly bit — validate against the **Let's Encrypt staging** endpoint + first (the module already has the staging-marker pattern). +4. `nixos-rebuild test` → confirm both certs issued and served (`openssl s_client -servername` for a + name in each zone, check SANs) → `switch`. + +## 5. Phase 1b — routing + service config (one stack at a time, health-gated) + +During the bake window every router rule carries **both** hostnames +(`Host(`ci.autonomic.zone`) || Host(`ci.commoninternet.net`)`); old names drop out only in Phase 4. + +- `dashboard.nix` + `bridge.nix` (share the `ci` host: dashboard `/` + bridge `/hook`), `reports.nix`, + and the traefik health-gate name in `proxy.nix`. +- `bridge.py` / `dashboard.py` code defaults for `DRONE_URL` / `DASH_URL`. +- `recipe-report.py` — footer, publish URL text and its verification curl → `report.ci.autonomic.zone` + (regenerate the index after, so the home page stops linking the old name). +- Drone **abra app rename** (name = domain, so this is a new stack): + 1. Export enabled-repo list from the old Drone API. + 2. Deploy new abra app `drone.ci.autonomic.zone` (same module; secrets `rpc_secret`, + `client_secret` re-insert from `/run/secrets` as today). **Fresh DB** — Drone data volume is + stack-name-prefixed; repos re-sync on Gitea login, build history is lost (accepted; export the + old repo list and re-enable via API). + 3. Point `drone-runner.nix` `DRONE_RPC_HOST` at the new name; restart runner. + 4. Re-run `scripts/bootstrap-drone-oauth.sh` with `DRONE=https://drone.ci.autonomic.zone` + (updates the Gitea OAuth redirect URI; script already uses `--resolve` to hit loopback). + 5. Update the two webhooks (ghost, discourse) to `https://drone.ci.autonomic.zone/hook?secret=` + — read the secret off the existing hook config before deleting it. + 6. `abra app undeploy` + remove the old drone app once green. +- `configuration.nix`: `opencodeUiHost = "oc.ci.autonomic.zone"`; extend the `networking.hosts` + self-pin list with the new names (keep old pins during bake). +- Gitea `GITEA_SERVER` stays `git.autonomic.zone` — unchanged. + +## 6. Phase 2 — harness / warm / per-run naming (the `_ci_commoninternet_net` blast radius) + +- `cc-ci/runner/harness/naming.py` `app_domain()` → `.ci.autonomic.zone` (single source of truth). +- Regexes/patterns that match the derived names: `docker-prune.nix` run-app filter, + `tests/concurrency/concutil.py` (`DOMAIN` const + `RUN_APP_RE`), any separate `RUN_APP_RE` in + `lifecycle.py` / `warm.py` / `warm_reconcile.py` / `canonical.py` / `generic.py` / `http.py`, + `nightly-sweep` keep-list patterns, `tests/gitea/recipe_meta.py` comment. +- Redeploy warm stacks under new names (abra `warm-.ci.autonomic.zone`), reconcile, drain old. +- `backupbot.nix` `DOMAIN` (stack-name-only change). +- Verify: full deterministic test suite + one live `!testme` run end-to-end. + +## 7. Phase 3 (optional) — move acme-dns off commoninternet.net + +Only if `commoninternet.net` is being dropped entirely: new `domain`/`nsname` in acme-dns config +(`acme.ci.autonomic.zone` / `ns-acme.ci.autonomic.zone` + Gandi records), briefly re-enable +registration or hand-migrate the account DB, re-point `_acme-challenge` CNAMEs, re-issue both certs. + +## 8. Phase 4 — retirement (after ≥7 clean days) + +Remove old names from the dual router rules → remove `security.acme.certs."ci.commoninternet.net"` ++ its env file + old cert secret pair → drop old `networking.hosts` pins → delete old Gandi records +(`ci`, `*.ci`, `_acme-challenge.ci`) → restore TTLs to 3600. Keep `acme.commoninternet.net` unless +Phase 3 was done. + +## 9. Verification checklist + +- **DNS:** the dig table in §3 against a Gandi NS and a public resolver. +- **Cert:** `openssl s_client -connect 195.201.88.249:443 -servername ` for one name per zone — + SANs correct for both, `ssl_verify_result=0`. +- **Front doors:** `ci` → 200 · `report.ci` → 200 · `drone.ci` → 200/303 · `oc.ci` → **401** + (basic auth intact) · `traefik.ci/api/version` → 200. +- **SSO:** Drone login through Gitea on the new redirect URI. +- **Webhook:** empty commit to `recipe-maintainers/ghost` main → build fires on the new host. +- **Bridge/!testme:** `!testme` on a scratch PR → build runs, `results.json` written, PR comment + contains **new** domain links. +- **Report:** publish one report page and verify it at `https://report.ci.autonomic.zone`. +- **Host:** `systemctl --failed` empty; `deploy-proxy` reconcile logs clean. + +## 10. Risks / gotchas + +- **Dual-cert SNI in the traefik reconciler** is the highest-risk change — stage-first, verify SNI + selection for both zones before switching. +- **Drone DB** does not follow the abra rename — accept fresh build history; export/re-apply the + enabled-repo list; re-insert secrets from `/run/secrets` (module already does). +- **Per-run recipe domains resolve via public DNS** — the `*.ci.autonomic.zone` wildcard A record is + load-bearing for every test deploy; never merge Phase 2 before it propagates. +- Old PR comments/badges link old names — they keep working until Phase 4 removes the old records. +- ~60 doc references (AGENTS.md, README, plan.md, skills, status skill) — one cosmetic doc PR at the + end so the docs don't lie. +- No domain-bearing secret values were found (webhook secrets are per-repo and copied as-is). + +## 11. Sequencing + +1. **Operator:** TTLs down + 3 Gandi records (§3) — 5 minutes. +2. **Agent:** cert PR (§4) → `test` → verify dual SNI → `switch`. +3. **Agent:** routing/env PRs (§5) stack-by-stack, health-gated; OAuth + webhooks + drone rename. +4. **Agent:** end-to-end verification (§9) + report republish under the new name. +5. **Bake ≥7 days** → Phase 2 PR → full suite → bake → Phase 4 retirement.