#!/bin/sh # Invoked over a pinned SSH connection by ReleaseRemote. No secrets in arguments. # mkdir is the NAS-wide lock; a disconnected runner leaves it for explicit recovery. set -eu umask 077 action=$1 root=$2 owner=$3 shift 3 die() { echo "S6: $*" >&2; exit 1; } case "$root" in /*) ;; *) die 'Deployment directory must be absolute';; esac case "$owner" in ''|*[!a-zA-Z0-9-]*) die 'Invalid lock owner';; esac test -d "$root" || die 'Provision the deployment directory first' cd "$root" lock="$root/deploy.lock" release="$root/releases/$owner" if [ "$action" = lock ]; then test ! -e pending.json || die 'Unresolved deployment: follow manual recovery runbook' mkdir "$lock" 2>/dev/null || die 'Deployment locked: another run or manual recovery owns the NAS' printf '%s\n' "$owner" > "$lock/owner" # Check again after acquiring, covering races with another lock holder. if [ -e pending.json ]; then rm "$lock/owner" rmdir "$lock" die 'Unresolved deployment: follow manual recovery runbook' fi exit 0 fi test -f "$lock/owner" && [ "$(cat "$lock/owner")" = "$owner" ] || die 'Deployment lock ownership mismatch' case "$action" in unlock) test ! -e pending.json || die 'Pending deployment retained; manual recovery required' rm "$lock/owner" rmdir "$lock" exit 0;; state) if [ -f state.json ]; then cat state.json; else printf '{}\n'; fi exit 0;; pending) test ! -e pending.json || die 'Deployment already pending' cat > pending.json.tmp mv pending.json.tmp pending.json exit 0;; commit) test -f pending.json || die 'No pending deployment' cat > state.json.tmp mv state.json.tmp state.json exit 0;; finish) test -f pending.json || die 'No pending deployment' mkdir -p history mv pending.json "history/$owner.json" exit 0;; event) mkdir -p history cat > "history/$owner-outcome.json.tmp" mv "history/$owner-outcome.json.tmp" "history/$owner-outcome.json" exit 0;; esac test -f production.env || die 'Missing production.env; follow provisioning runbook' # This account-owned file is trusted shell configuration, never a workflow input. . "$root/production.env" : "${BIND_IP:?}" "${FRONTEND_PORT:?}" "${DOCKER_BIN:?}" "${DOCKER_CONFIG:?}" export DOCKER_CONFIG docker_cmd() { if [ "${DOCKER_SUDO:-1}" = 1 ]; then sudo -n "$DOCKER_BIN" --config "$DOCKER_CONFIG" "$@" else "$DOCKER_BIN" --config "$DOCKER_CONFIG" "$@" fi } compose() { docker_cmd compose --project-name agiledancers-production --project-directory "$root" \ --env-file "$root/production.env" --env-file "$release/release.env" -f "$release/compose.yaml" "$@" } identity() { image=$1 sha=$2 test "$(docker_cmd image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$image")" = "$sha" \ || die 'Image revision does not match selected SHA' test "$(docker_cmd image inspect --format '{{.Os}}/{{.Architecture}}' "$image")" = linux/amd64 \ || die 'Image platform must be linux/amd64' } use_release() { case "$1" in ''|*[!a-zA-Z0-9-]*) die 'Invalid release directory';; esac release="$root/releases/$1" test -f "$release/compose.yaml" && test -f "$release/release.env" || die 'Missing retained release configuration' } health() { backend=$(compose ps -q backend) frontend=$(compose ps -q frontend) test -n "$backend" && test -n "$frontend" || return 1 # Compare image IDs, revision labels and the container release labels. for role in backend frontend; do id=$(compose ps -q "$role") if [ "$role" = backend ]; then expected=$BACKEND_IMAGE; else expected=$FRONTEND_IMAGE; fi test "$(docker_cmd inspect --format '{{.State.Running}}' "$id")" = true || return 1 test "$(docker_cmd inspect --format '{{.Image}}' "$id")" = "$(docker_cmd image inspect --format '{{.Id}}' "$expected")" || return 1 test "$(docker_cmd inspect --format '{{index .Config.Labels "nl.agilecc.revision"}}' "$id")" = "$RELEASE_SHA" || return 1 test "$(docker_cmd inspect --format '{{index .Config.Labels "nl.agilecc.release"}}' "$id")" = "$RELEASE_VERSION" || return 1 done docker_cmd exec "$frontend" wget -q -T 5 -O /dev/null http://backend:8080/q/health/ready || return 1 curl --noproxy '*' --fail --silent --show-error --max-time 5 "http://$BIND_IP:$FRONTEND_PORT/" -o /dev/null || return 1 curl --noproxy '*' --fail --silent --show-error --max-time 5 "http://$BIND_IP:$FRONTEND_PORT/api/dance-schools" -o /dev/null } case "$action" in preflight) test -n "${DATABASE_PASSWORD:-}" || die 'Missing DATABASE_PASSWORD in production.env; follow docs/persistent-storage.md before deploying S16' command -v timeout >/dev/null || die 'timeout is required for bounded remote operations' command -v curl >/dev/null || die 'curl is required' test -x "$DOCKER_BIN" || die 'Docker executable missing' docker_cmd compose version >/dev/null test "$(docker_cmd info --format '{{.OSType}}/{{.Architecture}}')" = linux/x86_64 \ || test "$(docker_cmd info --format '{{.OSType}}/{{.Architecture}}')" = linux/amd64 \ || die 'Synology Docker platform is not linux/amd64' test -d "$DOCKER_CONFIG" && test -f "$DOCKER_CONFIG/config.json" || die 'Provision registry pull login first' # Explicit private IPv4 binding; prevent accidental 0.0.0.0 publication. case "$BIND_IP" in 10.*|192.168.*|172.16.*|172.17.*|172.18.*|172.19.*|172.2[0-9].*|172.30.*|172.31.*|127.*) ;; *) die 'Bind frontend to a private IPv4 address';; esac case "$FRONTEND_PORT" in ''|*[!0-9]*) die 'Invalid frontend port';; esac test "$FRONTEND_PORT" -ge 1024 && test "$FRONTEND_PORT" -le 65535 || die 'Use port 1024..65535' ;; stage) mkdir -p "$release" test ! -e "$release/compose.yaml" || die 'Attempt configuration already exists' cat > "$release/compose.yaml" ;; prepare) version=$1; sha=$2; prefix=$3 backend=''; frontend='' for role in backend frontend; do repository="$prefix$role" image="$repository:$sha" docker_cmd pull --platform linux/amd64 "$image" >&2 identity "$image" "$sha" digest=$(docker_cmd image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$image" \ | awk -v prefix="$repository@sha256:" 'index($0,prefix)==1 { print; exit }') test -n "$digest" || die 'Missing registry digest' if [ "$role" = backend ]; then backend=$digest; else frontend=$digest; fi done printf 'RELEASE_VERSION=%s\nRELEASE_SHA=%s\nBACKEND_IMAGE=%s\nFRONTEND_IMAGE=%s\nBIND_IP=%s\nFRONTEND_PORT=%s\n' \ "$version" "$sha" "$backend" "$frontend" "$BIND_IP" "$FRONTEND_PORT" > "$release/release.env" compose config --quiet printf 'S6_BACKEND=%s\nS6_FRONTEND=%s\n' "$backend" "$frontend" ;; ensure) use_release "$1" . "$release/release.env" test "$RELEASE_VERSION" = "$2" && test "$RELEASE_SHA" = "$3" \ && test "$BACKEND_IMAGE" = "$4" && test "$FRONTEND_IMAGE" = "$5" \ || die 'Retained configuration differs from recorded successful release' for image in "$BACKEND_IMAGE" "$FRONTEND_IMAGE"; do docker_cmd pull --platform linux/amd64 "$image" >&2 identity "$image" "$RELEASE_SHA" done compose config --quiet ;; apply) mkdir "$lock/operation" 2>/dev/null || die 'A remote replacement is still active; manual recovery required' trap 'rmdir "$lock/operation"' EXIT use_release "$1" . "$release/release.env" health_seconds=${2:-280} case "$health_seconds" in ''|*[!0-9]*) die 'Invalid readiness window';; esac test "$health_seconds" -ge 1 && test "$health_seconds" -le 280 || die 'Invalid readiness window' end=$(( $(date +%s) + health_seconds )) # Always recreate the frontend too: nginx must resolve the new backend IP. # Recreate application containers only; the database and volume survive releases. compose up -d --no-build --pull missing --timeout 20 database compose up -d --no-deps --no-build --pull never --force-recreate --timeout 20 backend frontend while ! health; do test "$(date +%s)" -lt "$end" || die 'Readiness checks timed out' sleep 1 done echo 'S6_HEALTHY' ;; diagnose) use_release "$1" compose ps compose logs --no-color --tail 200 ;; *) die 'Unknown remote deployment operation';; esac