Static-site Caddies: shared AgentReady site block, serve redeploy, verify

static_site_caddy.sh is the one site-block body for the personal website
and kcd2 dice Caddies: prerendered paths, real 404s and the ai-catalog Link
header instead of an SPA catch-all. Both setup scripts gain a sudo-free
`serve` that recreates only their container; kcd2's readiness wait needs a
200. redeploy_agent_ready_sites.sh serves both, restarts the popes site and
runs the AgentReady checker against all three public URLs. Tested by
test_static_site_serve.sh, test_redeploy_agent_ready_sites.sh and the
caddy:2.8 e2e_agent_ready_caddy.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBScELfMUiieK9ManFbS3Y
This commit is contained in:
Krzysztof Rudnicki 2026-10-22 21:23:59 +02:00
parent 417fbed3e3
commit 2a76d05f9d
12 changed files with 656 additions and 30 deletions

View File

@ -0,0 +1,15 @@
{
"title": "Static-site Caddies serve AgentReady-compliant prerendered sites, with a narrow redeploy + verify",
"objective": "Both loopback static-site Caddies (personal website, kcd2 dice solver) render one shared site-block body that serves the prerendered dist/ with real 404s and the ai-catalog Link header, gain a sudo-free `serve` redeploy, and redeploy_agent_ready_sites.sh redeploys and verifies the three public static sites with the AgentReady checker.",
"acceptance_criteria": [
"e2e_agent_ready_caddy.sh website and dice: every assert passes and agent_ready url mode exits 0 against the rendered Caddyfile",
"Unknown paths return 404 with the site's 404 page, not the SPA shell with 200",
"`serve` refuses a missing dist/ before docker runs and recreates only that site's container",
"redeploy_agent_ready_sites.sh --verify-only prints one PASS/FAIL/UNDETERMINED line per site and exits 0 only when all pass"
],
"out_of_scope": [
"Site content and build (personal-website, kcd2-dice-solver, signal-bot repos)",
"The shared edge Caddy, sudo/pacman steps and full setup rebuilds"
],
"verifier": "linux_configuration/features/lib/tests/test_static_site_serve.sh, test_redeploy_agent_ready_sites.sh, e2e_agent_ready_caddy.sh website|dice, shellcheck, pre-commit"
}

View File

@ -0,0 +1,45 @@
{
"intent": "The personal website and kcd2 dice solver static Caddies serve their prerendered dist/ so both sites pass the AgentReady v2.0 checker (real 404s, discovery files, ai-catalog Link header), and a narrow sudo-free redeploy + verify exists for the three public static sites.",
"scope": [
"linux_configuration/features/lib/static_site_caddy.sh (new shared site-block body), website_build.sh, kcd2_serve.sh, kcd2_expose.sh",
"linux_configuration/features/setup_personal_website.sh and setup_kcd2_dice_solver.sh (new `serve` subcommand), redeploy_agent_ready_sites.sh (new)",
"linux_configuration/features/lib/tests: test_static_site_serve.sh, test_redeploy_agent_ready_sites.sh, e2e_agent_ready_caddy.sh",
"Non-goal: no edge Caddy, sudo, pacman or rebuild changes; site content lives in each site's own repo"
],
"changes": [
"static_site_caddy.sh: one Caddy site-block body for both static sites (prerendered paths, real 404 via handle_errors, ai-catalog Link header) plus a dist/ presence guard; website_build.sh and kcd2_serve.sh render it instead of two drifting SPA catch-alls",
"kcd2_serve.sh readiness wait requires HTTP 200 with a body, so the non-empty 404 page no longer counts as ready",
"setup_* `serve`: re-render the inner compose + Caddyfile and recreate only that site's container",
"redeploy_agent_ready_sites.sh: serve both sites, restart the popes-site user unit, then run agent_ready url against each public URL (--verify-only skips the redeploy)"
],
"verification": [
{
"command": "bash linux_configuration/features/lib/tests/test_static_site_serve.sh && bash linux_configuration/features/lib/tests/test_redeploy_agent_ready_sites.sh",
"result": "pass",
"evidence": "static-site serve: 26 passed, 0 failed; redeploy agent-ready sites: 15 passed, 0 failed"
},
{
"command": "bash linux_configuration/features/lib/tests/e2e_agent_ready_caddy.sh website && bash linux_configuration/features/lib/tests/e2e_agent_ready_caddy.sh dice",
"result": "pass",
"evidence": "e2e agent-ready website: 16 passed, 0 failed; e2e agent-ready dice: 16 passed, 0 failed (real caddy:2.8 container serving a copy of each dist/)"
},
{
"command": "bash linux_configuration/features/redeploy_agent_ready_sites.sh --verify-only",
"result": "pass",
"evidence": "PASS kuhy.duckdns.org, dice.kuhy.duckdns.org, popes.kuhy.duckdns.org (rc=0); an earlier run hit the 10 s per-site CHECK_TIMEOUT on all three while the checker alone took 2-22 s"
},
{
"command": "shellcheck -x on all 10 changed/new scripts; pre-commit run on the staged files",
"result": "pass",
"evidence": "shellcheck clean; pre-commit hooks pass"
}
],
"risks": [
"CHECK_TIMEOUT=10 in redeploy_agent_ready_sites.sh is tight: live checks took up to 22 s, which reports FAIL (timed out) on a healthy site",
"A `serve` with no dist/ is refused before docker runs; a stale dist/ is still served as-is"
],
"rollback": [
"git revert the commit, then run setup_personal_website.sh / setup_kcd2_dice_solver.sh to re-render the old Caddyfiles",
"curl both sites and check the landing page returns 200"
]
}

View File

@ -10,16 +10,18 @@
# :80 or :443 from every other site on the box. Gate on it rather than trust
# the config to stay correct.
assert_no_edge_conflict() {
local pid listeners
pid="$(docker inspect -f '{{.State.Pid}}' "$DICE_CONTAINER" 2>/dev/null || echo "")"
[[ -n $pid && $pid != "0" ]] || die "Could not read the ${DICE_CONTAINER} PID."
listeners="$(sudo -n ss -ltnp 2>/dev/null | grep "pid=${pid}," || true)"
local listeners
# netstat run INSIDE the container: it shares the host's network namespace
# but has its own pid namespace, so the only sockets it can attribute to a
# process ("<pid>/caddy") are the container's own. That is the same
# per-container listener table `sudo ss -ltnp | grep pid=` gave, without sudo.
listeners="$(docker exec "$DICE_CONTAINER" netstat -ltnp 2>/dev/null |
grep -E '[[:space:]][0-9]+/[^[:space:]]+[[:space:]]*$' || true)"
# Demand positive evidence FIRST. Asserting only the absence of :80/:443
# fails open: if `ss` returns nothing — a stale PID after a restart, or
# sudo -n unavailable — every "is it bad?" test misses and the guard happily
# reports success without having looked at anything.
# fails open: if netstat returns nothing — a container that already exited,
# or an image without busybox — every "is it bad?" test misses and the
# guard happily reports success without having looked at anything.
if ! grep -qE '(^|[[:space:]])127\.0\.0\.1:'"${DICE_PORT}"'([[:space:]]|$)' <<<"$listeners"; then
docker stop "$DICE_CONTAINER" >/dev/null 2>&1 || true
die "Could not confirm ${DICE_CONTAINER} listens on 127.0.0.1:${DICE_PORT}. Container stopped."
@ -30,7 +32,7 @@ assert_no_edge_conflict() {
fi
# Must be on loopback, not 0.0.0.0/*: host networking would otherwise expose
# the unfronted static server to the whole LAN.
if grep -qE '(\*|0\.0\.0\.0):'"${DICE_PORT}"'\b' <<<"$listeners"; then
if grep -qE '(\*|0\.0\.0\.0|::):'"${DICE_PORT}"'\b' <<<"$listeners"; then
docker stop "$DICE_CONTAINER" >/dev/null 2>&1 || true
die "${DICE_CONTAINER} bound ${DICE_PORT} on all interfaces, not loopback. Container stopped."
fi
@ -39,7 +41,9 @@ assert_no_edge_conflict() {
start_site() {
log_info "Starting the ${DICE_CONTAINER} container…"
docker compose -f "$DICE_COMPOSE" up -d
# --force-recreate: the Caddyfile is a bind mount compose does not track,
# and the inner caddy runs with `admin off`, so only a recreate loads it.
docker compose -f "$DICE_COMPOSE" up -d --force-recreate
if wait_for_site; then
log_ok "Solver answering on http://127.0.0.1:${DICE_PORT}/."
else

View File

@ -5,6 +5,9 @@
# Sourced rather than run, so it inherits the caller's strict mode and
# the helper functions and variables defined above the source line.
# shellcheck source=static_site_caddy.sh
source "${BASH_SOURCE[0]%/*}/static_site_caddy.sh"
# --- Phase 3: the static-serve stack ----------------------------------------
write_serve_stack() {
ensure_dir "$DICE_DATA_DIR"
@ -63,11 +66,7 @@ EOF
:${DICE_PORT} {
bind 127.0.0.1
root * /srv
# Single-page bundle: unknown paths should serve the app, not 404.
try_files {path} /index.html
file_server
encode gzip
$(static_site_caddy_body)
}
EOF
log_ok "Wrote ${DICE_COMPOSE} and ${DICE_INNER_CADDY}."
@ -89,12 +88,14 @@ EOF
# non-empty body. A bare "curl http://127.0.0.1:PORT/" sends Host: 127.0.0.1
# and returns an empty 200 against a host-matcher misconfiguration — it passes
# while every real request serves a blank page. Ask the question the edge asks.
# The status matters as much as the body: the handle_errors 404 page has a body
# too, so a non-empty 404 (an empty or stale /srv) must not count as ready.
wait_for_site() {
local _ size
local _ code size
for _ in $(seq 1 30); do
size="$(curl -s -o /dev/null -w '%{size_download}' \
-H "Host: ${DICE_DOMAIN}" "http://127.0.0.1:${DICE_PORT}/" 2>/dev/null || echo 0)"
if [[ ${size:-0} -gt 0 ]]; then
read -r code size < <(curl -s -o /dev/null -w '%{http_code} %{size_download}\n' \
-H "Host: ${DICE_DOMAIN}" "http://127.0.0.1:${DICE_PORT}/" 2>/dev/null || echo "000 0")
if [[ $code == "200" && ${size:-0} -gt 0 ]]; then
return 0
fi
sleep 1

View File

@ -0,0 +1,66 @@
#!/bin/bash
# Shared site-block body for the loopback static-site Caddies.
#
# Sourced by website_build.sh and kcd2_serve.sh, which each wrap this in their
# own global options and site address. One copy, because both serve a
# prerendered dist/ that must pass AgentReady (~/src/utils/agent_ready) and the
# two blocks drifting apart is how one of them grew an SPA catch-all.
#
# Check end to end with tests/e2e_agent_ready_caddy.sh website|dice.
# `serve` guard: refuse before docker sees the bind-mount source. A missing
# dist/ is not an error to compose -- docker creates it as an empty root-owned
# directory inside the repo and the site 404s while "running".
static_site_require_dist() {
[[ -f "$1/index.html" ]] || die "No build at $1 — run setup first."
}
# Prints the directives that go inside the ":<port> { ... }" block.
static_site_caddy_body() {
cat <<'EOF'
root * /srv
encode gzip
# AgentReady FIND-04: advertise the AI catalog in an HTTP header as well
# as in the page head.
header Link "</.well-known/ai-catalog.json>; rel=\"ai-catalog\"; type=\"application/ai-catalog+json\""
# Exact types the agent spec asks for. Go's defaults are text/xml and
# application/json, and a .md would otherwise be guessed per platform.
# The "file" matcher limits each to files that exist, so a missing
# /x.md 404s as the HTML 404 page rather than as markdown.
@md {
path *.md
file
}
header @md Content-Type "text/markdown; charset=utf-8"
@txt {
path /robots.txt /llms.txt
file
}
header @txt Content-Type "text/plain; charset=utf-8"
@sitemap {
path /sitemap.xml
file
}
header @sitemap Content-Type "application/xml"
@catalog {
path /.well-known/ai-catalog.json
file
}
header @catalog Content-Type "application/ai-catalog+json"
# {path}/index.html is the load-bearing middle term: every route is
# prerendered to dist/<route>/index.html (blog posts, /how-it-works/),
# and try_files does NOT resolve a directory to its index on its own.
# Without it those pages 404 instead of serving their own HTML.
#
# There is deliberately no /index.html catch-all after it: that SPA
# fallback answered every unknown path, robots.txt typo or probe with
# "200 text/html", which agents read as a soft 404.
try_files {path} {path}/index.html
file_server
# The prerendered 404 page, keeping the 404 status.
handle_errors 404 {
rewrite * /404.html
file_server
}
EOF
}

View File

@ -0,0 +1,156 @@
#!/usr/bin/env bash
# End-to-end AgentReady check of the inner Caddyfile a static-site lib renders.
#
# linux_configuration/features/lib/tests/e2e_agent_ready_caddy.sh website|dice
#
# Deliberately NOT named test_*.sh: run_all.sh runs those inside the namespace
# jail, which has no docker. This one runs on the host and needs docker plus
# ~/src/utils/agent_ready.
#
# It renders the inner Caddyfile with the real lib function into a temp dir,
# serves a COPY of the site's built dist/ (dotfiles included, as the live bind
# mount does) from a throwaway caddy:2.8 container -- the image production pins
# -- on a spare loopback port, then runs the checker in url mode plus exact
# asserts the checker is too lenient to make (it also accepts text/xml,
# application/json and text/plain where the spec here demands more).
#
# Host mapping: robots.txt and the sitemap carry absolute PUBLIC URLs, and the
# checker treats any other host as off-site. So the checker is pointed at
# http://<public host> with http_proxy aimed at the container: urllib then sends
# every request to 127.0.0.1:<port> carrying the public Host header. A canary
# run BEFORE the container starts must exit 3 (connection refused); output from
# it would mean the checker reached the live site and proves nothing.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./features_harness.sh
. "$SCRIPT_DIR/features_harness.sh"
readonly IMAGE="caddy:2.8"
readonly CHECKER="${HOME}/src/utils/agent_ready/check.sh"
CONTAINER=""
cleanup() {
if [[ -n $CONTAINER ]]; then
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
fi
_t_teardown
}
# Stand-ins for the common.sh helpers the libs inherit from their entry script.
log_ok() { :; }
log_info() { :; }
ensure_dir() { mkdir -p "$1"; }
# Globals each lib takes from above its entry script's source line.
render_website() {
WEBSITE_SRC="$TEST_TMPDIR/src"
WEBSITE_PORT="$PORT"
WEBSITE_DATA_DIR="$TEST_TMPDIR/serve"
WEBSITE_COMPOSE="$WEBSITE_DATA_DIR/docker-compose.yml"
WEBSITE_INNER_CADDY="$CADDYFILE_OUT"
# shellcheck source=../website_build.sh
. "$FEATURES_LIB_DIR/website_build.sh"
write_website_stack
}
render_dice() {
DICE_DOMAIN="$HOST"
DICE_PORT="$PORT"
DICE_SRC="$TEST_TMPDIR/src"
DICE_DATA_DIR="$TEST_TMPDIR/serve"
DICE_COMPOSE="$DICE_DATA_DIR/docker-compose.yml"
DICE_INNER_CADDY="$CADDYFILE_OUT"
DICE_CONTAINER="kcd2-dice"
CADDY_CONTAINER="gitea-caddy"
# shellcheck source=../kcd2_serve.sh
. "$FEATURES_LIB_DIR/kcd2_serve.sh"
write_serve_stack
}
run_checker() {
HTTP_PROXY="http://127.0.0.1:${PORT}" http_proxy="http://127.0.0.1:${PORT}" \
NO_PROXY="" no_proxy="" "$CHECKER" url "http://${HOST}" --budget 40
}
# "<status> <content-type>" for one path, body kept in $TEST_TMPDIR/body.
probe() {
curl -s -o "$TEST_TMPDIR/body" -D "$TEST_TMPDIR/headers" -w '%{http_code} %{content_type}' \
--max-time 5 -H "Host: ${HOST}" "http://127.0.0.1:${PORT}$1"
}
case "${1:-}" in
website)
HOST="kuhy.duckdns.org" PORT=18088 SRC_DIST="${HOME}/src/personal-website/dist"
PAGE="/blog/hello-world" PAGE_FILE="blog/hello-world/index.html" MIRROR="/blog/hello-world.md"
;;
dice)
HOST="dice.kuhy.duckdns.org" PORT=18089 SRC_DIST="${HOME}/src/kcd2-dice-solver/dist"
PAGE="/how-it-works/" PAGE_FILE="how-it-works/index.html" MIRROR="/how-it-works/index.md"
;;
*)
printf 'usage: %s website|dice\n' "$0" >&2
exit 2
;;
esac
_t_setup_env
trap cleanup EXIT
CADDYFILE_OUT="$TEST_TMPDIR/Caddyfile"
mkdir -p "$TEST_TMPDIR/dist"
cp -a "$SRC_DIST/." "$TEST_TMPDIR/dist"
"render_$1"
printf '== rendered site block\n'
sed -n "/^:${PORT} {/,/^}/p" "$CADDYFILE_OUT"
printf '== canary (no container yet): checker must exit 3\n'
canary=0
run_checker >/dev/null 2>&1 || canary=$?
_t_eq 3 "$canary" "checker cannot reach the live site through the proxy mapping"
if docker run --rm -v "$CADDYFILE_OUT:/etc/caddy/Caddyfile:ro" "$IMAGE" \
caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile >/dev/null 2>&1; then
_t_pass "caddy validate"
else
_t_fail "caddy validate"
fi
CONTAINER="agent-ready-e2e-$1-$$"
docker run -d --name "$CONTAINER" --network host \
-v "$TEST_TMPDIR/dist:/srv:ro" -v "$CADDYFILE_OUT:/etc/caddy/Caddyfile:ro" \
"$IMAGE" >/dev/null
for _ in $(seq 1 30); do
[[ "$(probe / || true)" == 200* ]] && break
sleep 0.2
done
printf '== exact HTTP asserts\n'
_t_has "$(probe /)" "200 text/html" "/ is 200 html"
_t_has "$(cat "$TEST_TMPDIR/headers")" '</.well-known/ai-catalog.json>; rel="ai-catalog"' "/ sends the ai-catalog Link header"
_t_has "$(probe "$PAGE")" "200 text/html" "$PAGE is 200 html"
if cmp -s "$TEST_TMPDIR/body" "$TEST_TMPDIR/dist/$PAGE_FILE"; then
_t_pass "$PAGE serves its own prerendered $PAGE_FILE, not the shell"
else
_t_fail "$PAGE serves its own prerendered $PAGE_FILE, not the shell"
fi
_t_eq "200 text/plain; charset=utf-8" "$(probe /robots.txt)" "/robots.txt type"
_t_eq "200 text/plain; charset=utf-8" "$(probe /llms.txt)" "/llms.txt type"
_t_eq "200 application/xml" "$(probe /sitemap.xml)" "/sitemap.xml type"
_t_eq "200 application/ai-catalog+json" "$(probe /.well-known/ai-catalog.json)" "ai-catalog type"
_t_eq "200 text/markdown; charset=utf-8" "$(probe /index.md)" "/index.md type"
_t_eq "200 text/markdown; charset=utf-8" "$(probe "$MIRROR")" "$MIRROR type"
_t_has "$(probe /agentready-e2e-nope)" "404 text/html" "unknown path is a 404 html page"
if cmp -s "$TEST_TMPDIR/body" "$TEST_TMPDIR/dist/404.html"; then
_t_pass "404 body is dist/404.html"
else
_t_fail "404 body is dist/404.html"
fi
_t_has "$(probe /nope.md)" "404 text/html" "missing .md is a 404 html page, not markdown"
printf '== AgentReady url mode (http://%s via 127.0.0.1:%s)\n' "$HOST" "$PORT"
status=0
run_checker || status=$?
_t_eq 0 "$status" "agent_ready url mode exit"
_t_report "e2e agent-ready $1"

View File

@ -0,0 +1,77 @@
#!/usr/bin/env bash
# Tests for features/redeploy_agent_ready_sites.sh.
#
# The wrapper is copied next to stub setup_*.sh scripts (it resolves its
# siblings from its own path), HOME points at a temp dir holding a stub
# checker, and systemctl is stubbed on PATH. Nothing live is restarted or
# fetched; the real --verify-only run against the public sites is manual.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./features_harness.sh
. "$SCRIPT_DIR/features_harness.sh"
_t_setup_env
trap _t_teardown EXIT
readonly WRAP_DIR="$TEST_TMPDIR/features"
readonly WRAPPER="$WRAP_DIR/redeploy_agent_ready_sites.sh"
mkdir -p "$WRAP_DIR" "$TEST_TMPDIR/src/utils/agent_ready"
cp "$FEATURES_LIB_DIR/../redeploy_agent_ready_sites.sh" "$WRAPPER"
# A sibling setup script that records its argv and exits $FAKE_RC_<name>.
cat >"$WRAP_DIR/setup_personal_website.sh" <<'EOF'
#!/usr/bin/env bash
name="$(basename "$0" .sh)"
printf '%s %s\n' "$name" "$*" >>"$TEST_TMPDIR/calls.log"
rc_var="FAKE_RC_${name}"
exit "${!rc_var:-0}"
EOF
cp "$WRAP_DIR/setup_personal_website.sh" "$WRAP_DIR/setup_kcd2_dice_solver.sh"
_t_stub systemctl
# Checker stub: PASS for kuhy, FAIL lines for dice, unreachable for popes.
cat >"$TEST_TMPDIR/src/utils/agent_ready/check.sh" <<'EOF'
#!/usr/bin/env bash
printf 'check %s\n' "$*" >>"$TEST_TMPDIR/calls.log"
case "$2" in
*//kuhy.*) exit "${FAKE_KUHY_RC:-0}" ;;
*//dice.*)
printf 'PASS AR-READ-03 ok\nFAIL AR-READ-02 soft 404\nFAIL AR-FIND-04 no Link\nFAIL AR-READ-02 again\n'
exit 1
;;
*) exit 3 ;;
esac
EOF
chmod +x "$TEST_TMPDIR/src/utils/agent_ready/check.sh"
run_wrapper() {
: >"$TEST_TMPDIR/calls.log"
local rc=0
HOME="$TEST_TMPDIR" bash "$WRAPPER" "$@" >"$TEST_TMPDIR/out" 2>&1 || rc=$?
echo "$rc"
}
printf '== --verify-only restarts nothing\n'
_t_eq 1 "$(run_wrapper --verify-only)" "a failing site makes the run exit 1"
_t_eq 0 "$(grep -cE '^(setup_|systemctl)' "$TEST_TMPDIR/calls.log" || true)" "no serve and no restart"
_t_eq 3 "$(grep -c '^check url https://' "$TEST_TMPDIR/calls.log")" "all three sites checked"
out="$(cat "$TEST_TMPDIR/out")"
_t_has "$out" "PASS https://kuhy.duckdns.org" "kuhy verdict"
_t_has "$out" "FAIL https://dice.kuhy.duckdns.org (AR-FIND-04,AR-READ-02)" "dice verdict lists unique failing ids"
_t_has "$out" "UNDETERMINED https://popes.kuhy.duckdns.org" "popes verdict on checker exit 3"
_t_eq 3 "$(wc -l <"$TEST_TMPDIR/out")" "one line per site"
printf '== full run\n'
_t_eq 1 "$(FAKE_RC_setup_kcd2_dice_solver=1 run_wrapper)" "full run exits 1"
_t_called "setup_personal_website serve" "website serve ran"
_t_called "setup_kcd2_dice_solver serve" "dice serve ran"
_t_called "systemctl --user restart signal-bot-popes-site.service" "popes unit restarted"
_t_has "$(cat "$TEST_TMPDIR/out")" "kcd2-dice FAILED (exit 1)" "a failed serve is reported"
_t_eq 3 "$(grep -c '^check url' "$TEST_TMPDIR/calls.log")" "a failed serve does not skip the checks"
printf '== exit codes\n'
_t_eq 2 "$(run_wrapper --bogus)" "unknown argument exits 2"
_t_has "$(bash "$WRAPPER" --help)" "--verify-only" "help documents --verify-only"
_t_report "redeploy agent-ready sites"

View File

@ -0,0 +1,122 @@
#!/usr/bin/env bash
# Tests for the `serve` subcommand of setup_personal_website.sh and
# setup_kcd2_dice_solver.sh: re-render the inner compose + Caddyfile and
# recreate only that site's container, with no sudo, pacman, rebuild or edge
# reload.
#
# Hermetic by necessity, not by taste. Every path in the entry scripts is a
# readonly constant under $HOME and the website's container_name is fixed, so a
# real `docker compose up --force-recreate` from a throwaway dir would recreate
# the LIVE personal-website container. Instead the real entry scripts run with
# HOME pointed at a temp dir and docker/curl stubbed on PATH. The rendered
# Caddyfile itself is served by real caddy in e2e_agent_ready_caddy.sh.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./features_harness.sh
. "$SCRIPT_DIR/features_harness.sh"
FEATURES_DIR="$(cd "$FEATURES_LIB_DIR/.." && pwd)"
readonly FEATURES_DIR
_t_setup_env
trap _t_teardown EXIT
# docker records every call; `docker exec <c> netstat` answers $FAKE_LISTENERS,
# the sudo-free listener table the dice edge-conflict guard reads.
cat >"$TEST_TMPDIR/bin/docker" <<'EOF'
#!/usr/bin/env bash
printf 'docker %s\n' "$*" >>"$TEST_TMPDIR/calls.log"
if [[ $1 == exec && $* == *netstat* ]]; then
printf '%s\n' "${FAKE_LISTENERS:-}"
fi
EOF
# curl expands only the -w variables the readiness loops ask for, plus the \n
# escape as real curl does.
cat >"$TEST_TMPDIR/bin/curl" <<'EOF'
#!/usr/bin/env bash
printf 'curl %s\n' "$*" >>"$TEST_TMPDIR/calls.log"
while [[ $# -gt 0 ]]; do
if [[ $1 == -w ]]; then
fmt="${2//'%{http_code}'/${FAKE_CODE:-200}}"
printf '%b' "${fmt//'%{size_download}'/1234}"
fi
shift
done
EOF
chmod +x "$TEST_TMPDIR/bin/docker" "$TEST_TMPDIR/bin/curl"
# Anything serve must never reach. `sleep` only to keep the 404 case fast.
for cmd in sudo pacman pnpm systemctl sleep; do
_t_stub "$cmd"
done
readonly OK_LISTENERS="tcp 0 0 127.0.0.1:8089 0.0.0.0:* LISTEN 1/caddy"
readonly WEB_SERVE="$TEST_TMPDIR/services/personal-website-serve"
readonly DICE_SERVE="$TEST_TMPDIR/services/kcd2-dice-serve"
make_dist() {
mkdir -p "$TEST_TMPDIR/src/$1/dist"
printf '<!doctype html>\n' >"$TEST_TMPDIR/src/$1/dist/index.html"
}
# run_serve <script> -> exit status; output in $TEST_TMPDIR/out.
run_serve() {
: >"$TEST_TMPDIR/calls.log"
local rc=0
HOME="$TEST_TMPDIR" bash "$FEATURES_DIR/$1" serve >"$TEST_TMPDIR/out" 2>&1 || rc=$?
echo "$rc"
}
assert_nothing_heavy() {
local hits
hits="$(grep -E '^(sudo|pacman|pnpm|systemctl) |caddy reload' "$TEST_TMPDIR/calls.log" || true)"
_t_eq "" "$hits" "$1: no sudo, pacman, pnpm, systemctl or edge reload"
}
printf '== usage advertises serve\n'
for script in setup_personal_website.sh setup_kcd2_dice_solver.sh; do
_t_has "$(bash "$FEATURES_DIR/$script" help)" "$script serve" "$script help lists serve"
done
printf '== serve refuses without a build, before touching docker\n'
for script in setup_personal_website.sh setup_kcd2_dice_solver.sh; do
_t_eq 1 "$(run_serve "$script")" "$script serve with no dist/ exits 1"
_t_eq 0 "$(grep -c '^docker' "$TEST_TMPDIR/calls.log" || true)" "$script: no docker call without a build"
done
make_dist personal-website
make_dist kcd2-dice-solver
printf '== website serve\n'
_t_eq 0 "$(run_serve setup_personal_website.sh)" "website serve exits 0"
_t_file_has "$WEB_SERVE/docker-compose.yml" "container_name: personal-website" "website compose rendered"
_t_file_has "$WEB_SERVE/Caddyfile" 'rel=\\"ai-catalog\\"' "website Caddyfile carries the Link header"
_t_called "docker compose -f $WEB_SERVE/docker-compose.yml up -d --force-recreate" \
"website container force-recreated"
assert_nothing_heavy "website serve"
printf '== dice serve\n'
export FAKE_LISTENERS="$OK_LISTENERS"
_t_eq 0 "$(run_serve setup_kcd2_dice_solver.sh)" "dice serve exits 0"
_t_file_has "$DICE_SERVE/docker-compose.yml" "container_name: kcd2-dice" "dice compose rendered"
_t_file_has "$DICE_SERVE/Caddyfile" "handle_errors 404" "dice Caddyfile carries the 404 handler"
_t_called "docker compose -f $DICE_SERVE/docker-compose.yml up -d --force-recreate" \
"dice container force-recreated"
_t_called "docker exec kcd2-dice netstat -ltnp" "dice edge guard ran without sudo"
assert_nothing_heavy "dice serve"
printf '== dice serve fails closed\n'
FAKE_LISTENERS="$OK_LISTENERS"$'\n'"tcp 0 0 :::80 :::* LISTEN 1/caddy"
_t_eq 1 "$(run_serve setup_kcd2_dice_solver.sh)" "a :80 listener fails serve"
_t_called "docker stop kcd2-dice" "the contending container is stopped"
_t_has "$(cat "$TEST_TMPDIR/out")" "bound :80/:443" " ...for the :80 reason"
FAKE_LISTENERS="$OK_LISTENERS"$'\n'"tcp 0 0 :::8089 :::* LISTEN 1/caddy"
_t_eq 1 "$(run_serve setup_kcd2_dice_solver.sh)" "an all-interfaces :8089 listener fails serve"
_t_has "$(cat "$TEST_TMPDIR/out")" "on all interfaces" " ...for the all-interfaces reason"
FAKE_LISTENERS=""
_t_eq 1 "$(run_serve setup_kcd2_dice_solver.sh)" "no attributable listener fails serve"
_t_has "$(cat "$TEST_TMPDIR/out")" "Could not confirm" " ...for the missing-evidence reason"
FAKE_LISTENERS="$OK_LISTENERS"
_t_eq 1 "$(FAKE_CODE=404 run_serve setup_kcd2_dice_solver.sh)" "a 404 with a body is not ready"
_t_has "$(cat "$TEST_TMPDIR/out")" "did not become reachable" " ...for the readiness reason"
_t_report "static-site serve"

View File

@ -5,6 +5,9 @@
# Sourced rather than run, so it inherits the caller's strict mode and
# the helper functions and variables defined above the source line.
# shellcheck source=static_site_caddy.sh
source "${BASH_SOURCE[0]%/*}/static_site_caddy.sh"
# --- Phase 5: build the website --------------------------------------------
build_website() {
log_info "Installing website dependencies and building…"
@ -48,17 +51,7 @@ EOF
}
:${WEBSITE_PORT} {
root * /srv
# {path}/index.html is the load-bearing middle term: the website
# prerenders each blog post to dist/blog/<slug>/index.html, and
# try_files does NOT resolve a directory to its index on its own.
# Without it every post silently falls through to the SPA shell and
# is served as the landing page -- right content once React boots,
# wrong <title> and no link preview, which looks like nothing is
# wrong until you check the tab.
try_files {path} {path}/index.html /index.html
file_server
encode gzip
$(static_site_caddy_body)
}
EOF
log_ok "Wrote ${WEBSITE_COMPOSE} and ${WEBSITE_INNER_CADDY}."

View File

@ -0,0 +1,118 @@
#!/bin/bash
#
# redeploy_agent_ready_sites.sh — narrow redeploy + AgentReady check of the
# three public static sites.
#
# Redeploy (skipped with --verify-only), each step independent of the others:
# - setup_personal_website.sh serve recreate the kuhy.duckdns.org container
# - setup_kcd2_dice_solver.sh serve recreate the dice.kuhy.duckdns.org one
# - systemctl --user restart signal-bot-popes-site.service
# None of these uses sudo, pacman, a rebuild or the shared edge: build first in
# each site's own repo, then run this to make the new dist/ + Caddyfile live.
#
# Verify: ~/src/utils/agent_ready/check.sh url against each public URL, one
# verdict line per site (PASS, FAIL with the failing check ids, or UNDETERMINED
# when the checker could not reach the site).
#
# Usage:
# redeploy_agent_ready_sites.sh Redeploy, then verify.
# redeploy_agent_ready_sites.sh --verify-only Verify only; restarts nothing.
# redeploy_agent_ready_sites.sh --help Show this help.
#
# Exit status: 0 when every step and every site passes, 1 otherwise, 2 on a
# usage error.
set -euo pipefail
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
readonly SCRIPT_DIR
readonly CHECKER="${HOME}/src/utils/agent_ready/check.sh"
readonly POPES_UNIT="signal-bot-popes-site.service"
readonly SITES=(
"https://kuhy.duckdns.org"
"https://dice.kuhy.duckdns.org"
"https://popes.kuhy.duckdns.org"
)
# Per-call ceilings keep the whole run inside the 60 s budget even when a site
# hangs; in practice a check takes under a second and a recreate a few.
readonly CHECK_TIMEOUT=10
readonly SERVE_TIMEOUT=20
FAILED=0
usage() {
grep -E '^#( |$)' "$0" | sed -E 's/^# ?//'
exit "${1:-0}"
}
# run_step <label> <cmd...>: output is shown only when the step fails, so a
# clean run stays at one line per step.
run_step() {
local label="$1" log rc=0
shift
log="$(mktemp)"
"$@" >"$log" 2>&1 || rc=$?
if [[ $rc -eq 0 ]]; then
printf 'redeploy %-28s ok\n' "$label"
else
printf 'redeploy %-28s FAILED (exit %d)\n' "$label" "$rc"
tail -n 15 "$log" | sed 's/^/ /'
FAILED=1
fi
rm -f "$log"
}
redeploy() {
run_step "personal-website" timeout "$SERVE_TIMEOUT" \
bash "${SCRIPT_DIR}/setup_personal_website.sh" serve
run_step "kcd2-dice" timeout "$SERVE_TIMEOUT" \
bash "${SCRIPT_DIR}/setup_kcd2_dice_solver.sh" serve
run_step "$POPES_UNIT" systemctl --user restart "$POPES_UNIT"
}
# check_site <url>: one verdict line, sets FAILED on anything but a pass.
check_site() {
local url="$1" out rc=0 ids
out="$(timeout "$CHECK_TIMEOUT" "$CHECKER" url "$url" 2>&1)" || rc=$?
case "$rc" in
0)
printf 'PASS %s\n' "$url"
return 0
;;
3) printf 'UNDETERMINED %s (checker could not reach it)\n' "$url" ;;
124) printf 'FAIL %s (checker timed out after %ss)\n' "$url" "$CHECK_TIMEOUT" ;;
*)
ids="$(grep -oE '^FAIL AR-[A-Z]+-[0-9]+' <<<"$out" | cut -d' ' -f2 | sort -u | paste -sd, || true)"
printf 'FAIL %s (%s)\n' "$url" "${ids:-exit ${rc}}"
;;
esac
FAILED=1
}
main() {
local verify_only=0
case "${1:-}" in
"") ;;
--verify-only) verify_only=1 ;;
-h | --help) usage ;;
*)
printf 'Unknown argument: %s\n' "$1" >&2
usage 2
;;
esac
[[ -x $CHECKER ]] || {
printf 'AgentReady checker missing: %s\n' "$CHECKER" >&2
exit 1
}
if [[ $verify_only -eq 0 ]]; then
redeploy
fi
local url
for url in "${SITES[@]}"; do
check_site "$url"
done
return "$FAILED"
}
main "$@"

View File

@ -25,6 +25,9 @@
#
# Usage:
# setup_kcd2_dice_solver.sh [setup] Build + deploy (default).
# setup_kcd2_dice_solver.sh serve Re-render the inner compose + Caddyfile
# and recreate only the kcd2-dice container
# (no sudo, pacman, rebuild or edge change).
# setup_kcd2_dice_solver.sh status Self-diagnose the deployment.
# setup_kcd2_dice_solver.sh help Show this help.
#
@ -115,12 +118,24 @@ source "$SCRIPT_DIR/lib/kcd2_expose.sh"
# shellcheck source=lib/kcd2_autorebuild.sh
source "$SCRIPT_DIR/lib/kcd2_autorebuild.sh"
# serve: the narrow redeploy. The inner caddy runs with `admin off`, so a
# Caddyfile change only loads on recreate; start_site forces one and keeps the
# edge-conflict guard.
serve_cmd() {
static_site_require_dist "${DICE_SRC}/dist"
write_serve_stack
start_site
}
main() {
local cmd="${1:-setup}"
case "$cmd" in
setup)
setup_cmd
;;
serve)
serve_cmd
;;
status)
status_cmd
;;

View File

@ -25,6 +25,9 @@
#
# Usage:
# setup_personal_website.sh [setup] Build + deploy (default).
# setup_personal_website.sh serve Re-render the inner compose + Caddyfile
# and recreate only the website container
# (no sudo, pacman, rebuild or edge change).
# setup_personal_website.sh status Self-diagnose the deployment.
# setup_personal_website.sh help Show this help.
#
@ -174,12 +177,23 @@ source "$SCRIPT_DIR/lib/website_build.sh"
# shellcheck source=lib/website_admin.sh
source "$SCRIPT_DIR/lib/website_admin.sh"
# serve: the narrow redeploy. The inner caddy runs with `admin off`, so a
# Caddyfile change only loads on recreate; start_website forces one.
serve_cmd() {
static_site_require_dist "${WEBSITE_SRC}/dist"
write_website_stack
start_website
}
main() {
local cmd="${1:-setup}"
case "$cmd" in
setup)
setup_cmd
;;
serve)
serve_cmd
;;
status)
status_cmd
;;