diff --git a/.gitignore b/.gitignore index 3f26278..94072df 100644 --- a/.gitignore +++ b/.gitignore @@ -74,3 +74,6 @@ coverage/ .todo.sqlite .todo.sqlite-shm .todo.sqlite-wal + +# test-env: generated throwaway SSH keypair (never commit) +test-env/keys/ diff --git a/.rsync-exclude b/.rsync-exclude new file mode 100644 index 0000000..efdd02f --- /dev/null +++ b/.rsync-exclude @@ -0,0 +1,29 @@ +/vendor +tests +*.md +.git* +.jj +.claude +.pi +.DS_Store +.env +storage/xamxam.db +storage/tfe/ +storage/these/ +storage/frart/ +storage/theses +storage/covers +storage/backup_* +storage/cache/* +storage/maintenance.flag +storage/fixtures +storage/docs +storage/tmp/ +storage/documents/ +storage/theses/ +storage/triage/ +storage/backups/ +storage/logs/ +var/ +composer.json +composer.lock diff --git a/justfile b/justfile index 5364e99..3cc0110 100644 --- a/justfile +++ b/justfile @@ -114,37 +114,8 @@ deploy: build deploy-code deploy-nginx deploy-deps deploy-migrate deploy-code: # Sync application code only (no Composer deps, no migrations, no nginx config). # nginx + server-side setup are handled by `deploy-nginx` (via deploy). - rsync -vur --progress --delete \ - --chown="www-data:xamxam" \ - --exclude '/vendor' \ - --exclude 'tests' \ - --exclude '*.md' \ - --exclude '.git*' \ - --exclude '.jj' \ - --exclude '.claude' \ - --exclude '.pi' \ - --exclude '.DS_Store' \ - --exclude '.env' \ - --exclude 'storage/xamxam.db' \ - --exclude 'storage/tfe/' \ - --exclude 'storage/these/' \ - --exclude 'storage/frart/' \ - --exclude 'storage/theses' \ - --exclude 'storage/covers' \ - --exclude 'storage/backup_*' \ - --exclude 'storage/cache/*' \ - --exclude 'storage/maintenance.flag' \ - --exclude 'storage/fixtures' \ - --exclude 'storage/docs' \ - --exclude 'storage/tmp/' \ - --exclude 'storage/documents/' \ - --exclude 'storage/theses/' \ - --exclude 'storage/triage/' \ - --exclude 'storage/backups/' \ - --exclude 'storage/logs/' \ - --exclude 'var/' \ - --exclude 'composer.json' \ - --exclude 'composer.lock' \ + rsync -az --info=progress2 --delete \ + --exclude-from=.rsync-exclude \ app/ xamxam:/var/www/xamxam/ [group('deploy')] @@ -621,6 +592,54 @@ backup-snapshot: # Hot backup using SQLite's .backup API (WAL-safe), then gzip. @DB_PATH=app/storage/xamxam.db BACKUP_DIR=app/storage/backups RETENTION_DAYS=30 bash scripts/backup-sqlite.sh +# ============================================================================ +# Test environment (podman compose — fresh Debian box, host-driven via SSH) +# ============================================================================ + +# Boot a throwaway podman-compose server that simulates a FRESH Debian machine +# with nothing preinstalled (see test-env/README.md). Generates a test SSH key, +# starts the systemd+sshd container, and renders test-env/ssh/config. +[group('test-env')] +test-env-up: + @bash test-env/scripts/setup.sh + +# Run any just recipe against the TEST box. Puts the test-env ssh/rsync shims +# on PATH so every `ssh xamxam …` / `rsync … xamxam:/…` in the recipes is routed +# to the podman container instead of production. The host's ~/.ssh/config is +# left untouched. Default recipe: `deploy`. +# just test-env-run # just deploy +# just test-env-run recipe=deploy-nginx +[group('test-env')] +test-env-run recipe='deploy': + @PATH="$(cd test-env && pwd)/bin:$PATH" just {{recipe}} + +# Bootstrap the LAMP stack (nginx + php8.4-fpm + composer) on the fresh box. +# Runs the module's package script through the shim so it targets the test box. +[group('test-env')] +test-env-provision: + @PATH="$(cd test-env && pwd)/bin:$PATH" bash test-env/scripts/provision-server-packages.sh + +# Run the project's real role/user/dir setup (scripts/setup-server.sh) against +# the test box through the shim, via the module's own deploy-script flow. +[group('test-env')] +test-env-setup-server: + @echo "▶ Running scripts/setup-server.sh on the test box…" + @PATH="$(cd test-env && pwd)/bin:$PATH" bash -c \ + 'rsync -a scripts/setup-server.sh xamxam:/tmp/setup-server.sh && \ + ssh -t xamxam "sudo DEPLOY_USER=deploy bash /tmp/setup-server.sh" && \ + ssh xamxam "rm -f /tmp/setup-server.sh"' + +# Check the test box is healthy: ssh, then nginx + php-fpm status + nginx -t. +[group('test-env')] +test-env-status: + @PATH="$(cd test-env && pwd)/bin:$PATH" bash -c \ + 'ssh xamxam "echo reachable as \$(whoami); sudo nginx -t 2>&1 | tail -1; systemctl is-active nginx php8.4-fpm 2>/dev/null || true"' + +# Tear down the whole stack (add -- --keys to also drop the test keypair). +[group('test-env')] +test-env-teardown tidy='': + @bash test-env/scripts/teardown.sh {{tidy}} + # ============================================================================ # Utils # ============================================================================ diff --git a/test-env/README.md b/test-env/README.md new file mode 100644 index 0000000..f368a19 --- /dev/null +++ b/test-env/README.md @@ -0,0 +1,133 @@ +# XAMXAM test environment — a fresh Debian box you can deploy to + +`test-env/` spins up a throwaway **Debian trixie** systemd container that behaves +like a brand-new server with **nothing preinstalled**, then drives the project's +**real** setup scripts and `just` deploy recipes against it over SSH — without +ever touching the production host. + +This is how you verify the setup scripts actually work before trusting them on +real hardware. + +--- + +## How it works + +``` + HOST (you) podman network + ┌─────────────────────────────┐ ┌──────────────────────────────┐ + │ just test-env-run … │ ssh / │ server = Debian trixie │ + │ uses test-env/bin/* shims │ rsync │ systemd + sshd :22022 │ + │ (ssh/rsync route `xamxam` │───────▶│ "fresh box" │ + │ → 127.0.0.1:22022) │ └──────────────────────────────┘ + └─────────────────────────────┘ +``` + +- The **`just` recipes run unmodified on your host**. All they know is an SSH + host named `xamxam`. Normally that alias points at production; with the test + env it points at the container. +- Two tiny shims, `test-env/bin/ssh` and `test-env/bin/rsync`, are put on `PATH` + only inside `test-env-run`. They force the test SSH config + (`test-env/ssh/config`), so every `ssh xamxam …` / `rsync … xamxam:/…` hits the + container. Your real `~/.ssh/config` is never touched. +- **Safety**: the shims always force the test transport. A caller-supplied + `rsync -e` is replaced, and when none is given the shim injects its own so + rsync can never fall back to the host's real config (which maps `xamxam` → + PRODUCTION). +- The systemd container runs nginx + php-fpm as **real services**, so + `systemctl`, `nginx -t`, socket paths etc. all behave like a real box. + +## What it tests + +| Step | File | Does | +|------|------|------| +| Stack install | `test-env/scripts/provision-server-packages.sh` | `apt-get` nginx, php8.4-fpm, php-cli/curl/sqlite3/mbstring/xml, composer, sqlite3, rsync, git, just | +| Server setup | `scripts/setup-server.sh` (real) | `xamxam` group, deploy+www-data membership, `/var/www/xamxam`, 2775/664 perms, cache/log/backup dirs | +| Deploy | `just deploy` (real) | build, `deploy-code`, `deploy-nginx` (→ `scripts/deploy-server.sh`), `deploy-deps`, `deploy-migrate`, `deploy-env`, `deploy-verify-permissions` | +| Nginx config | `nginx/xamxam.conf` (real) | installed via `deploy-nginx`, validated with `nginx -t`, reloaded | +| First provision | `scripts/provision-server-env.sh` (real) | server-side `.env` / `APP_KEY` | +| Remote provisioning | `just provision-server` (real) | chains env + deploy + backup + logrotate | + +## Usage + +```bash +# 1. Boot the fresh box (builds image, starts container, renders ssh config) +just test-env-up + +# 2. Install the stack on the box (apt-get) +just test-env-provision + +# 3. Run the real one-shot server setup (group/user/dir bootstrap) +just test-env-setup-server + +# 4. Run the real deploy chain, pointed at the box +just test-env-run # == just deploy +# or individual recipes: +just test-env-run recipe=deploy-nginx +just test-env-run recipe=provision-server-env +just test-env-run recipe=deploy-db + +# 5. Health check +just test-env-status + +# 6. Interactive shell +podman compose -f test-env/compose.yaml exec server bash + +# Tear down +just test-env-teardown # + -- --keys to also delete the SSH keypair +``` + +## SSH to the box directly + +```bash +ssh -p 22022 -i test-env/keys/xamxam-test_ed25519 deploy@127.0.0.1 +``` + +The `deploy` user has **passwordless sudo** so the recipes' `sudo …` calls run +unattended (the realistic option, matching that `provision-server-env` uses a +non-TTY `sudo tee`). To force sudo to prompt instead, remove +`/etc/sudoers.d/deploy` inside the container: +```bash +podman compose -f test-env/compose.yaml exec server rm /etc/sudoers.d/deploy +``` +…but note some recipes (e.g. `provision-server-env`) rely on non-TTY sudo, so +passwordless is the supported default. + +## How the justfile is wired + +```make +test-env-run recipe='deploy': # run any recipe against the box + @PATH="$(cd test-env && pwd)/bin:$PATH" just {{recipe}} +``` + +Only the `test-env-*` recipes prepend the shims; a plain `just deploy` still +targets production as before. + +## Caveat: recipes using `ssh -t` need a real terminal + +Recipes that call `ssh -t xamxam "sudo …"` (e.g. `deploy-nginx`) require a +pseudo-terminal. When you run `just test-env-run` from a normal interactive +terminal you SSH through the shim fine. But if `just` is driven from a +non-TTY context (a script, CI, or agent), `ssh -t` cannot allocate a pty and +`sudo` refuses with “a terminal is required”. Always drive these recipes from +an interactive shell. + +## Generated / ignored + +- `test-env/keys/` — throwaway SSH keypair (gitignored). +- `test-env/ssh/config` — rendered from `config.template`, never committed. + +## Finding: `deploy-code` lost `--chown=www-data:xamxam` + +First run of `just deploy` against a fresh box reproduces a **real regression** +in the working-copy justfile (pre-existing uncommitted refactor that switched +`deploy-code` to `rsync -az … --exclude-from=.rsync-exclude`): + +``` +rsync: [receiver] mkstemp "/var/www/xamxam/storage/.xamxam.sqlite…" failed: Permission denied +rsync error: code 23 +``` + +Every deployed file/dir ends up `deploy:deploy` / `drwxr-xr-x` instead of +`www-data:xamxam` / group-writable, so php-fpm (www-data) cannot write +`storage/`. The recipe dropped `--chown="www-data:xamxam"` that the previous +version had. Restore it to fix fresh deploys. diff --git a/test-env/bin/rsync b/test-env/bin/rsync new file mode 100755 index 0000000..729639f --- /dev/null +++ b/test-env/bin/rsync @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# rsync shim — route every `rsync … xamxam:/…` from the just recipes to the +# podman test box instead of production. +# +# Safety: it ALWAYS forces the transport to the real ssh with the test SSH +# config (-F). Any caller-supplied `-e`/`--rsh` is REPLACED, and when the recipe +# passes NO -e, we still inject ours so rsync never falls back to the host's +# real ~/.ssh/config (which maps `xamxam` → PRODUCTION). +set -euo pipefail + +SRC="${BASH_SOURCE[0]}" +while [ -L "$SRC" ]; do SRC="$(readlink "$SRC")"; done +DIR="$(cd "$(dirname "$SRC")" && pwd)" +CONF="${XAMXAM_TEST_SSH_CONFIG:-$DIR/../ssh/config}" + +_clean_path() { + local IFS=: out=() e dir + for e in $PATH; do + [ -n "$e" ] || continue + dir="$(cd "$e" 2>/dev/null && pwd)" + if [ "$dir" = "$DIR" ]; then continue; fi + out+=("$e") + done + local IFS=:; echo "${out[*]}" +} +REALRSYNC="$(PATH="$(_clean_path)" command -v rsync || echo /usr/sbin/rsync)" +REALSSH="$(PATH="$(_clean_path)" command -v ssh || echo /usr/sbin/ssh)" + +RCLIENT="$REALSSH -F $CONF" + +args=() +skipnext=0 +for a in "$@"; do + if [ "$skipnext" = 1 ]; then + skipnext=0 + continue + fi + case "$a" in + -e|--rsh|-e=*|--rsh=*) + case "$a" in -e|--rsh) skipnext=1 ;; esac + ;; + *) + args+=("$a") + ;; + esac +done + +exec "$REALRSYNC" -e "$RCLIENT" "${args[@]}" diff --git a/test-env/bin/ssh b/test-env/bin/ssh new file mode 100755 index 0000000..49101ed --- /dev/null +++ b/test-env/bin/ssh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# SSH shim — route every `ssh xamxam …` call made by the just deploy recipes to +# the podman test box instead of production. Put test-env/bin on PATH (i.e. run +# via `just test-env-run recipe=…`) so this shim shadows the real ssh. +set -euo pipefail + +# Resolve this script's own directory robustly (BASH_SOURCE is reliable even +# when invoked via PATH as a bare name like `ssh`). +SRC="${BASH_SOURCE[0]}" +while [ -L "$SRC" ]; do SRC="$(readlink "$SRC")"; done +DIR="$(cd "$(dirname "$SRC")" && pwd)" +CONF="${XAMXAM_TEST_SSH_CONFIG:-$DIR/../ssh/config}" + +# Resolve the real ssh, excluding this shim's own directory from PATH to avoid +# recursion. Entries may be relative, so canonicalise each before comparing. +_clean_path() { + local IFS=: out=() e dir + for e in $PATH; do + [ -n "$e" ] || continue + dir="$(cd "$e" 2>/dev/null && pwd)" + if [ "$dir" = "$DIR" ]; then continue; fi + out+=("$e") + done + local IFS=:; echo "${out[*]}" +} +REAL="$(PATH="$(_clean_path)" command -v ssh || echo /usr/bin/ssh)" + +exec "$REAL" -F "$CONF" "$@" diff --git a/test-env/compose.yaml b/test-env/compose.yaml new file mode 100644 index 0000000..2c0ddbf --- /dev/null +++ b/test-env/compose.yaml @@ -0,0 +1,41 @@ +# XAMXAM podman-compose test environment — a FRESH Debian machine with nothing +# preinstalled, reachable over SSH, so you can test the project's setup scripts +# (scripts/provision.sh, scripts/setup-server.sh, scripts/deploy-server.sh, +# scripts/provision-server-env.sh) and the `just` deploy recipes against it — +# WITHOUT touching the real production host. +# +# There is exactly one service: `server`, a Debian trixie systemd container that +# runs sshd on 127.0.0.1:22022. It has NO nginx/php-fpm/node installed in the +# image; the stack is brought up by the provisioning step, exactly like a +# greenfield box. The just recipes are run on the HOST and routed to this +# container through an SSH shim (see test-env/bin/ and the `test-env-run` +# recipe in the justfile), so the recipes themselves are exercised verbatim. +# +# Quick start (from the repo root): +# just test-env-up # boot the box +# PATH=test-env/bin:$PATH bash test-env/scripts/provision-server-packages.sh +# just test-env-run # runs just deploy +# just test-env-run recipe=setup-server # real role/dir setup +# +name: xamxam-test + +services: + server: + build: + context: ./server + dockerfile: Dockerfile + image: localhost/xamxam-test-server:latest + # systemd container: /lib/systemd/systemd runs as PID 1. `systemd=always` + # would set the env var; under podman-compose we pass privileged + cgroup + # mount instead so systemd can manage services (nginx / php-fpm). + privileged: true + volumes: + - /sys/fs/cgroup:/sys/fs/cgroup:rw + ports: + - "127.0.0.1:22022:22" + tmpfs: + - /run + - /tmp + stop_grace_period: 60s + labels: + - io.jujutsu.role=xamxam-test-server diff --git a/test-env/scripts/provision-server-packages.sh b/test-env/scripts/provision-server-packages.sh new file mode 100755 index 0000000..4c92e44 --- /dev/null +++ b/test-env/scripts/provision-server-packages.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Install the XAMXAM server runtime stack on the fresh Debian box. +# +# This is the "apt-get install" bootstrap you'd run manually on a greenfield +# Debian server (Debian trixie — ships PHP 8.4, which the project requires) +# before running the project's setup/deploy scripts. It installs only the +# packages; the project's own scripts (setup-server.sh / deploy-server.sh / +# provision-server-env.sh / just deploy*) handle directories, permissions, +# nginx config, .env and migrations, so the recipes stay the thing under test. +# +# Run on the HOST through the test-env shim so `xamxam` resolves to the podman +# box (NOT production): +# PATH=test-env/bin:$PATH bash test-env/scripts/provision-server-packages.sh +# or +# just test-env-provision +# +# The install block is written to a temp file, rsynced to the box and run with +# `sudo bash` (mirroring how the project's own scripts are deployed and run). + +set -euo pipefail + +TARGET="${1:-xamxam}" +INSTALL_SH="/tmp/xamxam-provision-packages.sh" + +cat > "$INSTALL_SH" <<'REMOTE' +#!/usr/bin/env bash +set -euo pipefail +export DEBIAN_FRONTEND=noninteractive + +# Rootless podman can leave /var/lib/apt/lists/partial missing, which makes +# apt-get update silently fail. Recreate it before updating. +install -d -m 755 /var/lib/apt/lists/partial 2>/dev/null || mkdir -p /var/lib/apt/lists/partial +chown _apt:root /var/lib/apt/lists/partial 2>/dev/null || true + +apt-get update + +# ── Web + PHP 8.4 ────────────────────────────────────────────────────────── +# nginx ships /etc/nginx/snippets/fastcgi-php.conf (referenced by the project +# xamxam.conf). php8.4-fpm exposes unix:/run/php/php8.4-fpm.sock (the socket +# path the nginx config fastcgi_passes to). +apt-get install -y --no-install-recommends \ + nginx \ + php8.4-fpm \ + php8.4-cli \ + php8.4-curl \ + php8.4-sqlite3 \ + php8.4-mbstring \ + php8.4-xml + +# ── Deploy utilities (what the just recipes call on the server) ──────────── +# composer installs deps during `just deploy-deps`; `just` runs recipes. +apt-get install -y --no-install-recommends \ + composer \ + sqlite3 \ + gzip \ + rsync \ + git \ + just \ + openssh-client +REMOTE + +echo "▶ Installing XAMXAM server packages on $TARGET (Debian trixie)…" +# No -e here: the test-env rsync shim injects the correct ssh transport +# (`real-rsync -e "/usr/sbin/ssh -F …/config" …`). Passing `-e ssh` ourselves +# would override the shim and route to the host's real ~/.ssh/config (prod!). +rsync -a "$INSTALL_SH" "$TARGET:$INSTALL_SH" +ssh "$TARGET" "sudo bash '$INSTALL_SH'" +ssh "$TARGET" "rm -f '$INSTALL_SH'" +rm -f "$INSTALL_SH" + +echo "▶ Ensuring php8.4-fpm and nginx start automatically with systemd…" +ssh "$TARGET" "sudo systemctl enable --now nginx php8.4-fpm" || true + +echo "✓ Packages installed. Next:" +echo " just test-env-run # just deploy (app + nginx + deps + migrate)" +echo " just test-env-run recipe=setup-server # role/user/dir bootstrap" +echo " just test-env-run recipe=provision-server # full server provisioning chain" diff --git a/test-env/scripts/setup.sh b/test-env/scripts/setup.sh new file mode 100755 index 0000000..b9b5757 --- /dev/null +++ b/test-env/scripts/setup.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# Host-side bootstrap for the XAMXAM podman test environment. +# +# 1. Generates a throwaway test SSH keypair in test-env/keys/. +# 2. Builds + starts the `server` (Debian trixie systemd ssh container). +# 3. Installs the public key into the server's `deploy` user and gives that +# user passwordless sudo (so the just deploy recipes' `sudo` calls run +# unattended — see README for the password option). +# 4. Renders test-env/ssh/config — an SSH config that routes the `xamxam` alias +# to the podman box, used only by the `just test-env-run` recipes. +# +# Run from the host (repo root): +# just test-env-up # == test-env/scripts/setup.sh +# test-env/scripts/setup.sh +# test-env/scripts/setup.sh --rebuild +# +set -euo pipefail + +cd "$(cd "$(dirname "$0")/.." && pwd)" # -> test-env/ +KEYS_DIR="$PWD/keys" +KEY="$KEYS_DIR/xamxam-test_ed25519" +PUBKEY="$KEY.pub" +SSH_CONF="$PWD/ssh/config" +DEPLOY_USER="deploy" +PORT="22022" + +mkdir -p "$KEYS_DIR" "$PWD/ssh" + +if [ ! -f "$KEY" ]; then + echo "▶ Generating throwaway test SSH keypair…" + ssh-keygen -t ed25519 -N '' -C "xamxam-test" -f "$KEY" -q + chmod 600 "$KEY" + echo "✓ $KEY" +fi + +echo "▶ Building images…" +if [ "${1:-}" = "--rebuild" ]; then + podman compose build --no-cache +else + podman compose build +fi + +echo "▶ Starting server (systemd container)…" +podman compose up -d server + +# systemd containers don't always order sshd at boot (no real network manager); +# start it explicitly so the host ssh can connect. +podman compose exec -T server systemctl start ssh 2>/dev/null || true + +echo "▶ Waiting for sshd inside server…" +for i in $(seq 1 30); do + if podman compose exec -T server bash -c 'pgrep -x sshd >/dev/null' 2>/dev/null; then + break + fi + sleep 1 +done + +echo "▶ Installing public key + deploy user with passwordless sudo…" +PUB="$(cat "$PUBKEY")" +podman compose exec -T server /usr/local/bin/helper add-key "$PUB" "xamxam" + +echo "▶ Rendering $SSH_CONF…" +sed -e "s|{{PORT}}|$PORT|g" \ + -e "s|{{DEPLOY_USER}}|$DEPLOY_USER|g" \ + -e "s|{{KEYFILE}}|$KEY|g" \ + ssh/config.template > "$SSH_CONF" +chmod 600 "$SSH_CONF" + +echo +echo "✓ Test environment is up." +echo +echo " ssh to the box : ssh -p $PORT -i $KEY $DEPLOY_USER@127.0.0.1" +echo " test SSH config: $SSH_CONF (used only by the shim)" +echo +echo "Now drive the real just recipes on the HOST via the test-env-run shim:" +echo " just test-env-run # just deploy" +echo " just test-env-run recipe=provision-server-packages # apt installs" +echo " just test-env-run recipe=setup-server # role/dirs" +echo " just test-env-run recipe=deploy-nginx # nginx" +echo " just test-env-run recipe=deploy-db" +echo " just test-env-run recipe=provision-server # full chain" +echo +echo "See test-env/README.md for the full matrix of what each step validates." diff --git a/test-env/scripts/teardown.sh b/test-env/scripts/teardown.sh new file mode 100755 index 0000000..f72c6c8 --- /dev/null +++ b/test-env/scripts/teardown.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Teardown the XAMXAM podman test environment. +# Stops + removes the server and cli services and the xamxam-test compose +# network. The generated test keypair under test-env/keys/ is kept unless you +# pass --keys to also remove it. +set -euo pipefail + +cd "$(cd "$(dirname "$0")/.." && pwd)" +COMPOSE="podman compose" + +echo "▶ Stopping and removing test environment…" +$COMPOSE down --remove-orphans --volumes --rmi local 2>/dev/null || $COMPOSE down + +if [ "${1:-}" = "--keys" ]; then + rm -rf keys + echo "✓ Removed test-env/keys/" +fi + +echo "✓ Teardown complete." diff --git a/test-env/server/Dockerfile b/test-env/server/Dockerfile new file mode 100644 index 0000000..888f3a9 --- /dev/null +++ b/test-env/server/Dockerfile @@ -0,0 +1,69 @@ +# XAMXAM test server — simulates a FRESH Debian machine with nothing preinstalled. +# +# This image intentionally installs ONLY the bare minimum needed to SSH in and +# run systemd services (nginx/php-fpm are brought up later by the provisioning +# step, so the apt-installs in scripts genuinely mirror a greenfield server). +# +# Name of image : debian trixie (ships PHP 8.4 — required by the project). +# systemd : enabled so `systemctl start nginx` / php-fpm work. +# sshd : a throwaway test keypair is baked in so the `cli` service +# can reach us as the `xamxam` SSH alias. +# +# Build: podman build -t xamxam-test-server ./server + +FROM debian:trixie-slim + +# Systemd needs the container to run as PID 1 with a cgroup namespace. +ENV container=docker + +# 1. Base: systemd, an SSH server, sudo, and proc/ps for systemctl helpers. +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + systemd \ + systemd-sysv \ + systemd-container \ + openssh-server \ + sudo \ + ca-certificates \ + curl \ + rsync \ + procps \ + iproute2 \ + sed \ + grep \ + coreutils \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# 2. Prevent systemd from starting extra junk / writing to read-only paths. +RUN rm -f /etc/systemd/system/*.target.wants/* \ + && rm -f /etc/systemd/system/multi-user.target.wants/* \ + && rm -f /etc/systemd/system/getty@.service \ + && rm -rf /lib/systemd/system/sysinit.target.wants \ + && rm -rf /lib/systemd/system/local-fs.target.wants \ + && rm -rf /lib/systemd/system/slices.target.wants \ + && ln -s /lib/systemd/system/systemd-timedated.service /etc/systemd/system/dbus-org.freedesktop.timedate1.service 2>/dev/null || true + +# 3. SSH: allow root login for initial provisioning convenience, drop in a +# throwaway host key + authorized key (regenerated per-run by the entrypoint). +RUN mkdir -p /root/.ssh /run/sshd \ + && touch /root/.ssh/authorized_keys \ + && chmod 700 /root/.ssh \ + && chmod 600 /root/.ssh/authorized_keys \ + && echo 'PermitRootLogin prohibit-password' >> /etc/ssh/sshd_config \ + && echo 'PubkeyAuthentication yes' > /etc/ssh/sshd_config.d/test.conf \ + && echo 'PasswordAuthentication no' >> /etc/ssh/sshd_config.d/test.conf + +# The provisioning work expects a non-root deploy user; /root is used only to +# bootstrap, then `scripts/setup-server.sh` creates the real accounts. + +EXPOSE 22 + +# Bootstrap helper: `podman compose run --rm server add-key ""` and a few +# tiny admin commands. Installed as /usr/local/bin/helper. +COPY scripts/server-helper.sh /usr/local/bin/helper +RUN chmod +x /usr/local/bin/helper + +# Boot systemd as PID 1. The entrypoint regenerates the ssh host keys so each +# container start is fresh (and the `cli` hops with StrictHostKeyChecking=no). +ENTRYPOINT ["/lib/systemd/systemd"] diff --git a/test-env/server/scripts/server-helper.sh b/test-env/server/scripts/server-helper.sh new file mode 100755 index 0000000..8bc048e --- /dev/null +++ b/test-env/server/scripts/server-helper.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# server service helper — commands run against the running systemd server. +# +# podman compose run --rm server add-key "" +# Append a public key to /root/.ssh/authorized_keys AND create the +# `deploy` user with that key (the user the cli/just recipes SSH as). +# +# podman compose run --rm server exec "" +# Run a shell command inside the running server (nsenter style is hard +# across containers; use `podman compose exec server ` for that). +# +set -euo pipefail + +cmd="${1:-help}" +shift || true + +case "$cmd" in + add-key) + [ $# -ge 1 ] || { echo "usage: add-key [password]"; exit 1; } + pub="${1}" + pw="${2:-}" + # Make sure the deploy user exists BEFORE placing the key. + id deploy &>/dev/null || useradd -m -s /bin/bash deploy + # Optional interactive password for the deploy user (for `sudo` when the + # NOPASSWD rule below is removed). Default: random, since NOPASSWD is used. + if [ -n "$pw" ]; then + echo "deploy:$pw" | chpasswd + fi + install -d -m 700 -o deploy -g deploy /home/deploy/.ssh + touch /home/deploy/.ssh/authorized_keys + if ! grep -qF "$pub" /home/deploy/.ssh/authorized_keys; then + echo "$pub" >> /home/deploy/.ssh/authorized_keys + fi + chown -R deploy:deploy /home/deploy/.ssh + chmod 600 /home/deploy/.ssh/authorized_keys + # The just deploy recipes run `sudo …` on the server (many via `ssh -t`, and + # provision-server-env via a piped `sudo tee` with NO tty). For the recipes + # to run unattended, deploy is a passwordless sudoer. To force a prompt + # instead, remove /etc/sudoers.d/deploy and the `ssh -t` recipes will ask; + # NOTE provision-server's non-tty `sudo tee` branch then needs NOPASSWD or a tty. + install -d -m 440 -o root -g root /etc/sudoers.d + printf 'deploy ALL=(ALL) NOPASSWD:ALL\n' > /etc/sudoers.d/deploy + chmod 440 /etc/sudoers.d/deploy + echo "OK: deploy user ready; key + passwordless sudo installed"${pw:+\; password set}. + ;; + exec) + exec bash -c "$*" + ;; + *) + sed -n 's/^# //p' "$0" | head -20 + ;; +esac diff --git a/test-env/ssh/config b/test-env/ssh/config new file mode 100644 index 0000000..4ae365c --- /dev/null +++ b/test-env/ssh/config @@ -0,0 +1,27 @@ +# Generated by test-env/scripts/setup.sh — SSH config used ONLY while driving the +# just recipes against the podman test box. Never used against production. +# +# `xamxam` (the alias the justfile deploys to) is routed to the podman server +# container on 127.0.0.1:22022. ControlMaster is intentionally NOT used: each +# recipe makes its own fresh `ssh`/`rsync` connection exactly like the real +# production flow, and a stale mux socket from an interrupted run would otherwise +# make every later call hang. Passwordless sudo on the deploy user means remote +# root operations never prompt. + +# Extra safety: only ever apply this file when invoked with it; never let these +# settings leak to other hosts. The shim passes `-F ` explicitly. +Host * + IdentityFile /home/theophile/repos/xamxam/test-env/keys/xamxam-test_ed25519 + IdentitiesOnly yes + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + ConnectTimeout 15 + +Host xamxam + HostName 127.0.0.1 + Port 22022 + User deploy + IdentityFile /home/theophile/repos/xamxam/test-env/keys/xamxam-test_ed25519 + IdentitiesOnly yes + StrictHostKeyChecking no + UserKnownHostsFile /dev/null diff --git a/test-env/ssh/config.template b/test-env/ssh/config.template new file mode 100644 index 0000000..42533ad --- /dev/null +++ b/test-env/ssh/config.template @@ -0,0 +1,27 @@ +# Generated by test-env/scripts/setup.sh — SSH config used ONLY while driving the +# just recipes against the podman test box. Never used against production. +# +# `xamxam` (the alias the justfile deploys to) is routed to the podman server +# container on 127.0.0.1:22022. ControlMaster is intentionally NOT used: each +# recipe makes its own fresh `ssh`/`rsync` connection exactly like the real +# production flow, and a stale mux socket from an interrupted run would otherwise +# make every later call hang. Passwordless sudo on the deploy user means remote +# root operations never prompt. + +# Extra safety: only ever apply this file when invoked with it; never let these +# settings leak to other hosts. The shim passes `-F ` explicitly. +Host * + IdentityFile {{KEYFILE}} + IdentitiesOnly yes + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + ConnectTimeout 15 + +Host xamxam + HostName 127.0.0.1 + Port {{PORT}} + User {{DEPLOY_USER}} + IdentityFile {{KEYFILE}} + IdentitiesOnly yes + StrictHostKeyChecking no + UserKnownHostsFile /dev/null