deploy: machine deployment — quadlets (gitea, nginx, isolated build network), poll+build pipeline, runbook

Reviewed (Opus security review, all findings verified) and tested end-to-end
locally: cold build 23s, no-op poll 45ms, incremental rebuild 4s, prune,
gitea-down grace, real bridge network with container DNS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012jWfn3RwPfFGTtBddm36Uy
This commit is contained in:
2026-07-28 13:44:46 +02:00
co-authored by Claude Fable 5
parent 4102974927
commit ed7a9c01e7
9 changed files with 336 additions and 0 deletions
+96
View File
@@ -0,0 +1,96 @@
# Deploy — CTAO portal demo on strapi-experimental.cyfronet
Everything runs rootless as user `strapi`. Footprint on the machine:
ONE directory (`~/ctao-portal-demo/`) + 4 unit files in `~/.config/`.
Uninstall restores the machine exactly (see bottom). No secrets in any file.
```
~/ctao-portal-demo/
├── gitea-data/ # Gitea state (repos, SQLite, accounts) ← the ONLY thing worth backing up
├── gitea-config/ # Gitea app.ini (generated from env)
├── repo/ # clone of the portal repo (created by first build)
├── releases/ # <sha>/ dirs + `current` symlink (what nginx serves)
├── state/ # last-built SHA
├── bin/build.sh # copied from deploy/ (source of truth stays in the repo)
└── config/nginx.conf
```
| Port | What | Exposed how |
|---|---|---|
| 3000 | Gitea | ingress vhost (TODO: ask Hubert) |
| 8080 | portal (nginx, static) | ingress vhost (TODO: ask Hubert) |
## Install (each step reviewed before running; [W] = writes to the machine)
1. **[W]** `mkdir -p ~/ctao-portal-demo/{gitea-data,gitea-config,releases,state,bin,config} ~/.config/containers/systemd ~/.config/systemd/user`
2. **[W]** Copy files from this dir (scp from the Mac):
- `ctao-demo-gitea.container`, `ctao-demo-web.container`, `ctao-demo.network` → `~/.config/containers/systemd/`
- `ctao-portal-build.service`, `ctao-portal-build.timer` → `~/.config/systemd/user/`
- `build.sh` → `~/ctao-portal-demo/bin/` (`chmod +x`)
- `nginx.conf` → `~/ctao-portal-demo/config/`
3. **[W]** Pull + pin images (one-time, needs internet):
`podman pull docker.io/gitea/gitea:1.27-rootless docker.io/library/nginx:stable-alpine docker.io/library/node:24-alpine`
`podman build -t localhost/ctao-portal-build:1 -f Containerfile.build .`
Then `podman images --digests` → paste the sha256 digests into both `.container` files.
4. **[W]** `systemctl --user daemon-reload && systemctl --user start ctao-demo-gitea`
5. **[W]** Create the Gitea admin — run YOURSELF in your own terminal (password
is prompted/printed there only; never goes through chat or shell history):
`podman exec -it ctao-demo-gitea gitea admin user create --admin --username <you> --email <you@…> --random-password`
6. **[W]** In the Gitea UI: create org `ctao`, repo `portal` (public read).
Push from the Mac through an SSH port-forward:
`ssh -L 3300:localhost:3000 strapi-experimental.cyfronet` then
`git remote add machine http://localhost:3300/ctao/portal.git && git push machine main`
7. **[W]** `systemctl --user enable --now ctao-portal-build.timer` — first run
clones + `npm ci` + builds (minutes); later runs are seconds. Wait until
`journalctl --user -u ctao-portal-build -n 5` shows `published <sha>`
(starting nginx earlier just serves 404s until the first build lands).
8. **[W]** `systemctl --user start ctao-demo-web`
9. **[R]** Verify: `curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/`
and `journalctl --user -u ctao-portal-build -n 20` (shows measured build times).
## After Hubert assigns the vhosts
1. Replace both `TODO(vhost)` values in `ctao-demo-gitea.container`
(ROOT_URL → gitea vhost, CORS `*` → portal origin, add
`GITEA__cors__SCHEME=https`); `systemctl --user daemon-reload && systemctl --user restart ctao-demo-gitea`.
2. In Gitea UI: Settings → Applications → new OAuth2 app for Sveltia
(redirect: `https://<portal-vhost>/admin/`), PKCE, no client secret.
3. Put the Gitea vhost URL + client id into `public/admin/config.yml` in the
portal repo, commit, push — the timer publishes it like any other change.
## Ask Hubert (one message)
1. Two ingress vhosts → `192.168.10.15:8080` (portal) and `:3000` (gitea) —
same mechanism as `strapi.isl-dev…:1337`.
2. Are vhosts public-internet or VPN-scopable? (Gitea preferably VPN-only.)
3. Does Cyfronet offer static-file hosting on the ingress itself? If yes, we
drop our nginx container entirely and rsync builds there instead.
## Uninstall (leaves zero traces)
```
systemctl --user disable --now ctao-portal-build.timer
systemctl --user stop ctao-demo-web ctao-demo-gitea
rm ~/.config/containers/systemd/ctao-demo-*.container \
~/.config/containers/systemd/ctao-demo.network \
~/.config/systemd/user/ctao-portal-build.{service,timer}
systemctl --user daemon-reload
podman network rm ctao-demo 2>/dev/null || true
podman rmi localhost/ctao-portal-build:1 docker.io/gitea/gitea:1.27-rootless docker.io/library/nginx:stable-alpine
rm -rf ~/ctao-portal-demo
```
## Notes
- Publish latency = poll (≤10 s) + build (measured 1 s on M-series; expect
4–8 s on the 2 vCPU VM — every build's time lands in the journal).
- Internet needed only for: image pulls (install) and `npm ci` when the
lockfile changes. Routine rebuilds are fully offline.
- Memory caps (`MemoryHigh`) keep us polite next to Outline + Strapi;
the build container is capped at 1 GB via `podman run --memory`. Watch the
first `npm ci` + build in the journal — if it OOMs inside its cgroup
(contained, just retries), raise the cap.
- Build isolation: build containers run on the `ctao-demo` bridge (see
`ctao-demo.network`) — repo/npm code cannot reach host loopback services.
- Secrets inventory: Gitea admin password (typed interactively, lives only
in Gitea's DB) — that's the complete list. Build/poll/serve use none.