Files
timka123andClaude Opus 5 5259e027c6 deploy: re-exec from a copy, so the script cannot rewrite itself mid-run
deploy.sh lives in the repo it updates. `git reset --hard` therefore replaced
the file bash was reading; bash keeps a byte offset into the script and resumed
at that offset in the new file, skipping whatever the diff had shifted.

That is how the previous commit's renewal hook ended up present on the server
and never executed: `grep` found it in deploy.sh, the hook directory was empty.

Stage one now updates the checkout and re-execs a copy under /tmp, which no
later `git reset` can touch; stage two does the work and removes the copy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 15:11:01 +03:00

178 lines
6.4 KiB
Bash
Executable File

#!/usr/bin/env bash
# Deploy Kadence on the server. Idempotent: run it for the first install and for
# every update after.
#
# ssh sw_pl_1 '/opt/kadence/deploy/deploy.sh'
#
# What it does, skipping whatever is already true:
# 1. fetches the pinned branch from Gitea over SSH
# 2. makes sure .env exists and has the secrets that must not be defaults
# 3. installs the nginx vhost and gets a Let's Encrypt certificate
# 4. rebuilds and restarts the containers, then waits for /api/health
#
# It never writes .env values that are already set, so rotating a secret by hand
# survives the next deploy.
set -euo pipefail
REPO_DIR=${REPO_DIR:-/opt/kadence}
DOMAIN=${DOMAIN:-kadence.timka.su}
BRANCH=${BRANCH:-master}
BIND=${KADENCE_BIND:-127.0.0.1:8010}
COMPOSE=(docker compose -f docker-compose.yml -f docker-compose.prod.yml)
say() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; }
warn() { printf '\033[1;33m !! %s\033[0m\n' "$*"; }
cd "$REPO_DIR"
# ---- 1. code ---------------------------------------------------------------
# This script lives in the repo it updates, so `git reset --hard` rewrites the
# file bash is in the middle of reading. Bash reads a script incrementally and
# keeps a byte offset, so after the reset it resumes at that offset in the *new*
# file — silently skipping or garbling whatever the diff shifted. (That is not
# hypothetical: it is how the certbot renewal hook came to be committed, present
# on disk, and never executed.)
#
# So: stage one updates the checkout and re-execs a *copy* outside the repo,
# which nothing can rewrite underneath it. Stage two does the actual work.
if [[ ${KADENCE_DEPLOY_STAGE:-} != run ]]; then
say "Fetching $BRANCH from $(git remote get-url origin)"
git fetch --prune origin
git checkout -q "$BRANCH"
git reset --hard "origin/$BRANCH"
echo "now at $(git log --oneline -1)"
staged=$(mktemp /tmp/kadence-deploy.XXXXXX.sh)
cat "$REPO_DIR/deploy/deploy.sh" > "$staged"
chmod +x "$staged"
export KADENCE_DEPLOY_STAGE=run KADENCE_DEPLOY_STAGED="$staged"
exec "$staged" "$@"
fi
# Stage two: started from the copy, so clean it up on the way out.
trap '[[ -n ${KADENCE_DEPLOY_STAGED:-} ]] && rm -f "$KADENCE_DEPLOY_STAGED"' EXIT
# ---- 2. secrets ------------------------------------------------------------
setkv() {
local key=$1 value=$2
sed -i "/^${key}=/d" .env 2>/dev/null || true
printf '%s=%s\n' "$key" "$value" >> .env
}
# `.env.example` ships placeholders rather than blanks — `POSTGRES_PASSWORD=change-me`
# is the dangerous one — so "already set" has to mean "set to something real", not
# merely "present".
is_placeholder() {
case "$1" in
''|change-me|changeme|'*'|your-*|TODO) return 0 ;;
*) return 1 ;;
esac
}
# Generated once, then left alone: a deploy that silently rotated API_TOKEN would
# lock out the phone and the desktop app, so this only ever fills a blank.
ensure_secret() {
local key=$1 value=$2
local current
current=$(sed -n "s/^${key}=//p" .env 2>/dev/null | tail -1)
if ! is_placeholder "$current"; then
return
fi
setkv "$key" "$value"
echo " generated $key"
}
if [[ ! -f .env ]]; then
say "Creating .env from .env.example"
cp .env.example .env
fi
chmod 600 .env
say "Checking secrets"
ensure_secret API_TOKEN "$(openssl rand -hex 32)"
ensure_secret POSTGRES_PASSWORD "$(openssl rand -hex 24)"
# Deployment facts rather than secrets: these describe where this instance runs,
# so the script owns them outright and corrects them if they drift.
setkv KADENCE_BIND "$BIND"
setkv CORS_ORIGINS "https://${DOMAIN}"
setkv TIMEZONE "${TIMEZONE:-Europe/Moscow}"
if ! grep -qE '^AUTH_PASSWORD=.+' .env; then
warn "AUTH_PASSWORD is blank — the browser UI is open to anyone who finds the"
warn "domain. Set it in .env and re-run, or run:"
warn " cd $REPO_DIR && ${COMPOSE[*]} exec app python -m app.cli set-password"
fi
# ---- 3. nginx + TLS --------------------------------------------------------
VHOST_SRC="deploy/nginx/${DOMAIN}.conf"
VHOST_DST="/etc/nginx/sites-available/${DOMAIN}"
CERT_DIR="/etc/letsencrypt/live/${DOMAIN}"
mkdir -p /var/www/certbot
if [[ ! -d $CERT_DIR ]]; then
say "No certificate yet — serving the ACME challenge over plain HTTP first"
# The repo vhost references a cert that does not exist, so nginx would refuse
# to load it. Stand up an HTTP-only vhost just long enough to be validated.
cat > "$VHOST_DST" <<EOF
server {
listen 80;
listen [::]:80;
server_name ${DOMAIN};
location /.well-known/acme-challenge/ { root /var/www/certbot; }
location / { return 503; }
}
EOF
ln -sfn "$VHOST_DST" "/etc/nginx/sites-enabled/${DOMAIN}"
nginx -t && systemctl reload nginx
say "Requesting a certificate for ${DOMAIN}"
# `certonly --webroot`, not `--nginx`: the vhost is managed in git and
# certbot's nginx installer would rewrite it behind our back.
certbot certonly --webroot -w /var/www/certbot -d "$DOMAIN" \
--non-interactive --agree-tos --register-unsafely-without-email \
--keep-until-expiring
fi
# `certonly` renews the files but tells nothing to pick them up, so without a
# deploy hook nginx keeps serving the old certificate until something reloads it
# by chance — i.e. it silently expires. `--nginx` would have done this for us;
# this is the price of keeping the vhost in git.
say "Installing the renewal hook"
install -d /etc/letsencrypt/renewal-hooks/deploy
cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh <<'HOOK'
#!/bin/sh
# Installed by kadence's deploy/deploy.sh. Runs after any successful renewal.
nginx -t && systemctl reload nginx
HOOK
chmod 755 /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
say "Installing the vhost"
install -m 644 "$VHOST_SRC" "$VHOST_DST"
ln -sfn "$VHOST_DST" "/etc/nginx/sites-enabled/${DOMAIN}"
nginx -t
systemctl reload nginx
# ---- 4. containers ---------------------------------------------------------
say "Building and starting"
"${COMPOSE[@]}" up -d --build
say "Waiting for the API"
for attempt in $(seq 1 60); do
if curl -fsS "http://${BIND}/api/health" >/dev/null 2>&1; then
echo " healthy after ${attempt}s"
break
fi
if [[ $attempt == 60 ]]; then
warn "still not healthy; last 40 lines of the app log:"
"${COMPOSE[@]}" logs --tail 40 app
exit 1
fi
sleep 1
done
say "Done"
curl -fsS "https://${DOMAIN}/api/health" && echo
curl -fsS "https://${DOMAIN}/api/info" && echo