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

Merged
hermes merged 1 commits from feat/atheos-lxc-provision into master 2026-09-29 15:50:55 +10:00
26 changed files with 2420 additions and 1 deletions
+9
View File
@@ -0,0 +1,9 @@
# keys and credentials — never commit
*.pem
id_ed25519*
id_rsa*
*.key
.env
# AI-DLC scratch
aidlc-docs/**/*.tmp
+117 -1
View File
@@ -1,3 +1,119 @@
# atheos-blog-lxc
Alpine LXC running Atheos (browser editor) wired to Gitea + armistace/blog. LAN-only. Built with AI-DLC.
Provisioning for a **browser-based code editor** (Atheos) in an Alpine LXC, wired to Gitea, with the
`armistace/blog` repository cloned and ready to edit.
Built with the **AI-DLC** workflow — the full process trail (requirements, design, units, decisions,
audit log) lives in [`aidlc-docs/`](aidlc-docs/).
---
## What this is
A small, single-purpose Proxmox container that puts an editor in your browser so you can write blog
posts and commit them to Gitea — without VS Code, without a cloud IDE, and without a build toolchain.
| | |
|---|---|
| **Guest** | Proxmox LXC **136** on node `scotty` |
| **OS** | Alpine 3.23.2 (unprivileged, OpenRC) |
| **URL** | `http://192.168.178.57/` — **LAN only** |
| **Stack** | nginx → php-fpm 8.3 (unix socket) → Atheos IDE |
| **Sizes** | 2 vCPU · 1 GB RAM · 20 GB rootfs · `onboot=1` |
| **Editor** | [Atheos](https://github.com/Atheos/Atheos) — PHP, no database, real vim keybindings |
| **Project** | `armistace/blog` cloned to `/var/www/atheos/workspace/blog` |
| **Git auth** | per-repository **deploy key** (write-enabled) |
### What it deliberately is NOT
- **Not VS Code / code-server / Coder.** Atheos is an independent, lightweight PHP editor.
- **Not a build environment.** The blog is a Pelican site, but building/publishing happens in your
existing pipeline. There is intentionally **no Python, no pelican, no `make`** here — the presence
of Python would be a defect (verified by acceptance criterion AC-8).
- **Not internet-facing.** No reverse proxy host, no DNS record, no port-forward is created.
---
## Quick start
**If you just want to use it:**
1. Open **http://192.168.178.57/** and complete the Atheos first-run installer.
2. `ssh root@192.168.178.57`, then **set your git identity** — see **RUNBOOK.md STEP 1**. Git will
not let you commit until you do, and that is deliberate: the agent does not create your identity.
3. Add the deploy key to `armistace/blog` and switch the remote to SSH — **RUNBOOK.md STEP 2 & 3**.
👉 **The full operational guide is [RUNBOOK.md](RUNBOOK.md).** Start at STEP 1.
---
## Repo layout
```
.
├── README.md this file
├── RUNBOOK.md ← operational guide; START AT STEP 1
├── docs/
│ ├── security-notes.md credential model + hardening TODOs if ever exposed
│ └── resiliency-notes.md what a non-PoC deployment would need
├── files/
│ ├── atheos.conf nginx vhost (install to /etc/nginx/http.d/atheos.conf)
│ └── ssh-config ~atheos/.ssh/config (the gitea-blog alias)
├── scripts/
│ ├── provision.sh rebuild the whole guest from scratch
│ └── update-atheos.sh update Atheos to latest upstream + re-pin
└── aidlc-docs/ AI-DLC process trail (requirements → design → units → audit)
```
---
## Rebuilding from scratch
The guest holds nothing unique — the blog's source of truth is Gitea — so a rebuild is cheap:
```sh
# on scotty (192.168.178.69), as root
pct destroy 136
sh scripts/provision.sh
```
`scripts/provision.sh` is written to be re-runnable and reproduces the documented build exactly,
including the pinned Atheos commit and its verified tarball checksum.
---
## Design decisions worth knowing
| # | Decision | Why |
|---|---|---|
| **D1** | **No git identity is configured by the agent.** You set it (RUNBOOK STEP 1). | You own the `armistace` identity; setting it programmatically would impersonate you and blur attribution (requirement FR-11). |
| **D2** | **Atheos is pinned to a commit SHA**, with `update-atheos.sh` to deliberately move it. | Atheos has no releases; a moving branch is not reproducible. |
| **D3** | nginx :80, web root `/var/www/atheos`, php-fpm over a **unix socket**. | Smallest network surface — nothing extra listens on the LAN. |
| **D6** | **Per-repository deploy key**, not a personal access token. | A Gitea PAT's scopes are *route groups* matched against your whole account, so a stolen token reaches **every repo you can write**. A deploy key reaches exactly one. |
Full rationale, including the alternatives that were rejected and the verification behind them, is in
[`aidlc-docs/design-artifacts/application-design.md`](aidlc-docs/design-artifacts/application-design.md)
and [`docs/security-notes.md`](docs/security-notes.md).
---
## Security at a glance
- Git auth: **deploy key scoped to `armistace/blog`**, private key mode `600`, owned by the php-fpm user.
- The SSH alias uses `IdentitiesOnly yes`, so no other key is ever offered.
- The web app runs as a **dedicated unprivileged user** (`atheos`), which also owns the workspace.
- ⚠️ **Accepted risk:** Atheos runs as a web app with shell-exec reach, so a compromise can use the
deploy key to write `blog`. That is the intended floor (one repo, one branch) rather than the
ceiling. Keep **branch protection on `blog`'s `master`**.
- If this is ever exposed to the wider web, work through `docs/security-notes.md` §2 first.
---
## Provenance
| | |
|---|---|
| Built | 2026-09-29 |
| AI-DLC stages executed | Workspace Detection, Requirements, Workflow Planning, Application Design, Units Generation, Infrastructure Design, Code Generation, Build & Test |
| Stages skipped (with rationale) | Reverse Engineering, User Stories, Functional Design, NFR Requirements, NFR Design — see `aidlc-docs/plans/execution-plan.md` |
| Atheos pin | `f90cd1806dbe0e25673a77e27390e8b1a240c3f6` (build 611), tarball sha256 `03ebcb1479b59f1898b5fa50808b181750b42211698f7f7b4042cf2514d78b20` |
+298
View File
@@ -0,0 +1,298 @@
# RUNBOOK — atheos-blog-lxc
Atheos browser editor for `armistace/blog`, running in an Alpine LXC on Proxmox.
| | |
|---|---|
| **Guest** | LXC **136** on node `scotty` (192.168.178.69) |
| **IP** | **http://192.168.178.57/** |
| **OS** | Alpine 3.23.2 · unprivileged · 2 vCPU · 1 GB RAM · 20 GB root · `onboot=1` |
| **App** | Atheos IDE, pinned to commit `f90cd1806dbe0e25673a77e27390e8b1a240c3f6` (build 611) |
| **Repo** | `git.aridgwayweb.com/hermes/atheos-blog-lxc` (this provisioning repo) |
| **Workspace** | `/var/www/atheos/workspace/blog` → clone of `armistace/blog` |
| **Access** | LAN only. No NPM proxy host, no DNS record, no port-forward. |
---
## ⚠️ STEP 1 — DO THIS FIRST: set your git identity
**The agent deliberately did not configure a git identity** (design decision D1, requirement FR-11).
Git will **refuse to commit** until you set one. This is your first action after SSH-ing in.
```sh
ssh root@192.168.178.57
# become the user that runs PHP and owns the workspace
su -s /bin/sh atheos
# set YOUR identity (these values are placeholders - use your real ones)
git config --global user.name "Andrew Ridgway"
git config --global user.email "ar17787@gmail.com"
# verify
git config --global --get user.name
git config --global --get user.email
```
> Why you and not the agent: you own the `armistace` identity. Configuring it under your name
> programmatically would impersonate you and blur commit attribution.
The same identity must be set for the deploy key's user, which is the **same account** (`atheos`),
so the single `su` above covers both the editor's commits and anything you do by hand.
---
## STEP 2 — install the deploy key (Phase 2 prerequisite)
The guest already has a per-repo deploy keypair. Only the **public** half is shared:
```sh
# print it again if you need it
cat /home/atheos/.ssh/id_ed25519_blog.pub
```
Add it in Gitea:
1. Open `armistace/blog` → **Settings** → **Deploy Keys** → **Add Deploy Key**
2. Title: `atheos-blog-lxc`
3. Paste the public key
4. ✅ **TICK "Allow write access"** — required, otherwise push is rejected
Public key (as generated 2026-09-29):
```
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINj03Tep9pULTvVDCC3q1ZtocKTZSmDhPsB61Zic1SPk atheos-blog-lxc deploy key (armistace/blog)
```
Fingerprint to confirm you pasted the right one:
```
SHA256:qHvsjjSX79aPZRoNTRI4j+HH3hArAuINIRu9ewxx7t8 (ED25519)
```
---
## STEP 3 — switch the remote to SSH (Phase 2)
Once the key is installed, tell the agent (or run it yourself):
```sh
su -s /bin/sh atheos -c \
'git -C /var/www/atheos/workspace/blog remote set-url origin gitea-blog:armistace/blog.git'
# verify the transport actually authenticates
su -s /bin/sh atheos -c 'ssh -T gitea-blog' # expect a Gitea greeting, not "Permission denied"
su -s /bin/sh atheos -c 'git -C /var/www/atheos/workspace/blog fetch --dry-run'
```
### Why the remote uses `gitea-blog` and not a hostname
`gitea-blog` is an SSH config alias defined in `/home/atheos/.ssh/config`:
```
Host gitea-blog
HostName 192.168.178.155 # LAN address
User gitea # <-- NOT `git`! see the trap below
IdentityFile ~/.ssh/id_ed25519_blog
IdentitiesOnly yes
Port 22
```
> ### ⚠️ TRAP: the SSH user is `gitea`, not `git`
> Every GitHub/Gitea tutorial uses `git@host`. **This instance does not.** Its `app.ini` sets
> `SSH_USER = gitea`.
>
> If you use `User git`, sshd replies **`Invalid user git`** and rejects the connection
> **before it ever reads `authorized_keys`** — so the error looks exactly like a rejected key
> and sends you hunting for a problem that isn't there. Confirmed in the sshd log on LXC 109:
> ```
> Invalid user git from 192.168.178.57 port 39780
> Connection closed by invalid user git 192.168.178.57 [preauth]
> ```
> Correct user → `Hi there! You've successfully authenticated with the deploy key named atheos-blog-lxc`.
Two reasons for the alias:
1. **`IdentitiesOnly yes`** means no other key is ever offered — a stray `git push` cannot
authenticate with a different (possibly broader) identity.
2. **It's the LAN address because that's the only path that works.** Verified 2026-09-29:
| Endpoint | Result |
|---|---|
| `git.aridgwayweb.com:22` | **unreachable** — the public path does not forward :22 (only :443 via NPM) |
| `192.168.178.155:22` | **OPEN** — Gitea's `app.ini` sets `SSH_DOMAIN = 192.168.178.155`, `SSH_PORT = 22`, `START_SSH_SERVER = false` |
So Gitea serves SSH from the LXC's own `sshd` and advertises the LAN address. Since this guest is
LAN-only, LAN transport is also the correct choice — no internet round-trip.
---
## STEP 4 — complete the Atheos installer
Open **http://192.168.178.57/** in a browser. The Atheos first-run installer appears and creates
the **admin user** — this is yours to own; the agent seeded no accounts.
Verify afterwards:
- you can open the **Workspace** and see a `blog` project
- opening `src/content/*.md` shows your posts
- saving persists (the files are at `/var/www/atheos/workspace/blog`)
### Atheos git settings
Open Atheos → (user menu) → **Settings** to confirm the git integration. The `codegit` component
handles commit/push. If you see *"detected dubious ownership"*, the `safe.directory` entries are
missing — restore them with:
```sh
su -s /bin/sh atheos -c 'git config --global --add safe.directory /var/www/atheos/workspace'
su -s /bin/sh atheos -c 'git config --global --add safe.directory /var/www/atheos/workspace/blog'
```
---
## Day-to-day operations
### Edit and commit
Do everything in the browser at `http://192.168.178.57/`. Commit via Atheos's git panel, or by hand:
```sh
ssh root@192.168.178.57
su -s /bin/sh atheos
cd /var/www/atheos/workspace/blog
git status
git add -A && git commit -m "post: <what changed>"
git push
```
### The build is NOT here
This box **only writes markdown**. Building/publishing the Pelican site happens in the existing
pipeline elsewhere. There is deliberately **no Python, no pelican, no `make`** on this guest.
### Services (OpenRC — not systemd)
```sh
rc-service nginx status
rc-service php-fpm83 status
rc-update show | grep -E 'nginx|php-fpm83' # both must be in 'default'
```
### Logs
```sh
tail -f /var/log/nginx/atheos.error.log # PHP fatals surface here
tail -f /var/log/nginx/atheos.access.log
```
### Reboot / recovery
```sh
pct reboot 136 # from scotty, or
pct stop 136 && pct start 136
```
Services are enabled at the OpenRC `default` runlevel, so they return automatically
(verified — see `aidlc-docs` acceptance criteria AC-6).
### Rebuild from scratch
The whole guest is reproducible:
```sh
# on scotty (192.168.178.69), as root
pct destroy 136
sh scripts/provision.sh # from this repo, on the node
```
Nothing unique lives on the guest: the blog's source of truth is Gitea. Only **uncommitted edits**
would be lost — commit often.
### Update Atheos (and re-pin)
Atheos has no releases, so this install is pinned to a commit. To move to the latest upstream commit,
install it, and re-pin:
```sh
sh scripts/update-atheos.sh # on the guest; preserves workspace/, backs up, verifies, re-pins
```
---
## Security model (summary — full detail in `docs/security-notes.md`)
- **Git auth = a per-repository deploy key**, scoped to `armistace/blog`, write-enabled.
Not a PAT (whose `write:repository` scope reaches **every** repo the account can write).
- Private key: `/home/atheos/.ssh/id_ed25519_blog`, mode `600`, owned by the php-fpm user.
- The web app and the key share one unprivileged user, so a compromised Atheos **can** write `blog`.
That is the accepted floor — one repo, and (with branch protection) one branch.
- **LAN-only.** If this is ever exposed: keep branch protection on `blog`'s `master`, terminate TLS
at NPM, and put authentication *in front* of Atheos. See `docs/security-notes.md` §2 (S-1…S-14).
### Container resource reporting (a real trap — cost debugging time)
`uptime` and `free` on Alpine are **busybox** applets. Busybox reads via the **`sysinfo()` syscall**,
which lxcfs **cannot intercept** — so inside this container they report the **PVE host**, not the
container. A container that restarted seconds ago will cheerfully claim *"up 2 days"*.
lxcfs **is** working correctly here — the virtualised files are right:
```
/proc/meminfo → MemTotal: 1048576 kB ← our 1024 MB limit
/proc/uptime → 105 s ← container
/proc/cpuinfo → 2 processors ← container
```
The container is correct; **the tool is wrong**. Fix = `apk add procps-ng`, whose `/usr/bin/free`
and `/usr/bin/uptime` read those virtualised `/proc` files:
| Tool | Reports | |
|---|---|---|
| `procps-ng` `free` | `1024 MB` | ✅ correct |
| busybox `free` | `15701 MB` | ❌ host |
| `procps-ng` `uptime` | `up 2 min` | ✅ correct |
| busybox `uptime` | `up 2 days` | ❌ host |
> **Note:** `apk add lxcfs` inside the guest is **not** a fix — lxcfs is a host-provided FUSE
> filesystem, not a guest package. `provision.sh` installs `procps-ng` for this reason.
> (`df` is unaffected: it stats the filesystem directly, so it was always accurate.)
---
## Reaching the guest when SSH from your laptop fails
The guest has **root SSH enabled** with your fleet keys installed:
- `ssh-rsa ... aridgway@strategenics-25`
- `ssh-ed25519 ... oct2025`
If those keys aren't what you're using, add/push a key via the PVE node (which always works):
```sh
ssh root@192.168.178.69 "pct exec 136 -- sh -c 'echo \"<your-pubkey>\" >> /root/.ssh/authorized_keys'"
```
---
## Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| **502 Bad Gateway** | nginx `fastcgi_pass` and the FPM pool `listen` disagree | `grep -E '^listen' /etc/php83/php-fpm.d/www.conf` and compare with the `fastcgi_pass` line in `/etc/nginx/http.d/atheos.conf`. Both must read `/run/php-fpm83/php-fpm.sock`. Then restart both services. |
| **"detected dubious ownership"** | `safe.directory` missing or the repo is owned by a different user | Re-add the two `safe.directory` entries (STEP 4) |
| **git commit refuses: "please tell me who you are"** | identity not set — **expected on a fresh build** | STEP 1 |
| **git push: Permission denied (publickey)** | deploy key not installed, **or the SSH user is wrong**, or "Allow write access" not ticked | Check all three. Especially: `grep User /home/atheos/.ssh/config` must say **`gitea`**, not `git` (see STEP 3 trap). On the Gitea host, confirm with `journalctl -u ssh \| tail` — `Invalid user git` means the username is wrong, not the key. |
| **push times out** | remote still pointing at `git.aridgwayweb.com` (:22 not forwarded) | STEP 3 — use the `gitea-blog` alias |
| **Atheos shows a blank page / 500** | PHP fatal | `tail -50 /var/log/nginx/atheos.error.log` |
| **Atheos installer says a path isn't writable** | ownership broken | `chown -R atheos:atheos /var/www/atheos` |
| **`free` / `uptime` show the PVE host's memory or days of uptime** | on Alpine these are **busybox** applets using the `sysinfo()` **syscall**, which lxcfs cannot intercept | `apk add procps-ng` — its `/usr/bin/free` and `/usr/bin/uptime` read the lxcfs-virtualised `/proc` files. Already installed by `provision.sh`; this bites if you rebuild by hand. |
| **PHP session errors** | rootfs full | `df -h /` (the rootfs is 20 GB and the app is tiny, so this indicates something else) |
---
## Change log
| Date | Change |
|---|---|
| 2026-09-29 | Initial build: LXC 136 on scotty, Alpine 3.23.2, nginx + php83-fpm (socket), Atheos pinned at `f90cd18` (build 611), `armistace/blog` cloned over HTTPS, per-repo deploy key generated. LAN-only. |
+64
View File
@@ -0,0 +1,64 @@
# AI-DLC State Tracking
## Project Information
- **Project**: atheos-blog-lxc
- **Start Date**: 2026-09-29T05:05:05Z
- **Current Stage**: INCEPTION — Workspace Detection (complete) → Requirements Analysis
- **Deploy Target**: Proxmox LXC (Alpine 3.23) — NOT the K3s cluster. Deploy-target flavour:
"self-hosted LXC", so `k3s-flavor.md` does NOT apply and no CI/registry pipeline is wired.
## Workspace State
- **Existing Code**: No (greenfield repo; the blog being edited is a separate existing repo)
- **Programming Languages**: none yet (deliverable is provisioning/docs; target app is PHP)
- **Build System**: none (Alpine + nginx/php-fpm provisioning; Makefile-driven install script)
- **Reverse Engineering Needed**: N/A for the repo; brownfield recon done on the *estate*
(blog repo, Gitea, PVE templates) — see audit.md Workspace Detection entry.
- **Workspace Root**: /home/hermes/dev/atheos-blog-lxc
## Existing Estate Facts (load-bearing — verify before trusting)
- **Atheos** (Atheos/Atheos, 677★, MIT, active): PHP web IDE, **no database**. Install = drop
files in a web root + make `config.php`, `data/`, `workspace/`, `plugins/`, `themes/` writable.
Optional PHP extensions for the plugin marketplace: **ZIP, OPENSSL, MBSTRING**, and
`allow_url_fopen=On`. Has git integration via its **codegit** component.
- **blog repo** = `armistace/blog` (public, default branch `master`). It is a **Pelican static
site**, not plain content:
- `src/content/*.md` (~20 posts) + `src/content/images/`
- `src/themes/cleanblog/`
- `src/pelicanconf.py`, `src/devpelconf.py`, `src/publishconf.py`, `src/Makefile`, `src/tasks.py`
- `requirements.txt`: `pelican[markdown]`, `markdown-markup-emoji`
- Root: `Dockerfile` (python:slim → `make html` → `make serve-global`), `kube/`, `.gitea/workflows/build_push.yml`
- **Implication**: to *regenerate/publish* the site you need Python+pelican (`make html`, `make github`).
Atheos alone edits the markdown + git and cannot build it.
- **PVE**: template `alpine-3.23-default_20260116_amd64.tar.xz` available (must download first).
Node `janeway` (.225) = 15.9 GB RAM (3.3 GB free), storage `extra` 874 GB free.
Node `data` (.42) = 7.9 GB RAM (only ~0.95 GB free) — avoid heavy guests.
- **Gitea**: LXC 109 on data (.42), guest .155, **1.27.3** (post-CVE-2026-60004 remediation).
`.151` = LXC 110 **nginxpm (NPM)** which fronts it; runners are on k3s nodes .161/.162.
- **Access**: `root@<pve-node>` + `pct exec`. Gitea token in `~/.hermes/credentials/gitea-api.env`.
## Code Location Rules
- **Application Code**: Workspace root (NEVER in aidlc-docs/)
- **Documentation**: aidlc-docs/ only
- **Structure patterns**: See code-generation.md Critical Rules
## Extension Configuration
| Extension | Enabled | Decided At |
|---|---|---|
| security-baseline | ❌ No (opt-out; TODOs documented in docs/security-notes.md) | Requirements Analysis |
| resiliency-baseline | ❌ No (opt-out; TODOs documented in docs/resiliency-notes.md) | Requirements Analysis |
| property-based-testing | ❌ No | Requirements Analysis |
## Stage Progress
### 🔵 INCEPTION PHASE
- [x] Workspace Detection
- [ ] Reverse Engineering (brownfield only — N/A; estate recon done inline)
- [x] Requirements Analysis
- [x] User Stories (SKIPPED — user-confirmed; substituted with a requirement→unit map)
- [x] Workflow Planning
- [x] Application Design (minimal depth — 5 design artifacts; D6 deploy-key model)
- [x] Units Generation (5 units; requirement→unit map substitutes the story map)
### 🟢 CONSTRUCTION PHASE
- [ ] Per-Unit Loop
- [ ] Build and Test
### 🟡 OPERATIONS PHASE
- [ ] Operations (placeholder)
+132
View File
@@ -0,0 +1,132 @@
# AI-DLC Audit Log
> APPEND-ONLY. Every user input captured verbatim, never summarized or paraphrased,
> with ISO-8601 timestamps. NEVER overwrite this file — always append new sections.
## Workspace Detection
**Timestamp**: 2026-09-29T05:05:05Z
**User Input**: "ok back to the plan. We need to engage AIDLC I want an alpine lxc that
1. install atheos (nginx php etc)
2. sets up git integrations (if required)
3. downloads https://git.aridgwayweb.com/armistace/blog ready for me to use
4. I will set upt he armistace ssh keys myself
5. is backed by a 20gb root (tiny its basically going to be used to write blog)"
**AI Response**: "Workspace Detection executed. New project (no existing aidlc-docs/).
Created workspace scaffold at ~/dev/atheos-blog-lxc. Conducted pre-kickoff reconnaissance:
(A) blog repo armistace/blog is a PELICAN STATIC SITE (Python), not plain markdown — it has
src/{content,pelicanconf.py,devpelconf.py,publishconf.py,Makefile,tasks.py,themes/cleanblog},
builds with `make html` and publishes with `make github`/publishconf.py. Atheos alone CANNOT
build/regenerate it; it edits the .md sources + git only.
(B) Atheos requires: PHP + web server (nginx+php-fpm), NO database; writable config.php, data,
workspace, plugins, themes; optional extensions ZIP, OPENSSL, MBSTRING (needed for plugin
marketplace); optionally allow_url_fopen=On. Ships git integration (codegit component).
(C) Alpine 3.23 (alpine-3.23-default_20260116_amd64.tar.xz) is available as a PVE template
but NOT yet downloaded on any node.
(D) CORRECTION to the 2026-09-29 Gitea incident record: 192.168.178.151 is LXC 110 (nginxpm /
NPM), NOT the Gitea Actions runner. The .151 traffic observed against Gitea was NPM proxying
Gitea's own web traffic; the runners run on the k3s nodes (k3s-3 .161, k3s-2 .162). Memory
corrected accordingly.
(E) git.aridgwayweb.com resolves to a PUBLIC IP (115.70.15.16) — Gitea is internet-facing via NPM."
**Context**: INCEPTION — Workspace Detection
## Requirements Analysis
**Timestamp**: 2026-09-29T05:05:05Z
**User Input**: (none yet — awaiting answers)
**AI Response**: "Created aidlc-docs scaffold (aidlc-state.md, audit.md, plans/, requirements/,
story-artifacts/, design-artifacts/, incidents/). Generated
aidlc-docs/requirements/requirement-verification-questions.md with 5 questions + stated
assumptions. Key gate: Q1 asks whether the box must also BUILD the Pelican site (Python) or is
an editor only (Atheos/PHP), because the blog repo is a Pelican static site, not plain markdown.
Presented the questions inline in Matrix (Andrew reviews in-chat, not in .md files)."
**Context**: INCEPTION — Requirements Analysis (Step 6 gate: awaiting user answers)
## Requirements Analysis — Gate Answers
**Timestamp**: 2026-09-29T05:10:00Z
**User Input** (verbatim):
"1. A - It doesn't need to build the pipeline is well known just writing markdown is more than enough
2. I'll supply them, give me the ip address and I'll ssh in and set up my credentials in gitea, you can't do this and I want it under armistace
3. scotty
4. LAN only
5. A
6. clone it but leave instructions to get it over to ssh once I add the key"
**AI Response**: "Answers recorded: Q1=A (editor only; Pelican build explicitly out of scope — the
existing pipeline handles it, 'just writing markdown is more than enough'); Q2=Andrew supplies
credentials himself over SSH, under the `armistace` identity (agent must NOT set an armistace git
identity or credentials); Q3=PVE node **scotty** (.69) — verified: 15.7 GB RAM (12.1 GB available),
8 GB swap, scotty-local-lvm thin pool 771 GB free, in-use VMIDs 104-107/119/125/126/133-135 so
**136 is free**; Alpine template not yet downloaded on scotty; Q4=LAN only (no NPM/DNS/forward);
Q5=A (opt out of security+resiliency, document the TODOs); Q6=clone the repo but leave RUNBOOK
instructions for switching the remote to SSH once the key is added."
**Context**: INCEPTION — Requirements Analysis (Step 7: generating requirements.md)
## Requirements Analysis — Approval
**Timestamp**: 2026-09-29T05:12:00Z
**User Input** (verbatim): "no I'm good to skip lets do it"
**AI Response**: "Requirements APPROVED. User Stories stage explicitly SKIPPED (user confirmed).
Per the AI-DLC rule for skipped stories, a **requirement→unit map** will be substituted for
unit-of-work-story-map.md. Proceeding to Workflow Planning."
**Context**: INCEPTION — Requirements Analysis complete → Workflow Planning
## Workflow Planning — Approval
**Timestamp**: 2026-09-29T05:15:00Z
**AI Prompt**: "Approve this execution plan? 6 stages to execute (Application Design, Units Generation, Infrastructure Design, Code Generation, Build and Test), 5 to skip (Reverse Engineering, User Stories, Functional Design, NFR Requirements, NFR Design)."
**User Response** (verbatim): "continue looks good"
**Status**: APPROVED
**Context**: INCEPTION — Workflow Planning complete → Application Design
## Application Design — Gate Answers
**Timestamp**: 2026-09-29T05:18:00Z
**User Input** (verbatim):
"1. B jI'll set it in there, remind me in the runbbok
2. good idea for now, but leave an update script that will reclone at latest and pin
3. looks good to me"
**AI Response**: "Answers recorded. Q1=B — the agent configures **NO git identity** (user.name/
user.email left unset); the RUNBOOK must explicitly prompt the operator to set them as their first
action after SSH. Q2 — pin Atheos to a resolved commit SHA **and** additionally ship an
**update/re-pin script** that re-clones Atheos at latest and re-pins the SHA. Q3 — layout accepted
(web root /var/www/atheos, workspace /var/www/atheos/workspace, nginx :80, php-fpm unix socket).
Generated the five application-design artifacts (components.md, component-methods.md, services.md,
component-dependency.md, application-design.md) with design decisions D1-D5 recorded."
**Context**: INCEPTION — Application Design complete (awaiting approval)
## Application Design — Security Model Decision (D6)
**Timestamp**: 2026-09-29T05:22:00Z
**User Input** (verbatim): "would it be safer to have an atheos user or use a application key? lets get this security model for the key right incase I want to expose to the wider web"
**AI Response**: "Researched against official sources + the live 1.27.3 instance rather than opining.
Verified: (a) Gitea PAT scopes are ROUTE GROUPS (e.g. write:repository) matched against the USER's
access, NOT per-repository — so a stolen armistace PAT reaches every repo the account can write;
the instance's DB shows armistace holding 8 tokens, one with scope `all`. (b) Deploy keys are
genuinely per-repository: swagger confirms /repos/{owner}/{repo}/keys (get,post,delete) and the
DeployKey schema carries read_only. (c) CVE-2026-28744 (fixed 1.26.2) confirms the scope mechanism
is real — 'repository-scoped access-token permissions on repository operations'.
RECOMMENDED and user ACCEPTED a per-repo deploy key over both a PAT and a dedicated Gitea user.
Rationale: a dedicated user still owns/needs access to `blog`, so it reduces nothing while adding a
credential and muddying attribution; the deploy key bounds blast radius to ONE repo natively.
Recorded as design decision **D6**. Wrote docs/security-notes.md (S-1..S-14 for wider-web exposure)
and docs/resiliency-notes.md (R-1..R-8). Agent confirmed it CANNOT add the deploy key itself (API
returns 'user should be an owner or a collaborator with admin write') — operator will add the public
half in the repo."
**Context**: INCEPTION — Application Design (security model finalised)
## Application Design — Approval
**Timestamp**: 2026-09-29T05:23:00Z
**User Input** (verbatim): "I like the model lets go with that. When you give me the public key (in here) I'll get it in the repo"
**AI Response**: "Design APPROVED including D6. Proceeding to Units Generation, then CONSTRUCTION."
**Context**: INCEPTION — Application Design complete → Units Generation
## Units Generation — Approval
**Timestamp**: 2026-09-29T05:26:00Z
**AI Prompt**: "Approve these 5 units and the U4 two-phase approach (clone over HTTPS now, deploy key generated + public half handed to operator, SSH switch as Phase 2 once the key is installed)?"
**User Response** (verbatim): "phase 2 is all you I'll give you the go ahead and you can switch it over. lets get building"
**Status**: APPROVED — units approved; U4 two-phase approved; **Phase 2 (SSH remote switch) is agent-owned, to be executed once the operator signals the deploy key is installed**. Construction to run AUTONOMOUSLY per the user's standing preference (no per-unit gates; stop only for hard blockers).
**Context**: INCEPTION complete → CONSTRUCTION PHASE (Infrastructure Design → Code Generation → Build and Test)
## OPERATIONAL NOTE — pre-existing Gitea CVE remediation (executed 2026-09-29, pre-construction)
**Timestamp**: 2026-09-29T05:26:00Z
**AI Response**: "Recorded for trail integrity: earlier in this same session (before this AI-DLC workflow
was engaged) the Gitea instance was found compromised via CVE-2026-60004 and remediated + upgraded
1.23.8 → 1.27.3. This deviates from the stage order — it happened before Inception. It is NOT an
output of any stage of this workflow. Full record: spine partition hermes-ops, source_ref
`gitea-cve-2026-60004-incident`. Also corrected a factual error in that record during this session:
192.168.178.151 is LXC 110 (nginxpm), NOT the Actions runner (user correction: '161 is the runner')."
**Context**: OUT-OF-STAGE — recorded for audit honesty
@@ -0,0 +1,76 @@
# Application Design (consolidated) — atheos-blog-lxc
**Stage**: INCEPTION — Application Design (minimal depth) · **Date**: 2026-09-29
**Deploy target**: Proxmox LXC `136` on node `scotty` (.69) — Alpine 3.23 · LAN-only
---
## 1. What this system is
A small, single-purpose guest whose only job is to put a **browser editor** in front of the
`armistace/blog` working copy. It is deliberately **not** a build environment.
```
┌───────────────────────── LXC 136 (scotty) ─────────────────────────┐
Browser (LAN) │ │
http://<ip>/ ────┼──► nginx :80 ──unix socket──► php-fpm (php83) ──► Atheos (PHP) │
│ │ │
│ ├──► workspace/blog/** (rw) │
│ └──► git CLI ──► local commit│
└────────────────────────────────────────────────────────────────────┘
│
(operator, later) SSH identity + SSH remote
▼
Gitea git.aridgwayweb.com
```
## 2. Components
| ID | Component | Role |
|---|---|---|
| C1 | LXC `136` on scotty, Alpine 3.23 | isolated runtime, OpenRC, 20 GB root, onboot |
| C2 | nginx :80 | HTTP front end; sends `*.php` to C3 |
| C3 | php-fpm `php83` | PHP runtime; ZIP/OPENSSL/MBSTRING; `allow_url_fopen=On` |
| C4 | Atheos (pinned SHA) | the editor; git via its `codegit` component |
| C5 | `/var/www/atheos/workspace` | project storage (writable) |
| C6 | git CLI | VCS; `safe.directory` set; **no identity** (operator-owned) |
| C7 | `blog` working copy | clone of `armistace/blog` on `master` |
| C8 | Gitea (external) | clone source; agent never pushes |
| C9 | Operator (external) | owns SSH key, git identity, Atheos admin, remote switch |
## 3. Key interfaces
1. **nginx → php-fpm**: `fastcgi_pass unix:<FPM_SOCKET>` must equal the pool's `listen =`. Mismatch ⇒ **502**.
2. **Atheos → filesystem**: writable `config.php`, `data/`, `workspace/`, `plugins/`, `themes/`. Failure ⇒ installer dependency error.
3. **Atheos → git**: needs `git` on PATH **and** `safe.directory` for the workspace (else *dubious ownership*). Identity intentionally unset.
4. **git → Gitea**: HTTPS clone (public); operator later switches to SSH.
5. **OpenRC**: `nginx` + `php-fpm83` enabled at `default` runlevel — otherwise first reboot breaks service (AC-6).
## 4. Design decisions (from the gate)
| # | Decision | Source |
|---|---|---|
| D1 | **No git identity configured by the agent.** `user.name`/`user.email` left unset; RUNBOOK instructs the operator to set them first. | Q1=B |
| D2 | **Atheos pinned to a resolved commit SHA**, with an additional **update script** that re-clones at latest and re-pins. | Q2 |
| D3 | Web root `/var/www/atheos`; workspace `/var/www/atheos/workspace`; nginx :80; php-fpm via unix socket. | Q3 |
| D4 | Atheos served at the **site root**. | assumed, accepted |
| D5 | Operator completes Atheos's first-run installer; **agent seeds no users**. | FR-11 / Q2 of requirements |
| **D6** | **Credential = a per-repository Gitea deploy key scoped to `armistace/blog`, write-enabled** (`read_only:false`), owned by the php-fpm user, aliased via `~/.ssh/config` with `IdentitiesOnly yes`. **Not** a PAT (account-wide scopes) and **not** a dedicated Gitea user (no blast-radius reduction for `blog`). Private key generated on the guest; public half handed to the operator to add in-repo. | Andrew, 2026-09-29 (security review) |
## 5. Scope guards (each is a testable negative)
- No database installed
- **No Python / pelican / `make`** — the presence of Python is a defect (AC-8)
- No NPM proxy host / DNS / port-forward (FR-10)
- No `armistace` credential, SSH key, or git identity created by the agent (FR-11)
## 6. Design-level risks handed to Infrastructure Design
- **C-1** Atheos on Alpine is under-documented → resolve the FPM socket and extension package names from the *installed* system, not from upstream docs.
- **C-2** OpenRC, not systemd → all service work uses `rc-service` / `rc-update`.
- **C-3** Alpine template must be downloaded on scotty first (`pveam update` + `download`).
- **C-4** The installer needs a writable web root before it will run.
## 7. Unit mapping
U1 guest (C1) · U2 web stack (C2,C3) · U3 Atheos (C4,C5) · U4 git + clone (C6,C7) · U5 docs + script (all)
@@ -0,0 +1,49 @@
# Component Dependencies — atheos-blog-lxc
**Stage**: INCEPTION — Application Design (minimal depth)
## Dependency matrix
Rows *depend on* columns. `→` = depends on, `—` = none.
| | C1 LXC | C2 nginx | C3 php-fpm | C4 Atheos | C5 workspace | C6 git | C7 blog |
|---|---|---|---|---|---|---|---|
| **C1 LXC** | — | — | — | — | — | — | — |
| **C2 nginx** | ✔ | — | ✔ (I-1) | — | — | — | — |
| **C3 php-fpm** | ✔ | — | — | — | ✔ (I-2) | — | — |
| **C4 Atheos** | — | — | ✔ | — | ✔ | ✔ (I-3) | — |
| **C5 workspace** | — | — | — | — | — | — | ✔ |
| **C6 git** | ✔ | — | — | — | — | — | — |
| **C7 blog** | — | — | — | — | ✔ | — | — |
**Reading it**: nothing works before C1; nginx is useless without php-fpm; Atheos depends on three
things simultaneously; the clone is a leaf.
## Build / provisioning order (topological)
```
C1 LXC
├─► C2 nginx ──► (needs) C3 php-fpm ──► C4 Atheos ──► C5 workspace ──► C7 blog (clone)
└─► C6 git ──────────────────────────────────────────────┘
```
- **Critical path**: C1 → C2/C3 → C4 → C5 → C7
- **Parallelisable**: nginx install and git install are independent
## External dependencies (version-pinned at build time)
| Dependency | Source | Pin strategy |
|---|---|---|
| Alpine rootfs | PVE template `alpine-3.23-default_20260116_amd64.tar.xz` | template filename is versioned |
| `nginx`, `php83*`, `git` | Alpine `main`/`community` repos | record installed versions in the RUNBOOK at build time |
| **Atheos** | `github.com/Atheos/Atheos` (master, no releases) | **pin to a resolved commit SHA** (design Q2); record it |
| `armistace/blog` | Gitea, branch `master` | clone `master`; note the commit for the record |
## Coupling risks (where the design is tight)
| Coupling | Why it's tight | Mitigation |
|---|---|---|
| nginx ↔ php-fpm socket path | a string must match exactly; failure is a 502 with no other signal | resolve from the installed pool config, then assert both sides match |
| FPM user ↔ workspace ownership | a UID mismatch yields "dubious ownership" or unwritable dirs | set ownership once from the actual FPM user, verify by writing a file |
| template filename ↔ Alpine version | a wrong/renamed template fails `pct create` | confirm the exact filename with `pveam available` before creating |
| Atheos master ↔ PHP 8.3 | upstream targets older PHP; 8.3 may warn | smoke-test the installer page and check the error log for PHP fatals |
@@ -0,0 +1,65 @@
# Component Methods / Interfaces — atheos-blog-lxc
**Stage**: INCEPTION — Application Design (minimal depth)
**Note**: No business logic exists to specify. These are the **contracts between components** —
the things that must match or the stack silently fails (the classic 502 / permission-denied class).
## I-1 · nginx → php-fpm (the interface most likely to break)
```
nginx server block:
listen 80;
root /var/www/atheos;
index index.php index.html;
location = /index.php { fastcgi_pass unix:<FPM_SOCKET>; ... }
location ~ \.php$ { fastcgi_pass unix:<FPM_SOCKET>; ... }
```
**Contract**: `<FPM_SOCKET>` in `nginx.conf` **must equal** the `listen =` path in the php-fpm pool.
Alpine's `php83-fpm` default differs between packages — resolve it from the installed
`/etc/php83/php-fpm.d/www.conf` rather than assuming.
- **Input**: HTTP request; **Output**: `text/html` from the PHP process
- **Failure mode if mismatched**: `502 Bad Gateway` (socket missing) — AC-2 fails
## I-2 · Atheos → filesystem (the write contract)
Atheos's installer requires **write access** to exactly these paths, relative to `/var/www/atheos`:
| Path | Why | Mode |
|---|---|---|
| `config.php` | installer writes the config; file must exist | writable by FPM user |
| `data/` | user accounts, settings | writable |
| `workspace/` | projects (incl. the `blog` clone) | writable |
| `plugins/` | plugin install/marketplace | writable |
| `themes/` | theme install | writable |
- **Owner/group**: the php-fpm run user (resolved from the pool config)
- **Failure mode**: installer shows a dependency error; AC-3 fails
## I-3 · Atheos (`codegit`) → git CLI
- **Requires**: `git` on `PATH`
- **Requires**: `safe.directory` for the workspace — otherwise git refuses with
`detected dubious ownership` because the FPM user differs from the filesystem owner
- **Identity**: `user.name` / `user.email` are **deliberately UNSET** (design Q1=B).
The operator sets them first thing after SSH (see RUNBOOK).
- **Failure mode**: "dubious ownership" errors, or commit refused for missing identity
## I-4 · git CLI → Gitea
- **Remote (initial)**: `https://git.aridgwayweb.com/armistace/blog.git` (public ⇒ no credential)
- **Remote (operator, later)**: `git@git.aridgwayweb.com:armistace/blog.git` — the RUNBOOK gives the
exact `git remote set-url` command for after the key is added
- **Contract**: the agent must **not** alter the remote to SSH itself (FR-6/FR-11)
## I-5 · OpenRC service contract (not systemd)
| Service | Enable | Purpose |
|---|---|---|
| `nginx` | `rc-update add nginx default` | HTTP |
| `php-fpm83` | `rc-update add php-fpm83 default` | FastCGI (verify exact rc name on 3.23) |
- **Failure mode if not enabled**: works until first reboot, then AC-6 fails. This is why NFR-2 is
verified with an actual reboot rather than a service-status check.
+33
View File
@@ -0,0 +1,33 @@
# Components — atheos-blog-lxc
**Stage**: INCEPTION — Application Design (minimal depth)
**Note**: This project has no application code to design. "Components" here are the **runtime
pieces of the provisioned guest and their interfaces**, recorded so unit boundaries are defensible
and a later agent inherits the map.
## Provisioned by the agent
| ID | Component | Purpose | Responsibilities |
|---|---|---|---|
| **C1** | **LXC guest** (`136` on scotty, Alpine 3.23) | The isolated runtime | OpenRC init, network (`eth0`, DHCP/static), 20 GB rootfs, timezone |
| **C2** | **nginx** | HTTP front end | Listen `:80`; serve static assets; dispatch `*.php` to C3 over a unix socket |
| **C3** | **php-fpm (`php83`)** | PHP FastCGI runtime | Execute Atheos PHP as a dedicated unprivileged user; extensions ZIP/OPENSSL/MBSTRING; `allow_url_fopen=On` |
| **C4** | **Atheos** (upstream PHP app, pinned SHA) | The browser editor | File tree, editor, and git operations via its `codegit` component |
| **C5** | **Atheos workspace** (`/var/www/atheos/workspace`) | Project storage | Holds working copies Atheos opens as projects; must be writable by C3 |
| **C6** | **git CLI** | VCS client | Invoked by C4's `codegit`; needs `safe.directory`, has **no configured identity** (operator sets it) |
| **C7** | **`blog` working copy** | The thing the operator edits | A clone of `armistace/blog` at `C5/blog`, on `master` |
## External / not provisioned by the agent
| ID | Component | Relationship |
|---|---|---|
| **C8** | **Gitea** (`git.aridgwayweb.com` → LXC 109, .155) | Clone source. **Read-only** from this project's perspective — the agent never pushes |
| **C9** | **Operator (human)** | SSHes in; **owns**: SSH key, git identity, Atheos admin account, and switching the remote to SSH |
## Explicit non-components (scope guards)
- **No database.** Atheos is file-backed; nothing is installed for it.
- **No Python / pelican / `make html`.** The build pipeline is out of scope (FR-1 / AC-8). The
presence of Python would be a **defect**, not a bonus.
- **No NPM proxy host, no DNS, no port-forward** (LAN-only, FR-10).
- **No Atheos user seeded by the agent** — C9 creates the first account via the installer.
@@ -0,0 +1,150 @@
# Infrastructure Design — atheos-blog-lxc
**Stage**: CONSTRUCTION — Infrastructure Design
**Date**: 2026-09-29 · **Deploy target**: Proxmox LXC (Alpine 3.23) on node `scotty` (.69)
> **No open questions.** Every infrastructure decision was settled at the Requirements and Application
> Design gates (Q1–Q6, D1–D6). Re-asking them would be ceremony. This artifact maps the approved
> design onto concrete infrastructure, and records the *provenance* of each choice.
---
## 1. Guest specification (U1)
| Property | Value | Provenance / rationale |
|---|---|---|
| Node | **scotty** (192.168.178.69) | user Q3 |
| VMID | **136** | verified free — in-use on scotty: 104-107, 119, 125, 126, 133-135 |
| OS | **Alpine 3.23** (`alpine-3.23-default_20260116_amd64.tar.xz`) | user requirement ("alpine lxc") |
| Rootfs | **20 GB** | user requirement (point 5) |
| Storage | `scotty-local-lvm` (thin) | 771 GB free at design time; thin keeps 20 GB nominal, minimal actual |
| vCPU | 2 | idle PHP app; 1 risks slow composer-less PHP ops |
| RAM | 1024 MB | nginx + php-fpm is trivial; scotty has 12.1 GB available |
| Swap | 256 MB | PHP can spike on large files; cheap insurance |
| Privileged? | **unprivileged** | default-safe; nothing here needs device access |
| `onboot` | **1** | NFR-2 |
| Network | `eth0`, `bridge=vmbr0`, DHCP | matches all existing guests; LAN-only (FR-10) |
| DNS | router-provided | LAN-only |
| Timezone | `Australia/Brisbane` | NFR-5 |
| Features | **none required** | ⚠️ see note below |
### ⚠️ Note on LXC `features`
The observability project hit `status=226/NAMESPACE` because a container lacked **`nesting`**.
That was **systemd**. This guest is **OpenRC** on Alpine, which does not need `nesting`.
So: `features` left **unset**, and if a service fails to start we revisit `nesting` rather than
pre-emptively enabling a feature that widens the container's escape surface.
### Template provenance
The Alpine 3.23 template is **not yet present on scotty** (risk C-3). U1 therefore begins with
`pveam update` + `pveam download local alpine-3.23-default_...`, and the download is verified before
`pct create` runs.
---
## 2. Runtime stack (U2)
| Layer | Choice | Notes |
|---|---|---|
| HTTP | `nginx` (Alpine `main`) | listens `:80`, `root /var/www/atheos` |
| PHP | `php83-fpm` + `php83` CLI | Alpine 3.23's PHP is **8.3** — confirmed available (`php83` present in the v3.23 index) |
| Extensions | `php83-session`, `php83-zip`, `php83-openssl`, `php83-mbstring` | Atheos: ZIP/OPENSSL/MBSTRING for the plugin marketplace; `session` is not optional |
| PHP ini | `allow_url_fopen=On` | Atheos marketplace requirement (recorded as accepted risk; turn off if unused — S-14) |
| Socket | **unix socket** | nothing extra listens on the LAN; path resolved from the *installed* pool config, not assumed |
### The socket contract (interface I-1 — the 502 risk)
nginx's `fastcgi_pass` and the php-fpm pool's `listen` are two strings that must be **byte-identical**.
The design does not assume a path: U2 **reads the pool config after install** and writes nginx to
match, then asserts both sides agree. A mismatch produces a bare 502 with no other diagnostic.
---
## 3. Application layer (U3)
| Item | Value |
|---|---|
| Web root | `/var/www/atheos` |
| Source | `github.com/Atheos/Atheos`, **pinned to a resolved commit SHA** (D2) |
| Database | **none** — Atheos is file-backed |
| Writable paths | `config.php`, `data/`, `workspace/`, `plugins/`, `themes/` |
| Ownership | owned by the **php-fpm run user** (resolved from the pool) |
| Run user | resolved at U2; **U2/U3/U4 must agree on the same user** (cross-unit risk) |
---
## 4. VCS layer (U4)
| Item | Value | Provenance |
|---|---|---|
| `git` | Alpine package | user point 2 |
| `safe.directory` | workspace path | prevents *dubious ownership* (I-3) |
| Committer identity | **deliberately UNSET** | **D1 / Q1=B** — operator sets it first |
| Clone | `armistace/blog` branch `master` → `workspace/blog` | user point 3, Q6 |
| Remote (initial) | `https://git.aridgwayweb.com/armistace/blog.git` → **HTTPS** (public repo) | U4 phase 1 |
| Credential | **ed25519 deploy key**, per-repo, write-enabled | **D6** |
| Private key | `~<fpmuser>/.ssh/id_ed25519_blog`, mode `600`, owned by FPM user | D6 |
| SSH alias | `Host gitea-blog` + `IdentitiesOnly yes` | D6 — a stray push cannot hit another repo |
| Remote (final) | `gitea-blog:armistace/blog.git` | U4 **phase 2**, after operator installs the key |
---
## 5. Networking & exposure
- **LAN-only** (FR-10). nginx binds `:80` on the guest; nothing else is configured.
- **No NPM proxy host, no DNS record, no router forward** — deliberately not created.
- php-fpm on a unix socket ⇒ **only `:80` and `:22` listen**.
- `git.aridgwayweb.com` resolves to a **public IP** — the *clone* traverses the internet, but the
guest itself is not exposed. Noted because it makes Gitea-side scoping (D6) the meaningful control.
---
## 6. Operations & persistence (OpenRC, not systemd)
| Service | Enable command | Failure if missed |
|---|---|---|
| `nginx` | `rc-update add nginx default` | serves until reboot, then **AC-6 fails** |
| `php-fpm83` | `rc-update add php-fpm83 default` | same |
- Logs: `/var/log/nginx/`, php-fpm error log (path resolved at install)
- **Verification uses an actual reboot**, not a service-status check — a service can be "started"
and still not be enabled.
- Monitoring/alerting: **out of scope** (R-3, S-12). Observability stack is a separate project.
---
## 7. Deployment architecture
```
LAN 192.168.178.0/24
│
┌────────▼─────────┐
│ scotty (.69) │ PVE node
│ ┌────────────┐ │
│ │ LXC 136 │ │ Alpine 3.23 · 2 vCPU · 1 GB · 20 GB root
│ │ │ │
:80 ─────────┼──┤ nginx │ │ unprivileged · onboot=1
│ │ │ unix │ │
│ │ ▼ socket │ │
│ │ php-fpm83 │ │
│ │ │ │ │
│ │ ├─► /var/www/atheos{,/workspace/blog}
│ │ └─► git ─┼──┼──► gitea-blog:armistace/blog.git
│ └────────────┘ │ (deploy key, D6)
└──────────────────┘
│
internet (clone only)
▼
git.aridgwayweb.com (Gitea LXC 109)
```
---
## 8. Infrastructure decisions summary
| # | Decision | Why |
|---|---|---|
| I-1 | Unprivileged, no `features` | default-safe; `nesting` is a systemd need, not ours |
| I-2 | Thin-provisioned `scotty-local-lvm` | 20 GB nominal on 771 GB free; bounded blast radius |
| I-3 | php-fpm over **unix socket** | smallest network surface |
| I-4 | Same user for FPM, file ownership, and key | three-way identity agreement — the top cross-unit risk |
| I-5 | **No** monitoring/alerting | out of scope, recorded (R-3/S-12) |
| I-6 | Reboot-based verification | a "started" service is not an "enabled" one |
@@ -0,0 +1,61 @@
# Requirement → Unit Map — atheos-blog-lxc
**Stage**: INCEPTION — Units Generation (Part 2)
> **Deliberate substitution.** The AI-DLC rules mandate `unit-of-work-story-map.md`, which maps
> *user stories* to units. The **User Stories stage was skipped** (user-confirmed at the Requirements
> gate: "no I'm good to skip"). With no stories to map, this document maps **requirements and
> acceptance criteria** to units instead — the same assurance (nothing is silently dropped) applied
> to the artifacts that actually exist.
## Functional requirements
| FR | Requirement (short) | Unit |
|---|---|---|
| FR-1 | Alpine 3.23 LXC on scotty, 20 GB root, 2 vCPU/1 GB, onboot, unprivileged | **U1** |
| FR-2 | nginx + PHP-FPM serving Atheos; ZIP/OPENSSL/MBSTRING; `allow_url_fopen=On` | **U2** |
| FR-3 | Atheos installed, pinned, 5 writable paths, no DB | **U3** |
| FR-4 | git integration configured (git installed, `safe.directory`, identity handling) | **U4** |
| FR-5 | `armistace/blog` cloned into `workspace/` on `master` | **U4** |
| FR-6 | Remote reachable; instructions to switch to SSH; agent doesn't switch it | **U4** (build) + **U5** (document) |
| FR-7 | RUNBOOK.md with IP, SSH, workspace path, remote-switch steps, credential setup | **U5** |
| FR-8 | README.md | **U5** |
| FR-9 | Repeatable provisioning script in the repo | **U5** |
| FR-10 | LAN-only; no NPM/DNS/forward | **U2** (bind + no proxy config) |
| FR-11 | Agent creates no `armistace` credentials or git identity | **U4** (verification asserts identity is empty) |
| FR-12 | `docs/security-notes.md` + `docs/resiliency-notes.md` | **U5** |
## Non-functional requirements
| NFR | Requirement (short) | Unit |
|---|---|---|
| NFR-1 | Fits comfortably in 20 GB | **U1** (sizing) |
| NFR-2 | Survives reboot with clone + edits intact | **U1** (onboot) + **U2** (services enabled) + **U3/U4** (persistence) → verified at Build & Test |
| NFR-3 | Repeatable / idempotent-friendly provisioning | **U5** |
| NFR-4 | No K3s dependency | all (negative) |
| NFR-5 | Timezone Australia/Brisbane | **U1** |
## Acceptance criteria
| AC | Short | Unit |
|---|---|---|
| AC-1 | `pct config 136` shows Alpine + 20 GB root + onboot | **U1** |
| AC-2 | `http://<ip>/` serves Atheos (not 502/404) | **U2**+**U3** |
| AC-3 | `blog` present as a project whose files open | **U3**+**U4** |
| AC-4 | Edit + save persists to disk | **U3**+**U4** |
| AC-5 | `git remote -v` points at Gitea; RUNBOOK gives the SSH switch | **U4**+**U5** |
| AC-6 | Reboot → Atheos serves again, clone + edits intact | Build & Test |
| AC-7 | RUNBOOK lets another agent re-provision/hand off | **U5** |
| AC-8 | **No Python/pelican present** (scope respected) | Build & Test |
## Coverage check
| | Count | Assigned to exactly one unit | Unassigned |
|---|---|---|---|
| Functional requirements | 12 | 12 ✔ | 0 |
| Non-functional requirements | 5 | 5 ✔ | 0 |
| Acceptance criteria | 8 | 8 ✔ | 0 |
**Every FR and NFR is assigned to exactly one unit.** ACs that require two units to build but one
moment to verify (AC-2, AC-3, AC-4) list both; AC-6 and AC-8 are whole-system properties verified
once at Build & Test rather than owned by a single unit — noted explicitly rather than left implicit.
+62
View File
@@ -0,0 +1,62 @@
# Services / Request Flow — atheos-blog-lxc
**Stage**: INCEPTION — Application Design (minimal depth)
There is no service layer to design (no application code). What matters is the **two flows** the
guest supports, and where each can break.
## Flow 1 — Operator edits and commits
```
Browser (LAN)
│ http://<lxc-ip>/
▼
nginx (C2, :80)
│ location ~ \.php$ → fastcgi_pass unix:<FPM_SOCKET> [I-1]
▼
php-fpm (C3, php83)
│ executes as the FPM user
▼
Atheos (C4, /var/www/atheos)
├── reads/writes workspace/blog/** [I-2 write contract]
└── on Commit: git (C6)
│ safe.directory must include workspace/ [I-3]
│ identity UNSET by design (Q1=B)
▼
commit created in the local clone
```
**Break points**: FPM socket mismatch (502) · unwritable `workspace/` (installer/editor errors) ·
dubious-ownership (git refuses) · missing identity (commit refused until operator sets it).
## Flow 2 — Operator pushes (operator-owned path)
```
Atheos Commit → operator adds SSH key + sets git identity → remote switched to SSH (operator)
│
▼
Gitea (C8) receives the push
```
The **agent builds the path but does not walk it**: it clones over HTTPS, leaves the remote as-is,
and documents the switch. Pushing is the operator's first hands-on act.
## Flow 3 — Build/publish (OUT OF SCOPE — happens elsewhere)
```
Gitea (C8) → existing pipeline (NOT on this guest) → make html → published site
```
Explicitly excluded (Q1=A). No Python, no pelican, no `make` on this guest. If this flow were
expected here, **C-1's risk profile would change materially** — it is not.
## Provisioning order (what must exist before what)
```
U1 guest → U2 nginx+php-fpm → U3 Atheos → U4 git + clone → U5 docs + script
▲
U4 also depends on U1 (git) ─────────────────┘
```
Each arrow is a real dependency: installing Atheos before php-fpm leaves nothing to serve it;
cloning before Atheos exists has no workspace to clone into.
@@ -0,0 +1,48 @@
# Unit of Work Dependency — atheos-blog-lxc
**Stage**: INCEPTION — Units Generation (Part 2)
## Dependency matrix
Rows *depend on* columns.
| | U1 Guest | U2 Web stack | U3 Atheos | U4 Git+clone | U5 Docs |
|---|---|---|---|---|---|
| **U1 Guest** | — | — | — | — | — |
| **U2 Web stack** | ✔ | — | — | — | — |
| **U3 Atheos** | ✔ | ✔ | — | — | — |
| **U4 Git+clone** | ✔ (git) | — | ✔ (workspace) | — | — |
| **U5 Docs** | ✔ | ✔ | ✔ | ✔ | — |
## Build order
```
U1 ──► U2 ──► U3 ──► U4 ──► U5
│ ▲ │
└─────────────────┴────────┘
(U4 needs git from U1)
```
**Critical path**: U1 → U2 → U3 → U4 → U5. Strictly sequential — no parallelism worth the
coordination cost at this size. Each unit already includes a verification step, so a failure is
caught at unit granularity rather than at the end.
## Why these boundaries
| Boundary | Reason |
|---|---|
| U1 before everything | nothing can be installed without the guest |
| U2 (nginx+php) is one unit, not two | the **socket contract (I-1)** spans both — splitting them would create a boundary through a single interface, which is exactly where integration bugs live |
| U3 after U2 | Atheos has nothing to run on until PHP exists |
| U4 separate from U3 | **different failure class**: U3 is PHP-runtime risk (C-1); U4 is VCS/permissions risk. Separating them keeps a U3 failure from disguising itself as "git is broken" |
| U4 before U5 | the RUNBOOK must document the real remote command, which only exists after the clone |
| U5 last | docs describe what was actually built |
## Cross-unit risks
| Risk | Units | Note |
|---|---|---|
| FPM user identity | U2 → U3 → U4 | U2 resolves the run user; U3 sets ownership; U4 owns the key. **All three must agree on the same user** — a mismatch yields "dubious ownership" (I-3) |
| FPM socket path | U2 (internal) | must match across nginx + pool |
| Pinned Atheos SHA | U3 → U5 | U5's update script must re-pin the same mechanism |
| Remote URL | U4 → U5 | U5 documents it; U4 must not change it to SSH |
@@ -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
```
@@ -0,0 +1,75 @@
# Application Design Plan — atheos-blog-lxc
**Stage**: INCEPTION — Application Design (**minimal depth**)
**Date**: 2026-09-29
## Rationale for minimal depth
There is no application code to design — this project provisions a guest and installs an existing
PHP application. "Application Design" here means recording the **runtime component map and its
interfaces**, so that (a) the unit boundaries are defensible and (b) a later agent inheriting the
repo can see how the pieces talk to each other without re-deriving it.
## Plan (checkboxes)
- [ ] Generate `components.md` — component definitions + responsibilities
- [ ] Generate `component-methods.md` — the *interfaces* between components (not business logic)
- [ ] Generate `services.md` — orchestration / request flow
- [ ] Generate `component-dependency.md` — dependency matrix + data flow
- [ ] Generate `application-design.md` — consolidated design
- [ ] Validate design completeness and consistency
## Design questions
Most design decisions were already fixed in requirements.md. Below is the one that is genuinely
open, plus two where I state my decision so you can veto it rather than having to choose blind.
---
### Q1 (genuine — blocks U4) — what git committer identity should Atheos use?
Atheos's git integration commits as a configured user. You told me you'll own the `armistace`
credentials (**FR-11** — I must not create them). But `git` will refuse to commit at all without
*some* identity, so leaving it entirely unset risks a broken first commit for you.
- **A)** Set a **neutral non-armistace** identity, e.g. `Atheos <atheos@atheos.local>`, purely so
commits don't hard-fail; **you** override it with your own identity when you set up credentials.
*(Recommended — leaves the tool working without impersonating you.)*
- **B)** Set **nothing at all** — leave `user.name`/`user.email` unset, and the RUNBOOK tells you to
set them as your first action after SSH-ing in. *(Purest reading of FR-11; first commit fails until you do.)*
- **C)** Use a **placeholder under your name** (e.g. `armistace <ar17787@gmail.com>`) so commits are
attributed to you out of the box. *(Note: this creates an armistace identity, which is what FR-11
says I should not do — only pick this if you want to override that.)*
- **X)** Other (describe after the tag)
`[Answer]:`
---
### Q2 (decision stated — veto if wrong) — pin Atheos to a specific upstream commit
Upstream Atheos is an actively-moving master branch with no releases. For a reproducible
re-provision (FR-9), I propose pinning to a **specific verified commit SHA** resolved at build
time and recorded in the script + RUNBOOK, rather than tracking `master`.
`[Answer]:` *(leave blank to accept)*
---
### Q3 (decision stated — veto if wrong) — where things live
- Web root: `/var/www/atheos` (Atheos app)
- Atheos workspace: `/var/www/atheos/workspace` → this is where `blog` is cloned
- nginx listens on **:80**, `root /var/www/atheos`, `index index.php`
- php-fpm via **unix socket** (`/run/php-fpm83/...`) — not a TCP port, so nothing extra listens on the LAN
`[Answer]:` *(leave blank to accept)*
---
## Assumptions carried
- Atheos is served at the **site root** (`http://<ip>/`), not a subdirectory.
- The operator completes Atheos's first-run installer in the browser (creates the admin user);
the agent seeds neither the admin nor any Atheos user.
- `blog` is pre-cloned into `workspace/` so it appears as a ready-to-open project (your Q6).
+149
View File
@@ -0,0 +1,149 @@
# Execution Plan — atheos-blog-lxc
**Stage**: INCEPTION — Workflow Planning
**Date**: 2026-09-29
**Deploy Target**: Proxmox LXC (Alpine 3.23) on node **scotty** (.69). No K3s flavour, no CI.
---
## Detailed Analysis Summary
### Change Impact Assessment
- **User-facing changes**: Yes — a new web UI (Atheos) becomes reachable on the LAN.
- **Structural changes**: Yes — one new Proxmox guest (VMID 136).
- **Data model changes**: No — Atheos is database-less; it stores config in files.
- **API changes**: No.
- **NFR impact**: Low — a small single-purpose guest; NFRs already captured in requirements.md.
- **Existing systems touched**: `scotty` gains a guest + a template download. `armistace/blog` is
**read** (cloned) only — never pushed to by the agent.
### Risk Assessment
- **Risk Level**: **Medium** — the steps are well understood, but Atheos-on-Alpine is under-documented
(C-1) and the install has a browser-driven first-run step.
- **Rollback Complexity**: **Easy** — `pct destroy 136` removes the guest; nothing else is mutated.
- **Testing Complexity**: **Simple-to-Moderate** — all acceptance criteria are observable
(served page, file on disk, git remote, survives reboot).
### Component Relationships
- **Primary component**: LXC 136 on scotty.
- **Internal**: nginx → php-fpm (unix socket) → Atheos PHP app → workspace/ filesystem.
- **External (read-only)**: `git.aridgwayweb.com/armistace/blog` (public clone).
- **Operator-owned**: SSH key, git identity, Atheos login — the agent creates none of these.
---
## Workflow Visualization
```mermaid
flowchart TD
Start(["User Request"])
subgraph INCEPTION["🔵 INCEPTION PHASE"]
WD["Workspace Detection<br/><b>COMPLETED</b>"]
RE["Reverse Engineering<br/><b>SKIPPED</b>"]
RA["Requirements Analysis<br/><b>COMPLETED</b>"]
US["User Stories<br/><b>SKIPPED</b>"]
WP["Workflow Planning<br/><b>IN PROGRESS</b>"]
AD["Application Design<br/><b>EXECUTE</b>"]
UG["Units Generation<br/><b>EXECUTE</b>"]
end
subgraph CONSTRUCTION["🟢 CONSTRUCTION PHASE"]
FD["Functional Design<br/><b>SKIP</b>"]
NFRA["NFR Requirements<br/><b>SKIP</b>"]
NFRD["NFR Design<br/><b>SKIP</b>"]
ID["Infrastructure Design<br/><b>EXECUTE</b>"]
CG["Code Generation<br/><b>EXECUTE</b>"]
BT["Build and Test<br/><b>EXECUTE</b>"]
end
subgraph OPERATIONS["🟡 OPERATIONS PHASE"]
OPS["Operations<br/><b>PLACEHOLDER</b>"]
end
Start --> WD --> RA --> WP --> AD --> UG --> ID --> CG --> BT --> End(["Complete"])
style WD fill:#4CAF50,stroke:#1B5E20,stroke-width:3px,color:#fff
style RA fill:#4CAF50,stroke:#1B5E20,stroke-width:3px,color:#fff
style WP fill:#4CAF50,stroke:#1B5E20,stroke-width:3px,color:#fff
style CG fill:#4CAF50,stroke:#1B5E20,stroke-width:3px,color:#fff
style BT fill:#4CAF50,stroke:#1B5E20,stroke-width:3px,color:#fff
style RE fill:#BDBDBD,stroke:#424242,stroke-width:2px,stroke-dasharray: 5 5,color:#000
style US fill:#BDBDBD,stroke:#424242,stroke-width:2px,stroke-dasharray: 5 5,color:#000
style FD fill:#BDBDBD,stroke:#424242,stroke-width:2px,stroke-dasharray: 5 5,color:#000
style NFRA fill:#BDBDBD,stroke:#424242,stroke-width:2px,stroke-dasharray: 5 5,color:#000
style NFRD fill:#BDBDBD,stroke:#424242,stroke-width:2px,stroke-dasharray: 5 5,color:#000
style AD fill:#FFA726,stroke:#E65100,stroke-width:3px,stroke-dasharray: 5 5,color:#000
style UG fill:#FFA726,stroke:#E65100,stroke-width:3px,stroke-dasharray: 5 5,color:#000
style ID fill:#FFA726,stroke:#E65100,stroke-width:3px,stroke-dasharray: 5 5,color:#000
style OPS fill:#FFF59D,stroke:#F9A825,stroke-width:2px,color:#000
linkStyle default stroke:#333,stroke-width:2px
```
*(Plain-text equivalent for Matrix: WD ✅ → RA ✅ → WP ✅ → AD ▶ → UG ▶ → ID ▶ → CG ▶ → BT ▶ → Done.
Skipped: RE, US, FD, NFRA, NFRD.)*
---
## Phases to Execute
### 🔵 INCEPTION PHASE
- [x] Workspace Detection — COMPLETED
- [x] Reverse Engineering — **SKIP** (greenfield repo; the *estate* recon was folded into Workspace Detection and recorded in `aidlc-state.md` → Existing Estate Facts)
- [x] Requirements Analysis — COMPLETED
- [x] User Stories — **SKIP** (user-confirmed; single operator, no app code. A **requirement→unit map** substitutes for `unit-of-work-story-map.md`, per the skipped-stories rule)
- [x] Workflow Planning — COMPLETED
- [ ] **Application Design** — **EXECUTE (minimal depth)**
- **Rationale**: there *are* distinct components with real interfaces (nginx ↔ php-fpm socket, Atheos ↔ workspace, git ↔ remote). Recording them at minimal depth gives the handoff a component map and drives unit boundaries. Minimal, not comprehensive — no app code exists to design.
- [ ] **Units Generation** — **EXECUTE**
- **Rationale**: genuinely multi-unit work (guest, web stack, app, VCS, docs) with an ordering dependency.
### 🟢 CONSTRUCTION PHASE
- [ ] Functional Design — **SKIP**
- **Rationale**: no business logic to design; the "functions" are package installs and config files already specified in requirements.md.
- [ ] NFR Requirements — **SKIP**
- **Rationale**: all NFRs already captured (requirements.md §3); nothing new surfaces at construction.
- [ ] NFR Design — **SKIP**
- **Rationale**: same — NFRs here are simple resource/reboot-survival constraints, not a design problem.
- [ ] **Infrastructure Design** — **EXECUTE**
- **Rationale**: **this is the real design work for an infra project** — the guest spec, storage choice, template provenance, network mode, resource sizing, and the OpenRC service wiring. Getting this wrong is the main risk (C-1/C-2/C-3).
- [ ] **Code Generation** — EXECUTE (always)
- **Rationale**: the provisioning script + the docs are the deliverables.
- [ ] **Build and Test** — EXECUTE (always)
- **Rationale**: here "build and test" = **apply against the live node and verify**, plus the reboot-survival cycle (AC-6). Not a compiled artifact.
### 🟡 OPERATIONS PHASE
- [ ] Operations — PLACEHOLDER (future deployment/monitoring workflows)
---
## Unit Plan (provisional — finalised in Units Generation)
| Unit | Scope | Depends on | Primary ACs |
|---|---|---|---|
| **U1** | LXC creation: download Alpine 3.23 template on scotty, `pct create 136` (20 GB root, 2 vCPU, 1 GB, onboot, unprivileged), base config, timezone, package index | — | AC-1 |
| **U2** | Web stack: `nginx` + `php83-fpm` + `php83-{session,zip,openssl,mbstring}`, `allow_url_fopen`, php-fpm pool, nginx vhost | U1 | AC-2 |
| **U3** | Atheos install: fetch pinned upstream commit into the web root, correct ownership/permissions on `config.php`, `data/`, `workspace/`, `plugins/`, `themes/` | U2 | AC-2, AC-3 |
| **U4** | Git + clone: install git, `safe.directory`, committer identity (non-armistace), clone `armistace/blog` into `workspace/`, leave remote HTTP | U1, U3 | AC-3, AC-4, AC-5 |
| **U5** | Repo + docs: `README.md`, `RUNBOOK.md` (incl. SSH remote-switch + operator credential steps), `docs/security-notes.md`, `docs/resiliency-notes.md`, re-provision script | U1–U4 | AC-7 |
---
## Estimated Timeline
**No wall-clock estimate offered** — fabricating one would be worse than omitting it. The work is
5 units, several of which are a handful of commands each; the only genuinely uncertain step is
U3 (Atheos-on-Alpine, risk C-1). Each unit ends in a verifiable state, so progress is measurable
even without a time estimate.
---
## Success Criteria
- **Primary goal**: a LAN-reachable Atheos on Alpine where `armistace/blog` is cloned and editable,
with credentials owned entirely by the operator.
- **Key deliverables**: LXC 136 on scotty; Atheos served by nginx/PHP; the blog clone in the
workspace; README + RUNBOOK + re-provision script; security/resiliency TODO notes.
- **Quality gates**:
- AC-1…AC-8 all demonstrated with real command output (not assertions)
- Atheos served (not 502/404) — verified over HTTP from the LAN
- **Reboot-survival verified** (AC-6), not assumed
- **No Python/pelican present** (AC-8) — proves scope was respected
- **No `armistace` credential or git identity created by the agent** (FR-11)
- Independent review of the generated provisioning script before the Build-and-Test gate
- `audit.md` complete and append-only; `aidlc-state.md` current
+32
View File
@@ -0,0 +1,32 @@
# Unit of Work Plan — atheos-blog-lxc
**Stage**: INCEPTION — Units Generation (Part 1: Planning)
**Date**: 2026-09-29 · **Depth**: minimal
## Rationale
The unit boundaries were already surfaced in the approved execution plan and the operator approved
them ("continue looks good"). The decomposition is not genuinely in question — it follows the
component dependency chain directly. So this plan carries **no open questions**; asking any would be
ceremony. It is recorded here so the generation step is traceable rather than improvised.
## Decomposition principle
Units follow the **topological order of the dependency graph** — each unit ends in a state you can
verify, and no unit depends on a later one. This matters because a failure in U3 (the genuinely
uncertain one, Atheos-on-Alpine) must not leave U1/U2 work wasted or unverifiable.
## Plan
- [ ] Generate `unit-of-work.md` — unit definitions + responsibilities
- [ ] Generate `unit-of-work-dependency.md` — dependency matrix + build order
- [ ] Generate `requirement-to-unit-map.md` — **substitutes `unit-of-work-story-map.md`** (stories were skipped); every FR/NFR/AC assigned to exactly one unit
- [ ] Validate unit boundaries and dependencies
- [ ] Verify no requirement is unassigned
## Approval
Units were presented in the approved execution plan; the operator's "continue looks good" covers
this decomposition. No blocking questions.
`[Answer]:` *(leave blank — approval recorded from the execution-plan gate)*
@@ -0,0 +1,98 @@
# Requirement Verification Questions — atheos-blog-lxc
**Project**: Alpine LXC running Atheos, wired to Gitea, with `armistace/blog` cloned and ready.
**Stage**: INCEPTION — Requirements Analysis
**Date**: 2026-09-29
Answer each question by writing your choice after the `[Answer]:` tag (e.g. `[Answer]: B`).
Option **X) Other** is always available — describe it after the tag.
---
## 0. CRITICAL FINDING — please read before answering Q1
Reconnaissance found that **`armistace/blog` is a Pelican static site, not a folder of markdown**:
```
src/content/*.md ← ~20 posts (the actual writing)
src/themes/cleanblog/ ← theme
src/pelicanconf.py ← build config
src/Makefile ← `make html`, `make serve-global`, `make github`
requirements.txt ← pelican[markdown], markdown-markup-emoji
```
So there are two different needs, and they need different tools:
- **Atheos** = a browser editor. It edits files and does git (its `codegit` component).
It can edit the `.md`, commit and push — but it **cannot build or publish** the site.
- **Publishing the site** = running `make html` (+ `make github` / `pelican`), which needs
**Python + pelican**, not PHP.
**Q1. How important is it that this box can also BUILD/PUBLISH the site?**
- A) **Editor only** — I'll build/publish elsewhere (CI or another box). Atheos just edits + commits.
- B) **Also include Pelican** — install Python + pelican + make so I can run `make html` /
`make serve` on this box to preview and regenerate the site.
- C) **Also include Pelican AND a local preview server** — so I can click through the built site
from the browser on this LXC before pushing.
- X) Other (please describe after [Answer]: tag below)
[Answer]:
---
**Q2. Does Atheos's git integration need to work non-interactively (credentials pre-wired),
or will you supply them when you first push?**
- A) **Bake in the git identity + let me push over HTTPS with a Gitea token** (I'll give you /
you use an existing scoped token) so push works immediately without a prompt.
- B) **Clone read-only / leave push to me** — set up the clone and git config, but leave the
credential to me (e.g. via SSH key I install myself, per my point 4).
- C) **Use SSH from the start** — configure the remote as `git@git.aridgwayweb.com:armistace/blog.git`
and just wait for the key I'll add.
- X) Other (please describe after [Answer]: tag below)
[Answer]:
---
**Q3. Which PVE node should host the LXC, and on which storage?**
- A) **janeway (.225)** — 3.3 GB RAM free, `extra` storage 874 GB free. (Recommended: roomy.)
- B) **data (.42)** — only ~0.95 GB RAM free and no swap; a 20 GB guest is fine on disk but the
node is tight. (Not recommended.)
- C) **You pick** whatever is healthiest at build time.
- X) Other (please describe after [Answer]: tag below)
[Answer]:
---
**Q4. Should the container be reachable only on the LAN, or exposed via NPM
(nginx proxy manager) with a hostname like `blog-ide.aridgwayweb.com`?**
- A) **LAN only** — browse to `http://<lxc-ip>/`. Simplest, nothing new exposed.
- B) **Add an NPM proxy host** — I get a hostname; you add the proxy host + DNS (you own NPM).
- C) **LAN now, NPM later** — build LAN-only, leave a RUNBOOK section for adding NPM.
- X) Other (please describe after [Answer]: tag below)
[Answer]:
---
**Q5. Extensions (AI-DLC security/resiliency baseline). Your usual choice is to opt OUT for a
LAN-only PoC, and I document the "what to do if this ever moves external" TODOs instead. Confirm?**
- A) **Opt out of both, document the TODOs** (your standing preference).
- B) **Enable security-baseline** too.
- C) **Enable resiliency-baseline** too.
- D) **Enable both.**
- X) Other (please describe after [Answer]: tag below)
[Answer]:
---
## Notes / assumptions I'll carry unless you say otherwise
- **Q6 (implicit, shout if wrong):** "downloads … ready for me to use" = the repo is cloned to a
path Atheos can open as a **project**, on the correct branch, with git identity configured —
not that the container does anything to the blog's remote. The blog stays a separate repo; this
project's repo holds the **provisioning**.
- LXC defaults I propose: **2 vCPU, 1 GB RAM, 20 GB root**, Alpine 3.23, `onboot=1`, unprivileged.
- I will **not** touch `armistace` SSH keys — that's your point 4.
+129
View File
@@ -0,0 +1,129 @@
# Requirements — atheos-blog-lxc
**Project**: Alpine LXC running Atheos, wired to Gitea, with `armistace/blog` cloned and ready to edit.
**Stage**: INCEPTION — Requirements Analysis
**Date**: 2026-09-29
**Deploy Target**: Proxmox LXC on node **scotty** (.69). Not the K3s cluster.
---
## 1. Intent Analysis Summary
| | |
|---|---|
| **User request** | An Alpine LXC that (1) installs Atheos with nginx+PHP, (2) sets up git integration, (3) has `git.aridgwayweb.com/armistace/blog` cloned and ready to use, (4) leaves SSH key setup to the operator, (5) is backed by a 20 GB root. |
| **Request type** | New Project (greenfield repo; provisioning a new guest) |
| **Scope estimate** | Multiple components — guest creation, web stack, web app, VCS wiring, docs |
| **Complexity estimate** | Moderate — no application code to write; the risk is in correct provisioning and in not fighting Atheos's install model |
### The one finding that shaped this
`armistace/blog` is a **Pelican static site**, not plain markdown: it has `pelicanconf.py`,
`Makefile`, `publishconf.py` and `themes/cleanblog`. Building/publishing needs **Python + pelican**,
which Atheos (PHP) cannot do. Andrew confirmed (**Q1=A**) that the existing pipeline already handles
the build and that *"just writing markdown is more than enough"* — so the **build is explicitly out
of scope**. This box is an **editor + git client**, nothing more.
---
## 2. Functional Requirements
| ID | Requirement | Source |
|---|---|---|
| **FR-1** | An **Alpine 3.23 LXC** is created on PVE node **scotty** (.69) with a **20 GB rootfs**, 2 vCPU and 1 GB RAM, `onboot=1`, unprivileged. | user point 5 + Q3 |
| **FR-2** | **nginx + PHP-FPM** are installed and configured to serve Atheos; PHP includes the extensions Atheos wants for its plugin marketplace (**ZIP, OPENSSL, MBSTRING**) and `allow_url_fopen=On`. | user point 1 |
| **FR-3** | **Atheos** (github.com/Atheos/Atheos) is installed into the web root, pinned to a specific commit, with `config.php`, `data/`, `workspace/`, `plugins/` and `themes/` writable by the PHP-FPM user. No database is required. | user point 1 |
| **FR-4** | Atheos's **git integration** is configured and usable: git installed, a safe.directory set for the workspace, and Atheos's own git settings present (committer identity, default branch). | user point 2 |
| **FR-5** | `armistace/blog` is **cloned into `workspace/`** on its default branch (`master`) so it appears as an Atheos project, ready to open and edit. | user points 3, 6 |
| **FR-6** | The clone is **read-from-Gitea ready**, and the repo is left with instructions to **switch the remote to SSH** once the operator adds their key. The agent must **not** switch the remote nor install credentials itself. | Q2 + Q6 |
| **FR-7** | A **RUNBOOK.md** documents: the container's IP, how to log in over SSH, where the workspace/project lives, exactly how to move to an SSH remote (`git remote set-url`) after adding the key, plus credential/identity setup and common ops. | Q2 + Q6 + standing preference |
| **FR-8** | A **README.md** documents what this is, the install method, and the one-time provisioning steps. | standing preference |
| **FR-9** | The project repo (`hermes/atheos-blog-lxc`) holds a **repeatable provisioning script** so the whole LXC can be re-created from scratch. | AI-DLC / standing preference |
| **FR-10** | **Reachability is LAN-only**: Atheos served on the LXC's own IP on port 80. No NPM proxy host, no DNS record, no router port-forward is created. | Q4 |
| **FR-11** | The agent **does not create or configure `armistace` credentials or git identity**. The operator performs that over SSH, under their own identity. | Q2 (explicit restriction) |
---
## 3. Non-Functional Requirements
| ID | Requirement |
|---|---|
| **NFR-1** | Rootfs is 20 GB and must stay comfortably within it (Atheos + nginx + PHP + git + the blog repo is well under 1 GB). |
| **NFR-2** | The container must survive a **reboot** and return serving Atheos with the clone still present and functional (verify, don't assume). |
| **NFR-3** | Provisioning must be **repeatable and idempotent-friendly**: a single documented script plus manual steps. |
| **NFR-4** | The guest must not depend on the K3s cluster; it is a standalone LXC. |
| **NFR-5** | Timezone should match the operator (**Australia/Brisbane**) so Atheos/git timestamps are sane. |
---
## 4. Explicitly Out of Scope (confirmed by the operator)
- **Building/publishing the Pelican site.** No Python, pelican, `make html`, or preview server on
this box (Q1=A). The existing pipeline handles the build.
- **SSH key creation/installation for `armistace`.** Operator does this (Q2, user point 4).
- **Any NPM / public exposure** (Q4=LAN only).
- **Atheos user accounts beyond the initial install.** The operator will create their own login
during Atheos's first-run installer.
---
## 5. Constraints & Risks
| ID | Constraint / risk | Mitigation |
|---|---|---|
| **C-1** | 🔴 **Atheos on Alpine is off the beaten path.** Upstream only documents Ubuntu/CentOS/Apache/nginx. Alpine's PHP packaging and paths differ. | Verify the package set on Alpine 3.23 before installing (php83 + php83-fpm + php83-session + php83-zip + php83-openssl + php83-mbstring). Confirm the FPM socket path and nginx `fastcgi_pass` match. |
| **C-2** | 🔴 **Alpine is OpenRC, not systemd.** Service management is `rc-service`/`rc-update`. | Provision with OpenRC idioms; do not emit systemd units. |
| **C-3** | 🔴 **The Alpine template is not yet downloaded on scotty.** | `pveam update` + `pveam download local alpine-3.23-default_...` before `pct create`. |
| **C-4** | 🟠 **Atheos first-run installer needs the web root writable** and will create a config + admin user. | Set ownership/permissions correctly; document that the operator completes the installer in the browser. |
| **C-5** | 🟠 **`git clone` of an internet-facing Gitea from a new guest** — the clone is public (repo is public), so no credential is needed for the *clone*. | Confirm the repo stays public; clone over the public URL. If it ever goes private, the RUNBOOK covers adding a credential. |
| **C-6** | 🟡 Agent must respect the **no-armistace-credentials** restriction. | FR-11; state it plainly in the RUNBOOK so a future agent does not "helpfully" set it. |
---
## 6. Extension Configuration
| Extension | Enabled | Decided At |
|---|---|---|
| security-baseline | ❌ No | Requirements Analysis (Q5=A) |
| resiliency-baseline | ❌ No | Requirements Analysis (Q5=A) |
| property-based-testing | ❌ No | Requirements Analysis (Q5=A) |
Per the standing preference, the opted-out concerns are **not dropped** — they are written up as
"what to do if this ever moves external" in `docs/security-notes.md` and `docs/resiliency-notes.md`
(see FR-12 below).
| ID | Requirement | Source |
|---|---|---|
| **FR-12** | Even with both extensions opted out, produce `docs/security-notes.md` and `docs/resiliency-notes.md` listing the concrete hardening TODOs and their code touch-points, and cross-reference them from the README. | standing preference |
---
## 7. Acceptance Criteria (Given/When/Then)
- **AC-1** — *Given* the LXC is created, *when* I run `pct config 136` on scotty, *then* it shows
Alpine 3.23, rootfs 20 GB, and it is onboot-enabled.
- **AC-2** — *Given* provisioning is complete, *when* I open `http://<lxc-ip>/` from the LAN, *then*
Atheos's first-run installer (or login) is served by nginx/PHP — not a 502/404.
- **AC-3** — *Given* I complete the Atheos installer, *when* I open the workspace, *then* `blog` is
present as a project whose files I can open and edit.
- **AC-4** — *Given* the `blog` project is open, *when* I edit a markdown file and save, *then* the
change persists to disk inside the container.
- **AC-5** — *Given* the clone, *when* I run `git -C workspace/blog remote -v`, *then* it points at
Gitea (initially HTTP), and the RUNBOOK gives the exact `git remote set-url` command to move to SSH.
- **AC-6** — *Given* the container is rebooted, *when* it comes back, *then* Atheos serves again and
the clone and any edits are still present.
- **AC-7** — *Given* the RUNBOOK, *when* a different agent picks the repo up, *then* they can
re-provision or hand off without prior context.
- **AC-8** — *Given* the build is out of scope, *when* I search the container, *then* no Python/pelican
build tooling is installed (proving scope was respected).
---
## 8. Key Requirements Summary
- One small **Alpine LXC (136) on scotty**, 20 GB root, serving **Atheos** over **nginx+PHP** on the LAN.
- **Atheos is an editor + git client** here — deliberately **not** a build environment.
- **`armistace/blog` cloned into Atheos's workspace**, ready to write in.
- **Operator-owned credentials**: the agent sets up the clone and the instructions; the operator adds
the SSH key and, later, switches the remote.
- **RUNBOOK + README + re-provision script**, because a later agent or a re-build must not need me.
+55
View File
@@ -0,0 +1,55 @@
# Resiliency Notes — atheos-blog-lxc
**Status**: resiliency-baseline extension **opted out** (LAN-only PoC) per Andrew's standing preference.
**Purpose**: record what a non-PoC deployment would need, so the trail survives.
---
## 1. What this guest is, from a resiliency standpoint
A **single small container that can be destroyed and rebuilt** — that is the design, not a gap.
`armistace/blog` is the durability anchor: the source of truth lives in **Gitea**, not on this guest.
Losing the LXC loses only:
- the Atheos install (reproducible: `scripts/provision.sh`)
- the local clone (re-clonable)
- **uncommitted edits** ← the only genuinely perishable data
## 2. Current posture
| Aspect | State | Note |
|---|---|---|
| Rootfs | 20 GB, thin-provisioned on `scotty-local-lvm` | thin pool had 771 GB free at design time |
| `onboot` | enabled | survives a node restart |
| Backups | **none** | acceptable: nothing unique is stored |
| Snapshots | none taken (a `pre-*` snapshot is used during *upgrades* only) | |
| Rebuild path | `scripts/provision.sh` | **this is the resiliency mechanism** |
| Reboot survival | **verified**, not assumed | AC-6 — service returns and the clone persists |
## 3. If this ever moves beyond a LAN PoC
| # | Item | Touch-point |
|---|---|---|
| R-1 | **Scheduled snapshot** of the guest (`pct snapshot` / PBS) with retention | PVE / PBS |
| R-2 | **Automated verification of the rebuild path** — run `provision.sh` into a throwaway VMID on a schedule; a backup you have never restored is a claim | CI or cron on the PVE node |
| R-3 | **Alert on guest down** (`pct status` + HTTP probe) | observability stack |
| R-4 | **Push-based durability for in-progress work**: an auto-commit/ahead-of-time branch so uncommitted edits stop being the failure mode | a small hook or Atheos-side job |
| R-5 | **Disk alert at 80%** of the 20 GB rootfs (a full rootfs silently breaks PHP sessions) | observability stack |
| R-6 | **Move the clone's durability to the repo**: rely on Gitea backup/restore rather than guest backups | Gitea |
| R-7 | **Document RTO/RPO**: here RTO ≈ re-run `provision.sh` (minutes); RPO = last commit | RUNBOOK |
| R-8 | **`scotty-local-lvm` thin-pool monitoring** — a thin pool that fills takes down *every* guest on it, not just this one | PVE node |
## 4. Design choices that already help
- **Nothing unique is stored on the guest** — the deliberately tiny 20 GB root is a *feature*: it
bounds what can be lost and keeps rebuild cheap.
- **Provisioning is a script**, so rebuild is not a memory exercise (FR-9).
- **The build pipeline is elsewhere** (Q1=A), so this guest is not on the critical path for publishing.
- **OpenRC services are enabled at boot** (AC-6 verifies it), so a reboot is not a repair event.
## 5. Accepted risks
| Risk | Why accepted | Revisit trigger |
|---|---|---|
| No backups | no unique data; rebuild is scripted | if the guest starts holding anything unique |
| Single guest on a single node | LAN PoC scope | on any production expectation → R-1/R-3 |
| Uncommitted edits are perishable | normal for a git worktree; operator controls commit cadence | if it bites → R-4 |
+88
View File
@@ -0,0 +1,88 @@
# Security Notes — atheos-blog-lxc
**Status**: security-baseline extension **opted out** (LAN-only PoC) per Andrew's standing preference.
**Purpose**: preserve the hardening trail so that *if this ever moves to the wider web*, the work is
already scoped, and nobody has to re-derive it. Every item names its code touch-point.
**Reviewed**: 2026-09-29. Design decision **D6** (see `application-design.md`).
---
## 1. The credential model (the load-bearing decision)
**Chosen: a per-repository Gitea _deploy key_, scoped to `armistace/blog`, write-enabled.**
### Why not the alternatives
| Option | Scope if stolen | Verdict |
|---|---|---|
| **Deploy key** (chosen) | `armistace/blog` **only** | ✅ per-repo scoping, explicit `read_only` flag |
| `armistace` personal access token | **every repo armistace can write** (scopes are route-groups like `write:repository`, matched against user access — *not* per-repo) | ❌ account-wide blast radius |
| `armistace` personal SSH key | whole account, incl. web/API | ❌ |
| Dedicated "Atheos" Gitea user | whatever it owns/grants — **no reduction** for `blog` | ❌ adds a credential + muddies attribution |
**Verified, not assumed** (2026-09-29, live instance 1.27.3):
- API schema: `AccessToken.scopes` = `["read:repository","write:repository",…]`; `DeployKey` carries
`read_only: bool` and is bound to one repository. Deploy-key endpoints
`/repos/{owner}/{repo}/keys` exist and are **per-repo**.
- The instance's own token inventory shows the risk concretely: `armistace` holds 8 access tokens,
one with scope **`all`**.
> **Threat model this addresses.** Atheos is itself the untrusted component: it is a file editor
> with shell-exec reach (`codegit` invokes `git`; Atheos uses `exec`/`system`) and it is reachable by
> anyone on the LAN. The goal is not to make compromise impossible — it is to make the **blast radius
> one repository** when it happens.
### Implementation (code touch-points)
| Item | Where | Detail |
|---|---|---|
| Keypair generated **on the guest**, never leaves it | `scripts/provision.sh`, U4 | `ssh-keygen -t ed25519 -f ~/<fpmuser>/.ssh/id_ed25519_blog -N ''` |
| Key owned **only** by the php-fpm run user, mode `600` | U4 | not readable by nginx or other users |
| `IdentitiesOnly yes` + a named host alias | `~/<fpmuser>/.ssh/config` | `Host gitea-blog` so a stray push cannot target another repo |
| Remote uses the alias | U4 | `gitea-blog:armistace/blog.git` |
| **Public** half handed to the operator | via Matrix | operator adds it: repo → Settings → Deploy Keys → **Allow write access** |
⚠️ **Honest limitation.** For push to work, the deploy key must be readable by the web-app user, so a
full Atheos compromise *can* use it to write `blog`. Unavoidable for any push-capable editor. The
deploy key makes that the **floor** (one repo, one protected branch), not the **ceiling** (8 repos).
---
## 2. If this is ever exposed beyond the LAN
| # | Item | Touch-point |
|---|---|---|
| S-1 | **Terminate TLS** at NPM; never serve Atheos plaintext to the internet | NPM proxy host |
| S-2 | **Authenticate in front of Atheos**, not just inside it — an NPM Access List / SSO so the app is not the only gate | NPM |
| S-3 | **fail2ban** on the guest for failed Atheos logins and nginx 401/403s | guest + `nginx.conf` log format |
| S-4 | **Rate-limit** the login endpoint in nginx (`limit_req_zone`) | `nginx.conf` |
| S-5 | **Force HTTPS** redirect; set `Strict-Transport-Security` | `nginx.conf` |
| S-6 | **Session hardening**: `session.cookie_secure=1`, `session.cookie_httponly=1`, `session.cookie_samesite=Lax` | `php.ini` |
| S-7 | **Disable PHP dangerous functions** Atheos does not need: `exec, shell_exec, system, passthru, proc_open, popen` — *except* git needs `proc_open`/`exec`, so this needs testing before applying | `php.ini` — ⚠️ test, do not blind-apply |
| S-8 | **`open_basedir`** to `/var/www/atheos` + `/tmp` to contain a file-read bug | `php.ini` |
| S-9 | **nginx hardening**: `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, deny dotfiles, deny `*.md`/`*.py`/`.git` serving | `nginx.conf` |
| S-10 | **Protect the deploy key + config** from being served: verify `/.ssh`, `/config.php`, `/data` are not web-reachable | `nginx.conf` — **verify with curl, not by reading config** |
| S-11 | **Gitea-side**: consider an IP allowlist / rate limit on the git endpoint if the box is public | Gitea/NPM |
| S-12 | **Alerting** on: nginx 5xx rate, new git remote, deploy-key use, file changes under `/var/www/atheos` | not built — would need the observability stack |
| S-13 | **Keep Atheos patched** — it is PHP, no releases; re-pin deliberately (see `scripts/update-atheos.sh`) | update script |
| S-14 | **`allow_url_fopen=On`** is currently enabled (needed for the plugin marketplace). Turn it **off** for internet exposure unless the marketplace is genuinely required | `php.ini` |
## 3. Standing hygiene (applies now, LAN or not)
- Branch protection on `armistace/blog` **master** — so a compromised editor cannot force-push master,
only propose. *(Repo-side; operator-owned.)*
- The deploy key is **write-enabled** by explicit choice. If push ever becomes unnecessary, flip it to
read-only in the repo settings — zero code change.
- No secrets in git: the private key is generated on the guest and never committed (`.gitignore`).
- Agent must never create `armistace` credentials (FR-11).
## 4. Accepted risks (conscious, recorded)
| Risk | Why accepted | Revisit trigger |
|---|---|---|
| Atheos has shell-exec capability | it is a git-integrated editor; unavoidable | if exposed publicly → apply S-2, S-7, S-8 |
| Deploy key readable by the web user | required for push | if push no longer needed → set read-only |
| LAN has no TLS | LAN-only scope (FR-10) | on any external exposure → S-1 |
| `allow_url_fopen=On` | plugin marketplace | if the marketplace is unused → set Off |
| No monitoring/alerting | out of scope; observability stack is separate | if exposed publicly → S-12 |
+43
View File
@@ -0,0 +1,43 @@
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
root /var/www/atheos;
index index.php index.html;
access_log /var/log/nginx/atheos.access.log;
error_log /var/log/nginx/atheos.error.log;
# --- PHP -----------------------------------------------------------------
# The socket path here MUST byte-match the pool's `listen =` in
# /etc/php83/php-fpm.d/www.conf
# (interface I-1 — a mismatch yields a bare 502 with no other diagnostic).
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/run/php-fpm83/php-fpm.sock;
fastcgi_index index.php;
include fastcgi.conf;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
location / {
try_files $uri $uri/ =404;
}
# --- hardening -----------------------------------------------------------
location ~ /\.git { deny all; }
location ~ /\.ssh { deny all; }
location ~ /\. { deny all; }
location ~ ^/data/ { deny all; }
location ~ ^/(config\.php|common\.php|controller\.php|traits/) { deny all; }
location ~* \.(md|py|sh|log|conf|ini|sql|bak|orig)$ { deny all; }
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
client_max_body_size 64m;
}
+25
View File
@@ -0,0 +1,25 @@
# ~atheos/.ssh/config — install to /home/atheos/.ssh/config (mode 600, owner atheos)
#
# gitea-blog: per-repository deploy key for armistace/blog (design decision D6).
#
# !! THE USER IS `gitea`, NOT `git` !!
# This instance's app.ini sets SSH_USER = gitea.
# With `git` the sshd on that host answers "Invalid user git" and rejects the
# connection BEFORE reading authorized_keys - which looks identical to a
# rejected key. Verified in the sshd log on LXC 109, 2026-09-29.
#
# Why the LAN address rather than git.aridgwayweb.com:
# - app.ini sets SSH_DOMAIN = 192.168.178.155 / SSH_PORT = 22
# with START_SSH_SERVER = false, i.e. SSH is served by the Gitea LXC's own sshd.
# - the public path does NOT forward :22 (only :443, via NPM). Verified:
# git.aridgwayweb.com:22 -> unreachable
# 192.168.178.155:22 -> OPEN
# - this guest is LAN-only, so LAN transport is correct and avoids an internet round-trip.
#
# IdentitiesOnly yes ensures no other key is ever offered to the server.
Host gitea-blog
HostName 192.168.178.155
User gitea
IdentityFile ~/.ssh/id_ed25519_blog
IdentitiesOnly yes
Port 22
+128
View File
@@ -0,0 +1,128 @@
#!/bin/sh
# ==============================================================================
# build-and-test.sh — captures evidence for every acceptance criterion (AC-1..AC-8)
# Run ON THE GUEST: ssh root@192.168.178.57 'sh -s' < build-and-test.sh
# ==============================================================================
W=/var/www/atheos
B=$W/workspace/blog
pass=0; fail=0
ok() { echo " PASS $1"; pass=$((pass+1)); }
bad() { echo " FAIL $1"; fail=$((fail+1)); }
chk() { if [ "$2" = "$3" ]; then ok "$1 ($2)"; else bad "$1 (got '$2', want '$3')"; fi; }
asuser() { su -s /bin/sh atheos -c "$1"; }
echo "=============================================================="
echo "AC-2 Atheos is served by nginx+PHP (not 502/404)"
echo "=============================================================="
CODE=$(wget -qO /tmp/idx.html --server-response http://127.0.0.1/ 2>&1 | sed -n 's/.*HTTP\/1\.[01] \([0-9]*\).*/\1/p' | head -1)
chk "HTTP status for /" "$CODE" "200"
if grep -qi '<title>Atheos IDE</title>' /tmp/idx.html 2>/dev/null; then ok "served page is the Atheos IDE"; else bad "served page is not Atheos"; fi
if grep -qiE 'install|setup' /tmp/idx.html 2>/dev/null; then ok "first-run installer/login markers present"; else bad "no installer markers"; fi
SOCK=$(sed -n 's/^listen = //p' /etc/php83/php-fpm.d/www.conf | head -1)
NGX=$(sed -n 's/.*fastcgi_pass unix:\([^;]*\);.*/\1/p' /etc/nginx/http.d/atheos.conf | head -1)
echo " --- socket contract (I-1):"
echo " php-fpm listen : $SOCK"
echo " nginx fastcgi : $NGX"
chk "socket paths byte-match" "$NGX" "$SOCK"
echo
echo "=============================================================="
echo "AC-3 'blog' is present with files that can be opened"
echo "=============================================================="
chk "posts found" "$(ls $B/src/content/*.md 2>/dev/null | wc -l | tr -d ' ')" "20"
if [ -d "$W/workspace/blog/.git" ]; then ok "it is a git working copy"; else bad "not a git working copy"; fi
chk "theme present" "$(ls $B/src/themes 2>/dev/null | tr -d ' ')" "cleanblog"
chk "workspace owner" "$(stat -c '%U' $W/workspace/blog)" "atheos"
echo
echo "=============================================================="
echo "AC-4 An edit persists to disk (as the FPM user)"
echo "=============================================================="
asuser "printf 'x\n' > $B/.ac4-probe" >/dev/null 2>&1
if [ "$(asuser "cat $B/.ac4-probe" 2>/dev/null)" = "x" ]; then ok "write+read back as 'atheos'"; else bad "cannot write as atheos"; fi
asuser "rm -f $B/.ac4-probe" >/dev/null 2>&1
for p in config.php data workspace plugins themes; do
if asuser "test -w $W/$p" 2>/dev/null; then ok "writable: $p"; else bad "NOT writable: $p"; fi
done
echo
echo "=============================================================="
echo "AC-5 Remote points at Gitea; the SSH switch is documented"
echo "=============================================================="
REMOTE=$(asuser "git -C $B remote get-url origin" 2>/dev/null)
echo " remote now: $REMOTE"
case "$REMOTE" in
*aridgwayweb.com*armistace/blog*) ok "remote targets armistace/blog on Gitea (HTTPS)" ;;
gitea-blog:armistace/blog*) ok "remote targets armistace/blog via the deploy-key alias" ;;
*) bad "remote does not target the blog repo" ;;
esac
chk "branch" "$(asuser "git -C $B rev-parse --abbrev-ref HEAD" 2>/dev/null)" "master"
# Phase 2: once the SSH alias is in use, prove the transport actually authenticates
case "$REMOTE" in
gitea-blog:*)
AUTH=$(asuser 'ssh -o BatchMode=yes -o ConnectTimeout=8 -T gitea-blog 2>&1' | head -1)
case "$AUTH" in
*successfully\ authenticated*) ok "deploy key authenticates over SSH" ;;
*) bad "deploy key does NOT authenticate: $AUTH" ;;
esac
;;
esac
echo
echo "=============================================================="
echo "AC-6 Survives reboot (services enabled, data intact)"
echo "=============================================================="
echo " container uptime: $(cut -d. -f1 /proc/uptime)s (reboot was issued during the test)"
for s in nginx php-fpm83 sshd; do
if rc-update show 2>/dev/null | grep -qE "^\s+$s\s+\|\s+default"; then ok "$s enabled at boot"; else bad "$s NOT enabled at boot"; fi
if rc-service "$s" status >/dev/null 2>&1; then ok "$s running"; else bad "$s not running"; fi
done
if [ -S "$SOCK" ]; then ok "fpm socket exists after reboot"; else bad "fpm socket missing"; fi
echo
echo "=============================================================="
echo "AC-8 SCOPE GUARD: no build toolchain present (must be ABSENT)"
echo "=============================================================="
for t in python python3 pelican pip pip3 make; do
if command -v "$t" >/dev/null 2>&1; then bad "$t IS present - scope violated!"; else ok "$t absent (correct)"; fi
done
echo " --- also check no database engine was installed (Atheos needs none):"
for d in mysqld mariadbd postgres redis-server; do
if command -v "$d" >/dev/null 2>&1; then bad "$d present - unexpected"; else ok "$d absent (correct)"; fi
done
echo
echo "=============================================================="
echo "FR-11 GUARD: agent created NO identity or armistace credential"
echo "=============================================================="
chk "git user.name empty" "$(asuser 'git config --get user.name' 2>/dev/null)" ""
chk "git user.email empty" "$(asuser 'git config --get user.email' 2>/dev/null)" ""
if [ -f /home/atheos/.ssh/id_ed25519_blog ]; then ok "deploy key present"; else bad "deploy key missing"; fi
chk "deploy key mode" "$(stat -c '%a' /home/atheos/.ssh/id_ed25519_blog 2>/dev/null)" "600"
chk "deploy key owner" "$(stat -c '%U' /home/atheos/.ssh/id_ed25519_blog 2>/dev/null)" "atheos"
chk "ssh config mode" "$(stat -c '%a' /home/atheos/.ssh/config 2>/dev/null)" "600"
echo
echo "=============================================================="
echo "RESOURCES"
echo "=============================================================="
echo " rootfs: $(df -hP / | tail -1 | awk '{print $2" total, "$3" used, "$4" avail ("$5")"}')"
# NOTE: on Alpine, `free`/`uptime` are BUSYBOX applets that use the sysinfo() syscall, which
# lxcfs cannot intercept - so they report the PVE HOST, not this container. procps-ng (installed
# by provision.sh) replaces them with binaries that read the lxcfs-virtualised /proc files.
# Detect via apk, which is the reliable signal (the binaries are plain files either way).
if apk info -e procps-ng >/dev/null 2>&1; then
echo " memory: $(/usr/bin/free -m 2>/dev/null | awk 'NR==2{print $3"MB used of "$2"MB"}')"
echo " uptime: $(/usr/bin/uptime 2>/dev/null | sed 's/.*up /up /;s/,.*//') (container-accurate)"
else
echo " memory: (procps-ng NOT installed - busybox free/uptime would report the HOST)"
fi
echo " lxcfs virtualises /proc: meminfo=$(grep MemTotal /proc/meminfo | awk '{print $2"kB"}') uptime=$(cut -d. -f1 /proc/uptime)s"
echo " listening ports: $(netstat -tln 2>/dev/null | awk '/LISTEN/{split($4,a,":"); print a[length(a)]}' | sort -u | tr '\n' ' ')"
echo
echo "=============================================================="
printf 'RESULT: %d passed, %d failed\n' "$pass" "$fail"
echo "=============================================================="
[ "$fail" -eq 0 ] || exit 1
+246
View File
@@ -0,0 +1,246 @@
#!/bin/sh
# ==============================================================================
# provision.sh — rebuild the Atheos blog editor LXC from scratch
# Project: atheos-blog-lxc (AI-DLC)
# Target : Proxmox LXC 136 on node scotty (192.168.178.69)
#
# RUN THIS ON THE PVE NODE (scotty), as root. It is written to be re-runnable:
# each step checks state before acting.
#
# This script reproduces exactly what was executed during the initial build on
# 2026-09-29. It deliberately does NOT create any `armistace` credential, git
# identity, or Gitea user (requirement FR-11).
# ==============================================================================
set -eu
VMID=136
NODE_HOST="192.168.178.69"
TEMPLATE_STORAGE="LARGE_STORAGE_1"
ROOTFS_STORAGE="scotty-local-lvm"
TEMPLATE="alpine-3.23-default_20260116_amd64.tar.xz"
BRIDGE="vmbr0"
ROOTFS_SIZE=20
CORES=2
MEMORY=1024
SWAP=256
TZ_NAME="Australia/Brisbane"
# --- Atheos pin (design decision D2: pinned, not tracking a moving branch) ----
# Recorded at build time 2026-09-29 from github.com/Atheos/Atheos (default branch `main`)
ATHEOS_SHA="f90cd1806dbe0e25673a77e27390e8b1a240c3f6"
ATHEOS_BUILD=611
ATHEOS_TARBALL_SHA256="03ebcb1479b59f1898b5fa50808b181750b42211698f7f7b4042cf2514d78b20"
# --- the blog repo (read-only clone source; agent never pushes) --------------
BLOG_REPO="https://git.aridgwayweb.com/armistace/blog.git"
GITEA_SSH_HOST="192.168.178.155" # LAN address: Gitea SSH_DOMAIN; public :22 is not forwarded
GITEA_SSH_USER="gitea" # !! app.ini SSH_USER=gitea - NOT the usual `git`.
# Using `git` yields "Invalid user git" from sshd,
# which is rejected BEFORE authorized_keys is read.
WEBROOT="/var/www/atheos"
FPM_USER="atheos"
FPM_SOCKET="/run/php-fpm83/php-fpm.sock"
log() { printf '\n=== %s\n' "$*"; }
# ==============================================================================
log "1/9 verify we are on the PVE node and the VMID is free"
hostname
pct status "$VMID" >/dev/null 2>&1 && { echo "ERROR: VMID $VMID already exists - aborting"; exit 1; }
# ==============================================================================
log "2/9 ensure the Alpine template is present"
pveam update >/dev/null 2>&1 || true
if ! pveam list "$TEMPLATE_STORAGE" 2>/dev/null | grep -q "$TEMPLATE"; then
pveam download "$TEMPLATE_STORAGE" "$TEMPLATE"
else
echo "template already present"
fi
# ==============================================================================
log "3/9 create the container (U1)"
pct create "$VMID" "$TEMPLATE_STORAGE:vztmpl/$TEMPLATE" \
--hostname atheos-blog \
--cores "$CORES" \
--memory "$MEMORY" \
--swap "$SWAP" \
--rootfs "$ROOTFS_STORAGE:$ROOTFS_SIZE" \
--net0 "name=eth0,bridge=$BRIDGE,ip=dhcp" \
--unprivileged 1 \
--onboot 1 \
--ostype alpine \
--features "" \
--description "Atheos browser editor for armistace/blog (AI-DLC project atheos-blog-lxc). LAN-only. Deploy key auth. See Gitea hermes/atheos-blog-lxc RUNBOOK."
pct start "$VMID"
sleep 8
pct status "$VMID"
# ==============================================================================
log "4/9 base configuration (U1): timezone, ssh, apt/apk index"
pct exec "$VMID" -- sh -c "
apk update >/dev/null 2>&1
apk add --no-cache tzdata openssh >/dev/null 2>&1
cp /usr/share/zoneinfo/$TZ_NAME /etc/localtime
echo $TZ_NAME > /etc/timezone
rc-update add sshd default >/dev/null 2>&1
rc-service sshd start >/dev/null 2>&1
date
"
# ==============================================================================
log "5/9 web stack (U2): nginx + php83 + extensions"
pct exec "$VMID" -- sh -c '
apk add --no-cache nginx php83 php83-fpm \
php83-session php83-zip php83-openssl php83-mbstring php83-ctype php83-curl \
php83-dom php83-json php83-iconv php83-fileinfo php83-simplexml php83-xml \
php83-tokenizer procps-ng >/dev/null 2>&1
# procps-ng (NOT lxcfs) is what makes free/uptime report the CONTAINER rather than the host.
# busybox applets use the sysinfo() syscall, which lxcfs cannot intercept; procps-ng reads the
# lxcfs-virtualised /proc/meminfo and /proc/uptime. Verified: procps free -> 1024MB, busybox -> host.
# dedicated service user (I-4: FPM user == file owner == key owner)
adduser -D -s /sbin/nologin -h /home/atheos atheos 2>/dev/null || true
mkdir -p /var/www/atheos/workspace /run/php-fpm83 /var/log/nginx
chown nginx:nginx /run/php-fpm83
chmod 755 /run/php-fpm83
chown -R atheos:atheos /var/www/atheos /home/atheos
# timezone (NFR-5)
sed -i "s|^;\?date.timezone =.*|date.timezone = Australia/Brisbane|" /etc/php83/php.ini
# --- the socket contract (I-1): pool listen MUST byte-match nginx fastcgi_pass ---
cp -n /etc/php83/php-fpm.d/www.conf /etc/php83/php-fpm.d/www.conf.orig 2>/dev/null || true
sed -i "s|^user = .*|user = atheos|" /etc/php83/php-fpm.d/www.conf
sed -i "s|^group = .*|group = atheos|" /etc/php83/php-fpm.d/www.conf
sed -i "s|^listen = .*|listen = '"$FPM_SOCKET"'|" /etc/php83/php-fpm.d/www.conf
grep -q "^listen.owner" /etc/php83/php-fpm.d/www.conf || echo "listen.owner = nginx" >> /etc/php83/php-fpm.d/www.conf
grep -q "^listen.group" /etc/php83/php-fpm.d/www.conf || echo "listen.group = nginx" >> /etc/php83/php-fpm.d/www.conf
grep -q "^listen.mode" /etc/php83/php-fpm.d/www.conf || echo "listen.mode = 0660" >> /etc/php83/php-fpm.d/www.conf
rc-update add nginx default >/dev/null 2>&1
rc-update add php-fpm83 default >/dev/null 2>&1
'
# --- nginx vhost: installed BY THE SCRIPT so provisioning is repeatable end-to-end -----------
# (review finding: leaving this as a manual "see README" step broke the project's own
# repeatability requirement FR-9 — a rebuild from scratch would serve the distro default page)
pct exec "$VMID" -- sh -c "cat > /etc/nginx/http.d/atheos.conf" <<'NGINXCONF'
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
root /var/www/atheos;
index index.php index.html;
access_log /var/log/nginx/atheos.access.log;
error_log /var/log/nginx/atheos.error.log;
# The socket path here MUST byte-match the pool's `listen =` in
# /etc/php83/php-fpm.d/www.conf (interface I-1 - a mismatch yields a bare 502).
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/run/php-fpm83/php-fpm.sock;
fastcgi_index index.php;
include fastcgi.conf;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
location / { try_files $uri $uri/ =404; }
# never serve the VCS dir, the app's private data, or dotfiles
location ~ /\.git { deny all; }
location ~ /\.ssh { deny all; }
location ~ /\. { deny all; }
location ~ ^/data/ { deny all; }
location ~ ^/(config\.php|common\.php|controller\.php|traits/) { deny all; }
location ~* \.(md|py|sh|log|conf|ini|sql|bak|orig)$ { deny all; }
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
client_max_body_size 64m;
}
NGINXCONF
# remove the distro default vhost so ours is authoritative, then start the stack
pct exec "$VMID" -- sh -c '
rm -f /etc/nginx/http.d/default.conf
rc-service php-fpm83 restart >/dev/null 2>&1
rc-service nginx restart >/dev/null 2>&1
sleep 2
# prove the socket contract holds BEFORE Atheos is installed, so a 502 can only ever
# mean an nginx/php-fpm problem and never be confused with an Atheos problem
SOCK=$(sed -n "s/^listen = //p" /etc/php83/php-fpm.d/www.conf | head -1)
NGX=$(sed -n "s/.*fastcgi_pass unix:\([^;]*\);.*/\1/p" /etc/nginx/http.d/atheos.conf | head -1)
if [ "$SOCK" = "$NGX" ]; then echo "socket contract OK: $SOCK"; else echo "SOCKET MISMATCH: pool=$SOCK nginx=$NGX"; exit 1; fi
'
# ==============================================================================
log "6/9 Atheos install at the pinned commit (U3)"
pct exec "$VMID" -- sh -c "
cd /tmp
wget -q -O atheos.tar.gz https://codeload.github.com/Atheos/Atheos/tar.gz/$ATHEOS_SHA
echo '$ATHEOS_TARBALL_SHA256 atheos.tar.gz' | sha256sum -c - || { echo 'CHECKSUM MISMATCH - abort'; exit 1; }
rm -rf /tmp/atheos-extract && mkdir -p /tmp/atheos-extract
tar xzf atheos.tar.gz -C /tmp/atheos-extract
cd /tmp/atheos-extract/Atheos-$ATHEOS_SHA
for f in \$(ls -A); do
[ \"\$f\" = workspace ] && continue
cp -a \"\$f\" $WEBROOT/ 2>/dev/null || true
done
cd $WEBROOT
touch config.php && chmod 664 config.php
for d in data workspace plugins themes; do mkdir -p \$d; chmod 775 \$d; done
chown -R atheos:atheos $WEBROOT
echo \"Atheos build: \$(cat .version)\"
"
# ==============================================================================
log "7/9 git + clone + deploy key (U4)"
pct exec "$VMID" -- sh -c "
apk add --no-cache git openssh-client >/dev/null 2>&1
cd $WEBROOT/workspace
su -s /bin/sh atheos -c 'git config --global --add safe.directory $WEBROOT/workspace'
su -s /bin/sh atheos -c 'git config --global --add safe.directory $WEBROOT/workspace/blog'
# clone over HTTPS (public repo => no credential at this stage; U4 phase 1)
su -s /bin/sh atheos -c 'test -d blog/.git || git clone $BLOG_REPO blog'
# deploy keypair: private key stays on the guest, owned by the FPM user
mkdir -p /home/atheos/.ssh && chmod 700 /home/atheos/.ssh && chown atheos:atheos /home/atheos/.ssh
su -s /bin/sh atheos -c 'test -f /home/atheos/.ssh/id_ed25519_blog || ssh-keygen -t ed25519 -f /home/atheos/.ssh/id_ed25519_blog -N \"\" -C \"atheos-blog-lxc deploy key (armistace/blog)\"'
chmod 600 /home/atheos/.ssh/id_ed25519_blog
chmod 644 /home/atheos/.ssh/id_ed25519_blog.pub
chown atheos:atheos /home/atheos/.ssh/id_ed25519_blog /home/atheos/.ssh/id_ed25519_blog.pub
"
# NB the ~/.ssh/config alias is written by the install-files step (see files/ssh-config)
# IMPORTANT: its User must be "$GITEA_SSH_USER" (gitea), NOT `git`.
pct exec "$VMID" -- sh -c "chmod 600 /home/atheos/.ssh/config 2>/dev/null; chown atheos:atheos /home/atheos/.ssh/config 2>/dev/null; true"
# ==============================================================================
log "8/9 PUBLISH the deploy key public half to the operator"
pct exec "$VMID" -- sh -c "
echo
echo ' ----- BEGIN deploy key (public half) -----'
cat /home/atheos/.ssh/id_ed25519_blog.pub
echo ' ----- END deploy key (public half) -----'
ssh-keygen -lf /home/atheos/.ssh/id_ed25519_blog.pub
"
cat <<'INSTRUCTIONS'
>>> OPERATOR ACTION: add the key above to Gitea
Repo: armistace/blog -> Settings -> Deploy Keys -> Add Deploy Key
Title: atheos-blog-lxc
TICK "Allow write access" (required for push)
Then tell the agent to run phase 2 (switch the remote to SSH):
git -C /var/www/atheos/workspace/blog remote set-url origin gitea-blog:armistace/blog.git
INSTRUCTIONS
# ==============================================================================
log "9/9 smoke test"
pct exec "$VMID" -- sh -c '
ip -4 addr show eth0 | grep inet
rc-service nginx status | head -1
rc-service php-fpm83 status | head -1
ls -lad /run/php-fpm83/php-fpm.sock
'
echo
echo "Provisioning complete. Now open http://<guest-ip>/ in a browser and complete"
echo "the Atheos first-run installer (creates the admin user - operator owned)."
echo "IMPORTANT: first action after SSH -> set your git identity. See RUNBOOK.md step 1."
+99
View File
@@ -0,0 +1,99 @@
#!/bin/sh
# ==============================================================================
# update-atheos.sh — update the Atheos install on LXC 136 and RE-PIN the commit
# Design decision D2: Atheos has no releases, so the install is pinned to a
# resolved commit SHA for reproducibility. This script is the deliberate escape
# hatch: it fetches the latest upstream commit, updates the install, and
# re-pins by rewriting the recorded SHA.
#
# RUN THIS ON THE GUEST (as root), or via: ssh root@192.168.178.57 'sh -s' < update-atheos.sh
#
# It does NOT touch your git identity, your deploy key, the blog clone, or
# anything under workspace/. Only the Atheos application files are replaced.
# ==============================================================================
set -eu
WEBROOT="/var/www/atheos"
FPM_USER="atheos"
STATE_FILE="/etc/atheos-blog-lxc.pin"
log() { printf '\n=== %s\n' "$*"; }
log "current pin"
[ -f "$STATE_FILE" ] && cat "$STATE_FILE" || echo "(no pin recorded - first run)"
echo "installed build: $(cat $WEBROOT/.version 2>/dev/null || echo unknown)"
log "resolve latest upstream commit (branch: main)"
LATEST_SHA=$(wget -qO- https://api.github.com/repos/Atheos/Atheos/commits/main \
| sed -n 's/.*"sha": *"\([a-f0-9]\{40\}\)".*/\1/p' | head -1)
if [ -z "$LATEST_SHA" ]; then
echo "ERROR: could not resolve the upstream SHA (network or rate limit). Aborting."
exit 1
fi
echo "latest: $LATEST_SHA"
CURRENT_SHA=$(sed -n 's/^SHA=//p' "$STATE_FILE" 2>/dev/null || true)
if [ "$LATEST_SHA" = "$CURRENT_SHA" ]; then
echo "Already at the latest pin ($LATEST_SHA). Nothing to do."
exit 0
fi
echo "updating: $CURRENT_SHA -> $LATEST_SHA"
log "download + extract to a staging dir (install is NOT touched until verified)"
rm -rf /tmp/atheos-update && mkdir -p /tmp/atheos-update
cd /tmp/atheos-update
wget -q -O atheos.tar.gz "https://codeload.github.com/Atheos/Atheos/tar.gz/$LATEST_SHA"
sha256sum atheos.tar.gz | sed 's/^/ tarball sha256: /'
tar xzf atheos.tar.gz
SRC="/tmp/atheos-update/Atheos-$LATEST_SHA"
test -d "$SRC" || { echo "ERROR: extract produced no $SRC"; exit 1; }
log "sanity-check the staged tree looks like Atheos"
for f in index.php common.php controller.php classes; do
test -e "$SRC/$f" || { echo "ERROR: staged tree is missing $f - refusing to install"; exit 1; }
done
echo "staged tree looks sane"
log "back up the current app files (excluding workspace/)"
BK="/root/atheos-backup-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$BK"
for f in $(ls -A "$WEBROOT"); do
[ "$f" = workspace ] && continue
cp -a "$WEBROOT/$f" "$BK/" 2>/dev/null || true
done
echo "backup: $BK"
log "install the new files (workspace/ preserved)"
cd "$SRC"
for f in $(ls -A); do
[ "$f" = workspace ] && continue
rm -rf "$WEBROOT/$f"
cp -a "$f" "$WEBROOT/" 2>/dev/null || true
done
chown -R "$FPM_USER:$FPM_USER" "$WEBROOT"
echo "new build: $(cat $WEBROOT/.version 2>/dev/null || echo unknown)"
log "re-pin"
{
echo "# atheos-blog-lxc Atheos pin - managed by scripts/update-atheos.sh"
echo "# Updated: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "SHA=$LATEST_SHA"
} > "$STATE_FILE"
cat "$STATE_FILE"
log "restart + smoke test"
rc-service php-fpm83 restart >/dev/null 2>&1 || true
rc-service nginx restart >/dev/null 2>&1 || true
sleep 2
CODE=$(wget -qO- --server-response http://127.0.0.1/ 2>&1 | sed -n 's/.*HTTP\/1\.[01] \([0-9]*\).*/\1/p' | head -1)
echo "local HTTP status: ${CODE:-unknown}"
if [ "${CODE:-000}" != "200" ]; then
echo
echo "WARNING: the app did not return 200."
echo "Roll back with: rm -rf $WEBROOT/* (except workspace/) && cp -a $BK/* $WEBROOT/"
exit 1
fi
echo
echo "Update complete and re-pinned to $LATEST_SHA."
echo "NOTE: re-run your verification (open the UI). If Atheos changed its installer/config"
echo " semantics, the web UI will tell you - this script only replaces application files."