From 2a76d05f9dcca946ac5a226b93b47cd18f829474 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Thu, 22 Oct 2026 21:23:59 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01NBScELfMUiieK9ManFbS3Y --- .../agent-ready-static-sites-2026-10-22.json | 15 ++ .../agent-ready-static-sites-2026-10-22.json | 45 +++++ .../features/lib/kcd2_expose.sh | 24 +-- .../features/lib/kcd2_serve.sh | 19 ++- .../features/lib/static_site_caddy.sh | 66 ++++++++ .../lib/tests/e2e_agent_ready_caddy.sh | 156 ++++++++++++++++++ .../tests/test_redeploy_agent_ready_sites.sh | 77 +++++++++ .../lib/tests/test_static_site_serve.sh | 122 ++++++++++++++ .../features/lib/website_build.sh | 15 +- .../features/redeploy_agent_ready_sites.sh | 118 +++++++++++++ .../features/setup_kcd2_dice_solver.sh | 15 ++ .../features/setup_personal_website.sh | 14 ++ 12 files changed, 656 insertions(+), 30 deletions(-) create mode 100644 docs/superpowers/contracts/agent-ready-static-sites-2026-10-22.json create mode 100644 docs/superpowers/evidence/agent-ready-static-sites-2026-10-22.json create mode 100755 linux_configuration/features/lib/static_site_caddy.sh create mode 100755 linux_configuration/features/lib/tests/e2e_agent_ready_caddy.sh create mode 100755 linux_configuration/features/lib/tests/test_redeploy_agent_ready_sites.sh create mode 100755 linux_configuration/features/lib/tests/test_static_site_serve.sh create mode 100755 linux_configuration/features/redeploy_agent_ready_sites.sh diff --git a/docs/superpowers/contracts/agent-ready-static-sites-2026-10-22.json b/docs/superpowers/contracts/agent-ready-static-sites-2026-10-22.json new file mode 100644 index 00000000..6e0863d3 --- /dev/null +++ b/docs/superpowers/contracts/agent-ready-static-sites-2026-10-22.json @@ -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" +} diff --git a/docs/superpowers/evidence/agent-ready-static-sites-2026-10-22.json b/docs/superpowers/evidence/agent-ready-static-sites-2026-10-22.json new file mode 100644 index 00000000..7f096806 --- /dev/null +++ b/docs/superpowers/evidence/agent-ready-static-sites-2026-10-22.json @@ -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" + ] +} diff --git a/linux_configuration/features/lib/kcd2_expose.sh b/linux_configuration/features/lib/kcd2_expose.sh index a15b24e3..bd6ccfc9 100755 --- a/linux_configuration/features/lib/kcd2_expose.sh +++ b/linux_configuration/features/lib/kcd2_expose.sh @@ -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 ("/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 diff --git a/linux_configuration/features/lib/kcd2_serve.sh b/linux_configuration/features/lib/kcd2_serve.sh index ff570364..7d5a149c 100755 --- a/linux_configuration/features/lib/kcd2_serve.sh +++ b/linux_configuration/features/lib/kcd2_serve.sh @@ -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 diff --git a/linux_configuration/features/lib/static_site_caddy.sh b/linux_configuration/features/lib/static_site_caddy.sh new file mode 100755 index 00000000..48a4547d --- /dev/null +++ b/linux_configuration/features/lib/static_site_caddy.sh @@ -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 ": { ... }" 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 "; 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//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 +} diff --git a/linux_configuration/features/lib/tests/e2e_agent_ready_caddy.sh b/linux_configuration/features/lib/tests/e2e_agent_ready_caddy.sh new file mode 100755 index 00000000..e9bd6287 --- /dev/null +++ b/linux_configuration/features/lib/tests/e2e_agent_ready_caddy.sh @@ -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:// with http_proxy aimed at the container: urllib then sends +# every request to 127.0.0.1: 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 +} + +# " " 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")" '; 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" diff --git a/linux_configuration/features/lib/tests/test_redeploy_agent_ready_sites.sh b/linux_configuration/features/lib/tests/test_redeploy_agent_ready_sites.sh new file mode 100755 index 00000000..7ffe2d28 --- /dev/null +++ b/linux_configuration/features/lib/tests/test_redeploy_agent_ready_sites.sh @@ -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_. +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" diff --git a/linux_configuration/features/lib/tests/test_static_site_serve.sh b/linux_configuration/features/lib/tests/test_static_site_serve.sh new file mode 100755 index 00000000..bf31b7ec --- /dev/null +++ b/linux_configuration/features/lib/tests/test_static_site_serve.sh @@ -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 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 '\n' >"$TEST_TMPDIR/src/$1/dist/index.html" +} + +# run_serve