#!/usr/bin/env bash # Builds knotless on a host from this checkout and (re)starts it there. # # deploy/deploy.sh # KNOTLESS_HOST=root@host KNOTLESS_DOMAIN=git.example.com deploy/deploy.sh # # or with those in deploy/local.env (see local.env.example). # # The image is built by the host's own docker, over SSH, so nothing is # cross-compiled here and no registry is involved. The build caches stay on # the host, so a second deploy rebuilds only what changed. # # With no step named it does all of it. The steps, for the deploy workflow # to build while the commit's checks run and roll out once they pass: # # - build: builds the image as a candidate, knotless:build-, # leaving knotless:current and what runs as they are, and removes any # other candidate an earlier deploy left; # - up: makes the candidate knotless:current and restarts what changed; # - drop: removes the candidate, for a deploy that will not go up. # # The commit is KNOTLESS_RUN_SHA, as knotless-run sets it, or HEAD. # # On a host whose docker drives cgroups (v2) with systemd, the build's # steps run in the cgroup /system.slice/knotless-build, made here, with a # cpu.weight (and io.weight) of 100, as main's checks have: the site's # containers (about 157) come first, and pulls' runs (10) take what the # rest leave, so a burst of pulls does not hold a deploy back. KNOTLESS_BUILD_CGROUP names another parent; set empty, none. # KNOTLESS_BUILD_CPUS caps that cgroup at so many CPUs (cpu.max), on a # host shared with other sites, so a build leaves them cores whatever # their weights; unset, none. # # Optional: KNOTLESS_PROXY_NETWORK (knotless), the docker network the # reverse proxy reaches knotless-serve on; KNOTLESS_DATA (/var/lib/knotless), # its state on the host; KNOTLESS_JETSTREAM, the Jetstream it follows # (set empty for none); KNOTLESS_FIREHOSE, a relay whose firehose it # follows too; KNOTLESS_BACKFILL, a relay it refills from at each start; # KNOTLESS_RELAY, the relay a rebuilt index refills from; # KNOTLESS_ADMIN_DIDS, the accounts that operate it: moderation and usage; # KNOTLESS_BOT_DIDS, the accounts run and triage act as, which may log in # at /oauth/bot; # KNOTLESS_DEFAULT_RUNNER_DID, the runner a new public repository # names unless its owner names others: the runner host's account; # KNOTLESS_CLIENT_MINIMUM and KNOTLESS_CLIENT_MINIMUM_REASON, the oldest # client version to ask for and why, when one harms repositories; # KNOTLESS_IOS_MINIMUM_BUILD and KNOTLESS_IOS_MINIMUM_REASON, the oldest # build of the iOS app to let run and why (ios/README.md); # KNOTLESS_LOG, what serve and the runner log, as RUST_LOG takes it (info). # # knotless-run too, if KNOTLESS_RUN_REPOS names repositories to watch # (`alice.example.com/tally bob.example.com/notes`), as the account # KNOTLESS_RUN_IDENTIFIER with the app password KNOTLESS_RUN_PASSWORD; # those two are kept on the host after the first deploy. Its state is in # KNOTLESS_RUN_DATA (/var/lib/knotless-run), and the secrets it grants in # /etc/knotless/run-secrets.json, which starts empty. KNOTLESS_RUN_ARGS # adds to its command line: `--pulls`, say, to run open pulls' heads too. # A deploy restarts it only when what it runs changed: its build, as the # image's /etc/knotless-run.fingerprint says (Dockerfile), or its settings; # KNOTLESS_RUN_RESTART=1 restarts it anyway. The deploy log says which, and why. # # knotless-triage too, if KNOTLESS_TRIAGE_REPOS names repositories to # triage, once /etc/knotless/triage.env is on the host: a deploy with # KNOTLESS_TRIAGE_IDENTIFIER, KNOTLESS_TRIAGE_PASSWORD (an app password), # KNOTLESS_DECIDE_URL and KNOTLESS_DECISION_TOKEN writes it, and one # without it leaves the triager out rather than failing. # KNOTLESS_TRIAGE_ARGS adds to its command line: `--model clef --scopes # kind,area`, say. # # knotless-agent's reviewer too, if KNOTLESS_REVIEW_REPOS names # repositories whose pulls it reviews (or `--owner `, for all of # an account's), once /etc/knotless/review.env is on # the host: a deploy with KNOTLESS_REVIEW_IDENTIFIER, # KNOTLESS_REVIEW_PASSWORD (an app password) and Claude's login, # CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY, writes it, and one # without it leaves the reviewer out rather than failing. # KNOTLESS_REVIEW_ARGS adds to its command line: `--rounds 5`, say. To # review private repositories it reads them with an OAuth session, which # it gets by logging in at /oauth/bot: put its DID in KNOTLESS_BOT_DIDS. # # knotless-agent's keeper too, if KNOTLESS_KEEP_REPOS names repositories # whose turned-away pulls it keeps moving (or `--owner `), once # /etc/knotless/keep.env is on the host: a deploy with # KNOTLESS_KEEP_IDENTIFIER and KNOTLESS_KEEP_PASSWORD (an app password) # writes it, and one without it leaves the keeper out rather than failing. # KNOTLESS_KEEP_ARGS adds to its command line: `--attempts 2`, say. # # serve and pages are moved with no downtime by deploy/rollout.sh: a new # container beside the old, the old stopped once the new is healthy; a # serve whose new build would rebuild the index is stopped and started. # # `run`, if it is to be restarted, hands over (deploy/handover.sh): the # one running is sent SIGTERM and, once it says it has handed over, the # new one starts beside it, while it finishes the runs it has going. Run # by knotless-run itself (KNOTLESS_RUN is set), as the deploy workflow # runs it, that goes on from the host in the background, as this run is # one of those the old runner finishes. set -euo pipefail cd "$(dirname "$0")/.." # Settings for one's own host, kept out of the repository. [ -f deploy/local.env ] && { set -a; . deploy/local.env; set +a; } STEP=all case "${1:-}" in build | up | drop) STEP=$1 && shift ;; esac DEST="${1:-${KNOTLESS_HOST:-}}" DOMAIN="${2:-${KNOTLESS_DOMAIN:-}}" [ -n "$DEST" ] && [ -n "$DOMAIN" ] || { sed -n '4,5p' "$0" >&2; exit 2; } NETWORK="${KNOTLESS_PROXY_NETWORK:-knotless}" DATA="${KNOTLESS_DATA:-/var/lib/knotless}" RUN_REPOS="${KNOTLESS_RUN_REPOS:-}" RUN_DATA="${KNOTLESS_RUN_DATA:-/var/lib/knotless-run}" TRIAGE_REPOS="${KNOTLESS_TRIAGE_REPOS:-}" REVIEW_REPOS="${KNOTLESS_REVIEW_REPOS:-}" KEEP_REPOS="${KNOTLESS_KEEP_REPOS:-}" SSH="ssh -o StrictHostKeyChecking=accept-new $DEST" COMMIT="${KNOTLESS_RUN_SHA:-$(git rev-parse HEAD 2>/dev/null || echo local)}" CANDIDATE="knotless:build-${COMMIT:0:12}" # Says "$*" with the time and commit, here and in the host's # /var/log/knotless-deploy.log, where rollout.sh adds its own lines: a # freeze can be lined up against a deploy there. say() { local line line="$(date -u +%Y-%m-%dT%H:%M:%SZ) ${COMMIT:0:12} → $*" echo "$line" printf '%s\n' "$line" | $SSH 'cat >> /var/log/knotless-deploy.log' 2>/dev/null || true } # Builds the candidate, behind the rest of the host where it can. build() { # Candidates earlier deploys left: deploys of main go one at a time, so # none of them is still to go up. # shellcheck disable=SC2029 $SSH "docker images --format '{{.Repository}}:{{.Tag}}' knotless | grep '^knotless:build-' | grep -vxF '$CANDIDATE' | xargs -r docker rmi >/dev/null || true" local cgroup if [ -n "${KNOTLESS_BUILD_CGROUP+set}" ]; then cgroup=$KNOTLESS_BUILD_CGROUP else # docker's buildkit puts each step's container in a cgroup of its own, # by path even under systemd: /system.slice/system.slice:docker:, # beside the containers' scopes. Given a parent, /buildkit/. cgroup=$($SSH sh -s -- "${KNOTLESS_BUILD_CPUS:-}" <<'EOF' [ "$(docker info --format '{{.CgroupDriver}} {{.CgroupVersion}}')" = "systemd 2" ] || exit 0 dir=/sys/fs/cgroup/system.slice/knotless-build { mkdir -p "$dir" && echo 100 >"$dir/cpu.weight"; } 2>/dev/null || exit 0 { echo 100 >"$dir/io.weight"; } 2>/dev/null || true if [ -n "$1" ]; then echo "$(($1 * 100000)) 100000" >"$dir/cpu.max" 2>/dev/null || true else echo max 100000 >"$dir/cpu.max" 2>/dev/null || true fi echo /system.slice/knotless-build EOF ) fi say "building $CANDIDATE on $DEST${cgroup:+, in $cgroup}" local began=$SECONDS DOCKER_HOST="ssh://$DEST" docker build ${cgroup:+--cgroup-parent "$cgroup"} \ --build-arg "KNOTLESS_COMMIT=$COMMIT" \ -t "$CANDIDATE" . say "built $CANDIDATE in $((SECONDS - began))s" # buildkit keeps every layer it ever built, with no cap of its own: it # held 49 GB when the disk filled (2026-10-31). Keep the 20 GB used # last, which hold the cargo cache mounts this build just read. $SSH "docker builder prune -f --max-used-space 20GB >/dev/null || true" } drop() { # shellcheck disable=SC2029 $SSH "docker rmi '$CANDIDATE' >/dev/null 2>&1 || true" say "removed $CANDIDATE" } case "$STEP" in build) build exit ;; drop) drop exit ;; all) build ;; esac say "making $CANDIDATE knotless:current on $DEST" # shellcheck disable=SC2029 $SSH " set -e docker image inspect '$CANDIDATE' >/dev/null 2>&1 || { echo 'no $CANDIDATE on the host: deploy.sh build first' >&2; exit 2; } docker tag '$CANDIDATE' knotless:current docker rmi '$CANDIDATE' >/dev/null " say "preparing $DEST" $SSH " set -e mkdir -p /etc/knotless '$DATA' '$DATA/bots/sessions' chown 1000:1000 '$DATA' '$DATA/bots' '$DATA/bots/sessions' docker network inspect '$NETWORK' >/dev/null 2>&1 || docker network create '$NETWORK' " if [ -n "$RUN_REPOS" ]; then if [ -n "${KNOTLESS_RUN_IDENTIFIER:-}" ] && [ -n "${KNOTLESS_RUN_PASSWORD:-}" ]; then printf 'KNOTLESS_IDENTIFIER=%s\nKNOTLESS_PASSWORD=%s\n' "$KNOTLESS_RUN_IDENTIFIER" "$KNOTLESS_RUN_PASSWORD" | $SSH 'umask 077; cat > /etc/knotless/run.env' fi $SSH " set -e mkdir -p '$RUN_DATA' [ -f /etc/knotless/run.env ] || { echo 'knotless-run needs KNOTLESS_RUN_IDENTIFIER and KNOTLESS_RUN_PASSWORD once' >&2; exit 2; } [ -f /etc/knotless/run-secrets.json ] || (umask 077; echo '{}' > /etc/knotless/run-secrets.json) " fi PROFILES="" [ -z "$RUN_REPOS" ] || PROFILES=run if [ -n "$TRIAGE_REPOS" ]; then if [ -n "${KNOTLESS_TRIAGE_IDENTIFIER:-}" ] && [ -n "${KNOTLESS_TRIAGE_PASSWORD:-}" ] && [ -n "${KNOTLESS_DECIDE_URL:-}" ] && [ -n "${KNOTLESS_DECISION_TOKEN:-}" ]; then printf 'KNOTLESS_IDENTIFIER=%s\nKNOTLESS_PASSWORD=%s\nKNOTLESS_DECIDE_URL=%s\nKNOTLESS_DECISION_TOKEN=%s\n' \ "$KNOTLESS_TRIAGE_IDENTIFIER" "$KNOTLESS_TRIAGE_PASSWORD" "$KNOTLESS_DECIDE_URL" "$KNOTLESS_DECISION_TOKEN" | $SSH 'umask 077; cat > /etc/knotless/triage.env' fi if $SSH '[ -f /etc/knotless/triage.env ]'; then PROFILES="${PROFILES:+$PROFILES,}triage" else say "not starting knotless-triage: it needs KNOTLESS_TRIAGE_IDENTIFIER, KNOTLESS_TRIAGE_PASSWORD, KNOTLESS_DECIDE_URL and KNOTLESS_DECISION_TOKEN once" fi fi if [ -n "$REVIEW_REPOS" ]; then CLAUDE_LOGIN="" [ -z "${ANTHROPIC_API_KEY:-}" ] || CLAUDE_LOGIN="ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY" [ -z "${CLAUDE_CODE_OAUTH_TOKEN:-}" ] || CLAUDE_LOGIN="CLAUDE_CODE_OAUTH_TOKEN=$CLAUDE_CODE_OAUTH_TOKEN" if [ -n "${KNOTLESS_REVIEW_IDENTIFIER:-}" ] && [ -n "${KNOTLESS_REVIEW_PASSWORD:-}" ] && [ -n "$CLAUDE_LOGIN" ]; then printf 'KNOTLESS_IDENTIFIER=%s\nKNOTLESS_PASSWORD=%s\n%s\n' \ "$KNOTLESS_REVIEW_IDENTIFIER" "$KNOTLESS_REVIEW_PASSWORD" "$CLAUDE_LOGIN" | $SSH 'umask 077; cat > /etc/knotless/review.env' fi if $SSH '[ -f /etc/knotless/review.env ]'; then PROFILES="${PROFILES:+$PROFILES,}review" else say "not starting the reviewer: it needs KNOTLESS_REVIEW_IDENTIFIER, KNOTLESS_REVIEW_PASSWORD and CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY once" fi fi if [ -n "$KEEP_REPOS" ]; then if [ -n "${KNOTLESS_KEEP_IDENTIFIER:-}" ] && [ -n "${KNOTLESS_KEEP_PASSWORD:-}" ]; then printf 'KNOTLESS_IDENTIFIER=%s\nKNOTLESS_PASSWORD=%s\n' \ "$KNOTLESS_KEEP_IDENTIFIER" "$KNOTLESS_KEEP_PASSWORD" | $SSH 'umask 077; cat > /etc/knotless/keep.env' fi if $SSH '[ -f /etc/knotless/keep.env ]'; then PROFILES="${PROFILES:+$PROFILES,}keep" else say "not starting the keeper: it needs KNOTLESS_KEEP_IDENTIFIER and KNOTLESS_KEEP_PASSWORD once" fi fi # Apple's push key, once it is on the host: without it, no notification is # sent, rather than the server refusing to start for a file not there. APNS_KEY="" if [ -n "${KNOTLESS_APNS_KEY_ID:-}" ] && [ -n "${KNOTLESS_APNS_TEAM:-}" ]; then if $SSH "[ -f '$DATA/apns.p8' ]"; then APNS_KEY=/data/apns.p8 else say "sending no push notifications: $DATA/apns.p8 is not on the host" fi fi { echo "KNOTLESS_DOMAIN=$DOMAIN" echo "KNOTLESS_PROXY_NETWORK=$NETWORK" echo "KNOTLESS_DATA=$DATA" # Set, even empty: empty turns Jetstream off. [ -z "${KNOTLESS_JETSTREAM+set}" ] || echo "KNOTLESS_JETSTREAM=$KNOTLESS_JETSTREAM" [ -z "${KNOTLESS_FIREHOSE:-}" ] || echo "KNOTLESS_FIREHOSE=$KNOTLESS_FIREHOSE" [ -z "${KNOTLESS_BACKFILL:-}" ] || echo "KNOTLESS_BACKFILL=$KNOTLESS_BACKFILL" [ -z "${KNOTLESS_RELAY:-}" ] || echo "KNOTLESS_RELAY=$KNOTLESS_RELAY" [ -z "${KNOTLESS_DEFAULT_RUNNER_DID:-}" ] || echo "KNOTLESS_DEFAULT_RUNNER_DID=$KNOTLESS_DEFAULT_RUNNER_DID" [ -z "${KNOTLESS_CLIENT_MINIMUM:-}" ] || echo "KNOTLESS_CLIENT_MINIMUM=$KNOTLESS_CLIENT_MINIMUM" [ -z "${KNOTLESS_CLIENT_MINIMUM_REASON:-}" ] || echo "KNOTLESS_CLIENT_MINIMUM_REASON=$KNOTLESS_CLIENT_MINIMUM_REASON" [ -z "${KNOTLESS_IOS_MINIMUM_BUILD:-}" ] || echo "KNOTLESS_IOS_MINIMUM_BUILD=$KNOTLESS_IOS_MINIMUM_BUILD" [ -z "${KNOTLESS_IOS_MINIMUM_REASON:-}" ] || echo "KNOTLESS_IOS_MINIMUM_REASON=$KNOTLESS_IOS_MINIMUM_REASON" [ -z "${KNOTLESS_ADMIN_DIDS:-}" ] || echo "KNOTLESS_ADMIN_DIDS=$KNOTLESS_ADMIN_DIDS" [ -z "${KNOTLESS_BOT_DIDS:-}" ] || echo "KNOTLESS_BOT_DIDS=$KNOTLESS_BOT_DIDS" [ -z "${KNOTLESS_LOG:-}" ] || echo "KNOTLESS_LOG=$KNOTLESS_LOG" [ -z "${KNOTLESS_PAGES_DOMAIN:-}" ] || echo "KNOTLESS_PAGES_DOMAIN=$KNOTLESS_PAGES_DOMAIN" if [ -n "$APNS_KEY" ]; then echo "KNOTLESS_APNS_KEY=$APNS_KEY" echo "KNOTLESS_APNS_KEY_ID=$KNOTLESS_APNS_KEY_ID" echo "KNOTLESS_APNS_TEAM=$KNOTLESS_APNS_TEAM" fi [ -z "$PROFILES" ] || echo "COMPOSE_PROFILES=$PROFILES" if [ -n "$TRIAGE_REPOS" ]; then echo "KNOTLESS_TRIAGE_REPOS=$TRIAGE_REPOS" [ -z "${KNOTLESS_TRIAGE_ARGS:-}" ] || echo "KNOTLESS_TRIAGE_ARGS=$KNOTLESS_TRIAGE_ARGS" fi if [ -n "$REVIEW_REPOS" ]; then echo "KNOTLESS_REVIEW_REPOS=$REVIEW_REPOS" [ -z "${KNOTLESS_REVIEW_ARGS:-}" ] || echo "KNOTLESS_REVIEW_ARGS=$KNOTLESS_REVIEW_ARGS" fi if [ -n "$KEEP_REPOS" ]; then echo "KNOTLESS_KEEP_REPOS=$KEEP_REPOS" [ -z "${KNOTLESS_KEEP_ARGS:-}" ] || echo "KNOTLESS_KEEP_ARGS=$KNOTLESS_KEEP_ARGS" fi if [ -n "$RUN_REPOS" ]; then echo "KNOTLESS_RUN_REPOS=$RUN_REPOS" echo "KNOTLESS_RUN_DATA=$RUN_DATA" [ -z "${KNOTLESS_RUN_ARGS:-}" ] || echo "KNOTLESS_RUN_ARGS=$KNOTLESS_RUN_ARGS" fi } | $SSH 'cat > /etc/knotless/.env' scp -q -o StrictHostKeyChecking=accept-new deploy/docker-compose.yml deploy/rollout.sh deploy/handover.sh "$DEST:/etc/knotless/" # Whether `run` is restarted: only when what it runs changed, since a # restart cancels the runs still waiting for a job (docs/design/runner.md, # "A stop"). What it runs is two things, each of which must be known to be # unchanged, or it is restarted as before: # # - its build: the fingerprint the image keeps of what the runner runs # (Dockerfile), read from the running container, against the one in # knotless:current, the image just built. The commit is set as the # binaries run rather than built in, so a commit that changes nothing # the runner is made of leaves the same fingerprint, whatever else in # the image or the repository it changes; # - its settings: a hash of the `run` service as compose resolves it from # docker-compose.yml and the .env just written, and of run.env, against # /etc/knotless/run-deployed, " ", which each restart # removes before it starts and writes once the new container is up. # # KNOTLESS_RUN_RESTART=1 restarts it whatever they say. # # Each script sent to the host below is one { } group, read whole before it # runs, with stdin from /dev/null: nothing it runs can take the rest of the # script as its input. STATUS=$($SSH sh -s <<'EOF' { cd /etc/knotless || exit 0 config=$(docker compose config run 2>/dev/null) || exit 0 { printf '%s\n' "$config"; cat run.env 2>/dev/null; } | sha256sum | cut -d ' ' -f 1 | sed 's/^/settings /' # The one leading: the newest docker still restarts, not one handing # over (handover.sh). id=$(docker compose ps -q --status running run 2>/dev/null | xargs -r docker inspect -f '{{.Created}} {{.HostConfig.RestartPolicy.Name}} {{.Id}}' | awk '$2 == "always"' | sort | tail -n 1 | awk '{ print $3 }') [ -n "$id" ] || exit 0 echo "container $id" echo "build $(docker exec "$id" cat /etc/knotless-run.fingerprint 2>/dev/null)" echo "candidate $(docker run --rm --entrypoint cat knotless:current /etc/knotless-run.fingerprint 2>/dev/null)" [ ! -f run-deployed ] || echo "record $(head -n 1 run-deployed)" } /var/log/knotless-run-restart.log 2>&1 < /dev/null & else setsid nohup sh handover.sh "$2" > /var/log/knotless-run-restart.log 2>&1 < /dev/null & fi } /dev/null || true" say "done: https://$DOMAIN, once the proxy on $NETWORK serves it"