feat: provision Alpine LXC 136 running Atheos, wired to Gitea

Provisions a LAN-only browser code editor (Atheos) in an Alpine 3.23 LXC on the
scotty PVE node, with armistace/blog cloned and ready to edit. Built via the
AI-DLC workflow; the full process trail is in aidlc-docs/.

Deliverables
- scripts/provision.sh          rebuild the guest from scratch (re-runnable)
- scripts/update-atheos.sh      move Atheos to latest upstream and RE-PIN
- scripts/build-and-test.sh     captures evidence for every acceptance criterion
- files/atheos.conf             nginx vhost (socket path byte-matches the FPM pool)
- files/ssh-config              ~atheos/.ssh/config deploy-key alias
- RUNBOOK.md                    operational guide (START AT STEP 1)
- docs/security-notes.md        credential model + wider-web hardening TODOs
- docs/resiliency-notes.md      what a non-PoC deployment would need

Design decisions
- D1 no git identity is configured by the agent; the operator sets it. Git will
  refuse the first commit until then, which is deliberate (requirement FR-11).
- D2 Atheos pinned to commit f90cd18 (build 611) with a verified tarball sha256,
  plus update-atheos.sh as the deliberate escape hatch.
- D3 nginx :80, web root /var/www/atheos, php-fpm over a unix socket.
- D6 git auth is a per-repository DEPLOY KEY scoped to armistace/blog, not a
  personal access token (a PAT's write:repository scope reaches every repo the
  account can write).

Verified on the live guest (40 checks, 0 failures)
- AC-2  Atheos served over HTTP 200 by nginx+php-fpm; socket paths byte-match
- AC-3  20 posts + themes/cleanblog present; owned by the php-fpm user
- AC-4  all five writable paths writable as the service user
- AC-5  remote targets armistace/blog; deploy key authenticates over SSH
- AC-6  survival proven with a real container restart, marker byte-identical
- AC-8  no python/pelican/pip/make and no DB engine - scope boundary enforced
- FR-11 git identity is empty; deploy key mode 600 owned by the service user

Traps documented in the RUNBOOK (each cost real debugging time)
- The Gitea SSH user is `gitea`, NOT `git` (app.ini SSH_USER=gitea). With `git`,
  sshd answers "Invalid user git" and rejects before reading authorized_keys,
  which is indistinguishable from a bad key.
- The Gitea SSH endpoint is the LAN address (:22 is not forwarded publicly).
- `uptime` and `free` report the PVE HOST inside an LXC without lxcfs, so a
  container that just restarted can claim days of uptime.
This commit is contained in:
2026-09-29 15:50:34 +10:00
parent 27272e6f6f
commit a6fb61d0ea
26 changed files with 2420 additions and 1 deletions
@@ -0,0 +1,89 @@
# Unit of Work — atheos-blog-lxc
**Stage**: INCEPTION — Units Generation (Part 2: Generation)
**Date**: 2026-09-29
A unit = a logical grouping of work that ends in a **verifiable state**.
---
## U1 — Guest provisioning
| | |
|---|---|
| **Goal** | An Alpine 3.23 LXC exists, reachable, correctly sized, and boot-persistent |
| **Components** | C1 |
| **Produces** | VMID **136** on node `scotty`, 20 GB root, 2 vCPU, 1 GB RAM, unprivileged, `onboot=1`, TZ `Australia/Brisbane`, package index updated |
| **Preconditions** | Alpine 3.23 template downloaded on scotty (`pveam update` + `download`) |
| **Verification** | `pct config 136` shows Alpine + 20 GB root; the guest answers `ping`/`ssh`; date is Brisbane |
| **ACs** | AC-1 |
| **Notes** | Template download is part of this unit — it does not exist on scotty yet (risk C-3) |
## U2 — Web stack
| | |
|---|---|
| **Goal** | nginx serves PHP, and the two sides agree on the socket |
| **Components** | C2, C3 |
| **Produces** | `nginx`, `php83-fpm`, `php83-{session,zip,openssl,mbstring}` installed; pool + vhost configured; `allow_url_fopen=On`; **both services enabled at the OpenRC `default` runlevel** |
| **Preconditions** | U1 |
| **Verification** | A `phpinfo()`-style probe renders over HTTP (**not** 502/404); the nginx `fastcgi_pass` socket path **string-matches** the pool's `listen` (interface I-1); both services are in `rc-update show` |
| **ACs** | AC-2 |
| **Notes** | Resolve the actual FPM socket from the installed pool config, not from upstream docs (risk C-1). OpenRC idioms only — no systemd (risk C-2) |
## U3 — Atheos install
| | |
|---|---|
| **Goal** | Atheos runs and its installer page loads |
| **Components** | C4, C5 |
| **Produces** | Atheos at `/var/www/atheos` pinned to a resolved commit SHA; `config.php` present; `data/`, `workspace/`, `plugins/`, `themes/` writable by the FPM user |
| **Preconditions** | U2 |
| **Verification** | `http://<ip>/` returns the Atheos installer (HTTP 200, no PHP fatal in the error log); a write test inside `workspace/` succeeds as the FPM user |
| **ACs** | AC-2, AC-3 |
| **Notes** | **The genuinely uncertain unit** (risk C-1). If Atheos fights Alpine's PHP layout, stop and report rather than papering over it |
## U4 — Git + clone + deploy key
| | |
|---|---|
| **Goal** | The operator can open `blog`, edit it, and push from here once they add the key |
| **Components** | C6, C7 |
| **Produces** | `git` installed; `safe.directory` set for the workspace; clone of `armistace/blog` (branch `master`) at `workspace/blog`; **ed25519 deploy keypair generated on the guest** (private key owned by the FPM user, mode 600); `~/.ssh/config` host alias `gitea-blog` with `IdentitiesOnly yes`; `user.name`/`user.email` **deliberately left unset** |
| **Preconditions** | U1 (git), U3 (workspace exists) |
| **Verification** | `blog` appears as a project and its files open; `git -C workspace/blog remote -v` shows Gitea; the **public** key is emitted for the operator; `git config --get user.name` returns **empty** (proving D1) |
| **ACs** | AC-3, AC-4, AC-5 |
| **Notes** | Two phases by necessity: this unit leaves the remote on **HTTPS**; the SSH switch happens *after* the operator installs the deploy key (D6). The agent must not install it |
## U5 — Docs, scripts, handoff
| | |
|---|---|
| **Goal** | A different agent or a rebuild needs no prior context |
| **Components** | all |
| **Produces** | `README.md`, `RUNBOOK.md` (incl. **"set your git identity first"** prompt, the deploy-key install steps, and the exact `git remote set-url` command), `docs/security-notes.md`, `docs/resiliency-notes.md`, `scripts/provision.sh`, `scripts/update-atheos.sh` |
| **Preconditions** | U1–U4 |
| **Verification** | README + RUNBOOK exist and are linked; `provision.sh` is the exact sequence that was actually run; `update-atheos.sh` re-clones Atheos at latest and **re-pins the SHA** (D2) |
| **ACs** | AC-7 |
| **Notes** | The RUNBOOK is the operator-facing artifact: it must open with their required first action |
---
## Deferred / not a unit
- **The SSH remote switch** — operator-owned (D6). Documented in RUNBOOK, not built.
- **Atheos admin user creation** — operator completes the installer (D5).
- **Pelican build/publish** — out of scope entirely (Q1=A, AC-8).
## Greenfield code-organization strategy
No application code is generated, so the repo holds **only** provisioning + docs:
```
atheos-blog-lxc/
├── README.md
├── RUNBOOK.md
├── docs/{security-notes.md,resiliency-notes.md}
├── scripts/{provision.sh,update-atheos.sh}
└── aidlc-docs/ ← process trail only; never code
```