Files
atheos-blog-lxc/scripts/provision.sh
T
hermes 8f07c377c0 fix: address independent review - provision the vhost, fix host-vs-container reporting
Addresses the two valid items from the independent review (code/security/infra all
Approved; these were Minor/Needs-Change items).

1. provision.sh left the nginx vhost as a manual "see README" step, which broke the
   project's own repeatability requirement (FR-9): a rebuild from scratch would have
   served the distro default page instead of Atheos. The vhost is now written by the
   script itself, the distro default.conf is removed, and the script ASSERTS the socket
   contract (nginx fastcgi_pass == php-fpm pool listen) before Atheos is installed - so
   a 502 can only ever mean an nginx/php-fpm problem, never be mistaken for an Atheos one.

2. `free` and `uptime` reported the PVE HOST inside the container (a container that had
   restarted seconds earlier claimed "up 2 days"), which is misleading in monitoring and
   in the test output.

   The review suggested `apk add lxcfs` in the guest. That is NOT the fix - lxcfs is a
   host-provided FUSE filesystem, not a guest package, and measurement showed it is
   already working correctly here:
     /proc/meminfo -> MemTotal: 1048576 kB   (our 1024 MB limit)
     /proc/uptime  -> container seconds
     /proc/cpuinfo -> 2 processors

   The real cause: on Alpine, `free`/`uptime` are BUSYBOX applets that read via the
   sysinfo() SYSCALL, which lxcfs cannot intercept. procps-ng ships binaries that read
   the lxcfs-virtualised /proc files instead. Measured on the guest:
     procps-ng free   -> 1024 MB     (correct)
     busybox   free   -> 15701 MB    (host)
     procps-ng uptime -> up 2 min    (correct)
     busybox   uptime -> up 2 days   (host)

   So provision.sh now installs procps-ng, and build-and-test.sh reports container-
   accurate figures (detecting it via apk rather than a binary-existence probe, since
   the busybox applets are plain files too).

Verified after the change: build-and-test 40 passed / 0 failed; socket contract MATCH;
resource line now reads "18MB used of 1024MB, up 2 min".
2026-09-29 15:49:21 +10:00

247 lines
11 KiB
Bash

#!/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."