~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
443 lines · 20.1 KB Raw History
#!/usr/bin/env bash
# Builds knotless on a host from this checkout and (re)starts it there.
#
#   deploy/deploy.sh <ssh destination> <domain>
#   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-<commit>,
#   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 <handle>`, 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 <handle>`), 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:<id>,
    # beside the containers' scopes. Given a parent, <parent>/buildkit/<id>.
    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, "<container id> <hash>", 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)"
} </dev/null
EOF
) || STATUS=""
field() { printf '%s\n' "$STATUS" | awk -v k="$1" '$1 == k { $1 = ""; sub(/^ /, ""); print; exit }'; }
SETTINGS=$(field settings)
RUNNING=$(field container)
RUN_BUILD=$(field build)
NEW_BUILD=$(field candidate)
[[ $RUN_BUILD =~ ^[0-9a-f]{64}$ ]] || RUN_BUILD=""
[[ $NEW_BUILD =~ ^[0-9a-f]{64}$ ]] || NEW_BUILD=""
[[ $SETTINGS =~ ^[0-9a-f]{64}$ ]] || SETTINGS=""
RESTART_RUN=""
if [ -z "$RUN_REPOS" ]; then
  # No runner here: one a deploy before left is stopped and removed, as a
  # restart would stop it, once this deploy's run is filed. Named, compose
  # would start `run` whatever its profile, so it is never restarted.
  :
elif [ "${KNOTLESS_RUN_RESTART:-}" = 1 ]; then
  RESTART_RUN="asked to, by KNOTLESS_RUN_RESTART"
elif [ -z "$SETTINGS" ]; then
  RESTART_RUN="its settings could not be read"
elif [ -z "$RUNNING" ]; then
  RESTART_RUN="it is not running"
elif [ "$(field record)" != "$RUNNING $SETTINGS" ]; then
  RESTART_RUN="its settings changed, or were not recorded for the container running"
elif [ -z "$RUN_BUILD" ]; then
  RESTART_RUN="the one running keeps no fingerprint of its build"
elif [ -z "$NEW_BUILD" ]; then
  RESTART_RUN="the new image's fingerprint could not be read"
elif [ "$RUN_BUILD" != "$NEW_BUILD" ]; then
  RESTART_RUN="its build changed, ${RUN_BUILD:0:12} to ${NEW_BUILD:0:12}"
fi
if [ -z "$RUN_REPOS" ]; then
  if [ -n "$RUNNING" ]; then
    say "run: removing: no KNOTLESS_RUN_REPOS"
    RUN_MODE=remove
  else
    RUN_MODE=keep
  fi
elif [ -n "$RESTART_RUN" ]; then
  say "run: restarting: $RESTART_RUN"
  RUN_MODE=restart
else
  say "run: its build, ${RUN_BUILD:0:12}, and settings are the same, not restarted"
  RUN_MODE=keep
fi

# pages and serve roll over with no gap (rollout.sh); anything else is
# restarted as compose does; and `run`, if it is to be, last, the one
# running handing over to the new one (handover.sh).
say "starting"
if [ -z "${KNOTLESS_RUN:-}" ]; then
  $SSH sh -s -- "$RUN_MODE" "${SETTINGS:-none}" <<'EOF'
{
  set -e
  cd /etc/knotless
  docker compose config --quiet
  sh rollout.sh pages serve
  docker compose up -d --remove-orphans $(docker compose config --services | grep -vx run)
  if [ "$1" = remove ]; then
    rm -f run-deployed
    docker compose rm -sf run
  elif [ "$1" = restart ]; then
    sh handover.sh "$2"
  fi
  docker compose ps
} </dev/null
EOF
else
  $SSH sh -s -- "$RUN_MODE" "${SETTINGS:-none}" <<'EOF'
{
  set -e
  cd /etc/knotless
  docker compose config --quiet
  sh rollout.sh pages serve
  docker compose up -d --remove-orphans $(docker compose config --services | grep -vx run)
  docker compose ps
  [ "$1" = restart ] || [ "$1" = remove ] || exit 0
  # In the background: the runner running this deploy finishes it first,
  # handing over or going, and this run is one of its runs going.
  if [ "$1" = remove ]; then
    rm -f run-deployed
    setsid nohup docker compose rm -sf run > /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
EOF
fi

# Each deploy leaves the image it replaced untagged, 1.2 GB that the
# daily prune keeps for a week: dozens of deploys a day filled the disk
# (2026-10-31). Remove those no container still runs; one a container
# still runs, as `run` may until its restart, goes with the next deploy.
say "removing images no container runs and no tag names"
$SSH "docker image prune -f >/dev/null || true"

say "done: https://$DOMAIN, once the proxy on $NETWORK serves it"