From 434bfff6fc4b6ccfdf430ad9cb14d60afda4db73 Mon Sep 17 00:00:00 2001 From: dawn Date: Wed, 23 Sep 2026 10:26:01 +0300 Subject: [PATCH] localinfra: seed and verify a site with an opt-in smoke Signed-off-by: dawn --- docker-compose.yml | 6 + localinfra/readme.md | 13 +++ localinfra/scripts/init-data.sh | 4 + localinfra/scripts/init-sites.sh | 193 +++++++++++++++++++++++++++++++ localinfra/seed.Dockerfile | 2 +- 5 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 localinfra/scripts/init-sites.sh diff --git a/docker-compose.yml b/docker-compose.yml index 2d8697630..2d8c2fa7c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -175,6 +175,12 @@ services: PDS_URL: http://pds:3000 KNOT_URL: http://knot2:5555 KNOT_HOSTNAME: knot2.tngl.boltless.dev + # opt in only with --profile sites-worker and sites-local-r2.compose.yml + SITES_SMOKE: ${SITES_SMOKE:-0} + SITES_URL: http://sites:8787 + SITES_HOSTNAME: sites.tngl.boltless.dev + SITES_DOMAIN: sites.tngl.boltless.dev + SITES_LOCAL_R2_TOKEN: ${SITES_LOCAL_R2_TOKEN:-} volumes: - ./localinfra/scripts:/scripts:ro - init-state:/shared:ro diff --git a/localinfra/readme.md b/localinfra/readme.md index e17c5f614..6293f86d3 100644 --- a/localinfra/readme.md +++ b/localinfra/readme.md @@ -99,6 +99,19 @@ To make that work: `TANGLED_APPVIEW_HOST` must be a loopback IP with the mapped port (`127.0.0.1:3000`), not `localhost`: atproto's dev OAuth client requires a loopback IP for the redirect URI. If you remap the published appview port, update `TANGLED_APPVIEW_HOST` in `docker-compose.yml` to match. +## sites fixture (opt-in) + +the normal `init-data` run does not need the optional sites worker. to seed and check a static site, use the same init script with the local r2 bridge enabled: + +```sh +export SITES_LOCAL_R2_TOKEN="$(openssl rand -hex 32)" +podman compose -f docker-compose.yml -f localinfra/sites-local-r2.compose.yml \ + --profile sites-worker up -d sites sitesd +SITES_SMOKE=1 podman compose -f docker-compose.yml \ + -f localinfra/sites-local-r2.compose.yml --profile sites-worker \ + run --rm init-data +``` + ## Observability Prometheus, Grafana, Tempo, and Loki are available through the optional `observability` profile. To run the standalone spindle with metrics, traces, and remote logs: diff --git a/localinfra/scripts/init-data.sh b/localinfra/scripts/init-data.sh index 11611051c..4fe7bc00a 100644 --- a/localinfra/scripts/init-data.sh +++ b/localinfra/scripts/init-data.sh @@ -669,4 +669,8 @@ put_record "$VOUCH_BOB_DAVID" "$(jq -nc \ --arg createdAt '2025-09-22T10:39:35Z' \ '{kind:"denounce", reason:$reason, createdAt:$createdAt}')" >/dev/null +if [ "${SITES_SMOKE:-0}" = 1 ]; then + ( . /scripts/init-sites.sh ) +fi + printf 'done.\n' >&2 diff --git a/localinfra/scripts/init-sites.sh b/localinfra/scripts/init-sites.sh new file mode 100644 index 000000000..50f504395 --- /dev/null +++ b/localinfra/scripts/init-sites.sh @@ -0,0 +1,193 @@ +#!/bin/sh +[ "${SITES_SMOKE:-0}" = 1 ] || return 0 +[ "${PDS_URL:-}" = http://pds:3000 ] && [ "${KNOT_URL:-}" = http://knot2:5555 ] && + [ "${SITES_URL:-}" = http://sites:8787 ] && + [ "${SITES_HOSTNAME:-}" = sites.tngl.boltless.dev ] && + [ "${SITES_DOMAIN:-}" = sites.tngl.boltless.dev ] || + fail '[sites smoke] requires the local PDS, knot, Worker, and dev-only sites domain.' +: "${SITES_LOCAL_R2_TOKEN:?SITES_SMOKE=1 needs the opt-in local R2 bridge token}" + +SMOKE_DOMAIN=sites-smoke.$SITES_DOMAIN +SMOKE_RKEY=sites-smoke +SMOKE_NAME=sites-smoke +SMOKE_BRIDGE=$SITES_URL/__tangled_r2/tangled-sites +SMOKE_AUD=did:web:$SITES_HOSTNAME +SMOKE_DIR=$(mktemp -d) +trap 'rm -rf "$SMOKE_DIR"' EXIT +SMOKE_RESPONSE=$SMOKE_DIR/response +SMOKE_REPO=$SMOKE_DIR/repo + +# keep response bodies private. never log either auth token. +smoke_http() { + smoke_status=$(curl -s --max-time 8 -o "$SMOKE_RESPONSE" -w '%{http_code}' "$@") || smoke_status=000 +} +smoke_xrpc() { + smoke_lxm=$1 + smoke_method=$2 + smoke_payload=$3 + smoke_jwt=$(service_token "$OWNER_JWT" "$smoke_lxm" "$SMOKE_AUD") + if [ "$smoke_method" = POST ]; then + smoke_http -X POST -H 'Content-Type: application/json' -H "Authorization: Bearer $smoke_jwt" \ + --data "$smoke_payload" "$SITES_URL/xrpc/$smoke_lxm" + else + smoke_http -H "Authorization: Bearer $smoke_jwt" "$SITES_URL/xrpc/$smoke_lxm" + fi +} +smoke_expect_200() { + [ "$smoke_status" = 200 ] || fail "[sites smoke] $1: HTTP $smoke_status ($(jq -r '.error // "request failed"' "$SMOKE_RESPONSE" 2>/dev/null || printf 'request failed'))." +} +smoke_served() { + smoke_http -H "Host: $SMOKE_DOMAIN" "$SITES_URL/$1" + [ "$smoke_status" = "$2" ] || return 1 + [ "$2" != 200 ] || [ "$(cat "$SMOKE_RESPONSE")" = "$3" ] +} +smoke_r2_list() { + smoke_http -H "Authorization: Bearer $SITES_LOCAL_R2_TOKEN" --get \ + --data-urlencode "prefix=$SMOKE_PREFIX" "$SMOKE_BRIDGE" + [ "$smoke_status" = 200 ] && jq -e 'type == "array"' "$SMOKE_RESPONSE" >/dev/null +} +smoke_r2_has() { + smoke_r2_list && jq -e --arg key "$SMOKE_PREFIX$1" 'any(.[]; .key == $key)' "$SMOKE_RESPONSE" >/dev/null +} +smoke_r2_lacks() { + smoke_r2_list && jq -e --arg key "$SMOKE_PREFIX$1" 'all(.[]; .key != $key)' "$SMOKE_RESPONSE" >/dev/null +} + +smoke_ready=0 +for smoke_attempt in $(seq 1 30); do + smoke_http -H "Host: $SMOKE_DOMAIN" "$SITES_URL/" + case "$smoke_status" in 404|200|308) smoke_ready=1; break ;; esac + sleep 2 +done +[ "$smoke_ready" = 1 ] || fail '[sites smoke] sites Worker unavailable after 30 attempts. Start --profile sites-worker and the local R2 bridge overlay.' + +smoke_http "$SMOKE_BRIDGE" +[ "$smoke_status" = 401 ] || fail "[sites smoke] unauthenticated local R2 bridge must answer 401 (got $smoke_status)." +smoke_http -H "Authorization: Bearer $SITES_LOCAL_R2_TOKEN" "$SMOKE_BRIDGE?prefix=sites-smoke-healthcheck" +[ "$smoke_status" = 200 ] && jq -e 'type == "array"' "$SMOKE_RESPONSE" >/dev/null || + fail '[sites smoke] authenticated local R2 bridge is unavailable. Enable the opt-in bridge overlay with matching token.' + +# reuse the owner's claim. never release or steal it. +smoke_xrpc org.tangled.temp.site.getDomainClaim GET '' +smoke_expect_200 getDomainClaim +smoke_claim=$(jq -r '.domain // empty' "$SMOKE_RESPONSE") +case "$smoke_claim" in + "$SMOKE_DOMAIN") ;; + '') + smoke_xrpc org.tangled.temp.site.claimDomain POST "$(jq -nc --arg domain "$SMOKE_DOMAIN" '{domain:$domain}')" + smoke_expect_200 claimDomain ;; + *) fail "[sites smoke] owner already claims $smoke_claim; refusing to change it." ;; +esac + +ssh-keygen -q -t ed25519 -N '' -f "$SMOKE_DIR/id_ed25519" >/dev/null +smoke_pubkey=$(cat "$SMOKE_DIR/id_ed25519.pub") +put_record "at://$OWNER_DID/sh.tangled.publicKey/3mssssmoke222" \ + "$(jq -nc --arg key "$smoke_pubkey" --arg createdAt "$CREATED_AT" \ + '{key:$key,name:"local sites smoke",createdAt:$createdAt}')" >/dev/null +export GIT_SSH_COMMAND="ssh -i $SMOKE_DIR/id_ed25519 -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=$SMOKE_DIR/known_hosts -o ConnectTimeout=5" + +smoke_http -H 'Content-Type: application/json' -H "Authorization: Bearer $(service_token "$OWNER_JWT" sh.tangled.repo.create "$KNOT_DID")" \ + --data "$(jq -nc --arg rkey "$SMOKE_RKEY" '{rkey:$rkey,name:$rkey,defaultBranch:"main"}')" \ + "$KNOT_URL/xrpc/sh.tangled.repo.create" +case "$smoke_status" in + 200) SMOKE_REPO_DID=$(jq -er '.repoDid' "$SMOKE_RESPONSE") ;; + 409) + smoke_http "$PDS_URL/xrpc/com.atproto.repo.getRecord?repo=$OWNER_DID&collection=sh.tangled.repo&rkey=$SMOKE_RKEY" + [ "$smoke_status" = 200 ] || fail '[sites smoke] repo exists on knot but owner PDS record is missing (partial earlier smoke or mismatched volumes); cannot recover repo DID automatically.' + SMOKE_REPO_DID=$(jq -er '.value.repoDid' "$SMOKE_RESPONSE") + [ "$(jq -r '.value.knot' "$SMOKE_RESPONSE")" = "$KNOT_HOSTNAME" ] || fail '[sites smoke] reused repo record names a different knot.' + ;; + *) fail "[sites smoke] repo.create: HTTP $smoke_status." ;; +esac + +SMOKE_PREFIX=$OWNER_DID/$SMOKE_RKEY/ + +# wait for `plc` propagation and the knot's matching owner/rkey proof. +smoke_proven=0 +for smoke_attempt in $(seq 1 45); do + smoke_http "http://plc:8080/$SMOKE_REPO_DID" + if [ "$smoke_status" = 200 ]; then + smoke_http "$KNOT_URL/xrpc/sh.tangled.repo.describeRepo?repoDid=$SMOKE_REPO_DID" + if [ "$smoke_status" = 200 ] && jq -e --arg did "$SMOKE_REPO_DID" --arg owner "$OWNER_DID" \ + --arg rkey "$SMOKE_RKEY" '.repoDid == $did and .ownerDid == $owner and .rkey == $rkey' "$SMOKE_RESPONSE" >/dev/null; then + smoke_proven=1; break + fi + fi + sleep 2 +done +[ "$smoke_proven" = 1 ] || fail '[sites smoke] repo DID PLC document or knot owner/rkey proof unavailable after 45 attempts.' + +put_record "at://$OWNER_DID/sh.tangled.repo/$SMOKE_RKEY" \ + "$(jq -nc --arg knot "$KNOT_HOSTNAME" --arg repoDid "$SMOKE_REPO_DID" \ + --arg createdAt "$CREATED_AT" \ + '{knot:$knot,name:"sites-smoke",repoDid:$repoDid,createdAt:$createdAt}')" >/dev/null + +# fetch the existing branch on reruns. never force-push or erase unrelated keys. +git init -q --object-format=sha256 -b main "$SMOKE_REPO" +git -C "$SMOKE_REPO" remote add origin "ssh://git@knot2:2222/$SMOKE_REPO_DID" +smoke_git_ready=0 +for smoke_attempt in $(seq 1 30); do + if git -C "$SMOKE_REPO" fetch -q origin main 2>"$SMOKE_DIR/git-error"; then + git -C "$SMOKE_REPO" reset -q --hard FETCH_HEAD + smoke_git_ready=1; break + fi + if git -C "$SMOKE_REPO" ls-remote -q origin HEAD 2>"$SMOKE_DIR/git-error"; then + smoke_git_ready=1; break + fi + sleep 2 +done +[ "$smoke_git_ready" = 1 ] || fail '[sites smoke] SSH key did not propagate to knot or Git remote was unavailable after 30 attempts.' +[ "$(git -C "$SMOKE_REPO" rev-parse --show-object-format)" = sha256 ] || fail '[sites smoke] fixture Git repo is not SHA-256.' +git -C "$SMOKE_REPO" config user.name 'Local Sites Smoke' +git -C "$SMOKE_REPO" config user.email 'sites-smoke@local.invalid' +mkdir -p "$SMOKE_REPO/public" +printf 'sites smoke version one\n' >"$SMOKE_REPO/public/index.html" +printf 'remove me\n' >"$SMOKE_REPO/public/stale.txt" +git -C "$SMOKE_REPO" add -- public/index.html public/stale.txt +git -C "$SMOKE_REPO" commit -qm 'sites smoke version one' || fail '[sites smoke] initial fixture commit was unchanged; cannot prove the first deploy.' +git -C "$SMOKE_REPO" push -q origin HEAD:main || fail '[sites smoke] initial fixture push failed.' + +smoke_config=$(jq -nc --arg repoDid "$SMOKE_REPO_DID" --arg rkey "$SMOKE_RKEY" \ + --arg knot "$KNOT_HOSTNAME" --arg name "$SMOKE_NAME" \ + '{repoDid:$repoDid,name:$name,rkey:$rkey,knot:$knot,branch:"main",dir:"/public",isIndex:false}') +smoke_configured=0 +for smoke_attempt in $(seq 1 30); do + smoke_xrpc org.tangled.temp.repo.updateSiteConfig POST "$smoke_config" + if [ "$smoke_status" = 200 ]; then smoke_configured=1; break; fi + smoke_error=$(jq -r '.error // empty' "$SMOKE_RESPONSE" 2>/dev/null || :) + case "$smoke_error" in OwnershipUnverifiable|RepoNotFound) sleep 2 ;; *) fail "[sites smoke] updateSiteConfig: HTTP $smoke_status ($smoke_error)." ;; esac +done +[ "$smoke_configured" = 1 ] || fail '[sites smoke] Worker could not verify repo DID/owner after 30 attempts.' +smoke_xrpc org.tangled.temp.repo.deploySite POST "$(jq -nc --arg repoDid "$SMOKE_REPO_DID" '{repoDid:$repoDid}')" +smoke_expect_200 deploySite + +smoke_first=0 +for smoke_attempt in $(seq 1 90); do + if smoke_served "$SMOKE_NAME/index.html" 200 'sites smoke version one' && + smoke_served "$SMOKE_NAME/stale.txt" 200 'remove me' && + smoke_r2_has stale.txt; then smoke_first=1; break; fi + sleep 2 +done +[ "$smoke_first" = 1 ] || fail '[sites smoke] initial deploy did not serve index + stale asset and populate its R2 key after 90 attempts.' + +printf 'sites smoke version two\n' >"$SMOKE_REPO/public/index.html" +git -C "$SMOKE_REPO" rm -q -- public/stale.txt +git -C "$SMOKE_REPO" add -- public/index.html +git -C "$SMOKE_REPO" commit -qm 'sites smoke version two' +SMOKE_HEAD=$(git -C "$SMOKE_REPO" rev-parse HEAD) +git -C "$SMOKE_REPO" push -q origin HEAD:main || fail '[sites smoke] updated fixture push failed.' +# a matching success record proves the knot feed saw this push. +smoke_updated=0 +for smoke_attempt in $(seq 1 90); do + smoke_jwt=$(service_token "$OWNER_JWT" org.tangled.temp.repo.getDeployHistory "$SMOKE_AUD") + smoke_http -H "Authorization: Bearer $smoke_jwt" \ + "$SITES_URL/xrpc/org.tangled.temp.repo.getDeployHistory?repoDid=$SMOKE_REPO_DID" + if [ "$smoke_status" = 200 ] && + jq -e --arg sha "$SMOKE_HEAD" 'any(.deploys[]; .trigger == "push" and .commitSha == $sha and .status == "success")' "$SMOKE_RESPONSE" >/dev/null && + smoke_served "$SMOKE_NAME/index.html" 200 'sites smoke version two' && + smoke_served "$SMOKE_NAME/stale.txt" 404 '' && + smoke_r2_lacks stale.txt; then smoke_updated=1; break; fi + sleep 2 +done +[ "$smoke_updated" = 1 ] || fail '[sites smoke] no successful push-triggered deploy of v2 with stale asset 404 and R2 key removed after 90 attempts; check sitesd Knot feed and deploy history.' +printf '[sites smoke] SHA-256 repo, auth boundary, push deploy, served update, and stale R2 cleanup passed.\n' >&2 diff --git a/localinfra/seed.Dockerfile b/localinfra/seed.Dockerfile index 78ae190c1..291c990c6 100644 --- a/localinfra/seed.Dockerfile +++ b/localinfra/seed.Dockerfile @@ -1,2 +1,2 @@ FROM alpine:3.22 -RUN apk add --no-cache curl jq +RUN apk add --no-cache curl jq git openssh-client -- 2.51.2