#!/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" < /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