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>
178 lines
6.4 KiB
Bash
Executable File
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
|