#!/usr/bin/env bash # Release deployment for desfoto.de. # # .ocauto/deploy # # Ships the committed static build to the production host, starts the stack # behind the existing Traefik instance, and removes the obsolete # "desfoto.de -> dennyschulz.de" redirect from the shared /srv/stack project. # # Rollback: the state before every release is snapshotted to # /home/denny/stacks/desfoto-releases/-/ # including the shared stack file that the release modified. Restore it with # ROLLBACK_TO= .ocauto/deploy rollback # The snapshot is written even for the very first release, when no site is live # yet; rolling back to that snapshot then removes the new stack again and puts # the previous /srv/stack configuration back. set -euo pipefail cd "$(dirname "$0")/.." root="$(pwd)" release_sha="${1:-}" remote="${DESFOTO_REMOTE:-prod-main}" live_dir="/home/denny/stacks/desfoto" releases_dir="/home/denny/stacks/desfoto-releases" stack_dir="/srv/stack" log() { printf '[deploy] %s\n' "$1"; } die() { printf '[deploy] ERROR: %s\n' "$1" >&2; exit 1; } if [ "$release_sha" = "rollback" ]; then target="${ROLLBACK_TO:-}" [ -n "$target" ] || die "ROLLBACK_TO must name a snapshot directory" log "rolling back to $target" # shellcheck disable=SC2029 ssh "$remote" "set -euo pipefail sudo test -d '$target' if sudo test -d '$target/site'; then sudo rsync -a --delete --chown=denny:denny '$target/site/' '$live_dir/site/' sudo cp '$target/nginx.conf' '$live_dir/nginx.conf' sudo cp '$target/compose.yml' '$live_dir/compose.yml' sudo cp '$target/compose.vps.yml' '$live_dir/compose.vps.yml' cd '$live_dir' sudo docker compose -f compose.yml -f compose.vps.yml up -d --remove-orphans else echo '[deploy] initial-state snapshot: removing the desfoto stack again' if sudo test -d '$live_dir'; then ( cd '$live_dir' && sudo docker compose -f compose.yml -f compose.vps.yml down --remove-orphans ) fi fi if sudo test -f '$target/docker-compose.yml.stack-backup'; then echo '[deploy] restoring the previous shared stack configuration' sudo cp '$target/docker-compose.yml.stack-backup' '$stack_dir/docker-compose.yml' cd '$stack_dir' sudo docker compose up -d --no-deps landing fi" log "rollback started" exit 0 fi [ -n "$release_sha" ] || die "usage: .ocauto/deploy " [ "$(git rev-parse HEAD)" = "$release_sha" ] || die "HEAD does not match $release_sha" [ -z "$(git status --porcelain -- site nginx.conf compose.yml compose.vps.yml)" ] \ || die "the deployable files have uncommitted changes" tmp="$(mktemp -d)" trap 'rm -rf "$tmp"' EXIT log "packing $release_sha" tar -czf "$tmp/desfoto-$release_sha.tgz" -C "$root" site nginx.conf compose.yml compose.vps.yml log "uploading" scp -q "$tmp/desfoto-$release_sha.tgz" "$remote:/tmp/desfoto-$release_sha.tgz" log "deploying on $remote" # shellcheck disable=SC2029 ssh "$remote" "REMOTE_SHA='$release_sha' LIVE_DIR='$live_dir' RELEASES_DIR='$releases_dir' STACK_DIR='$stack_dir' bash -s" <<'REMOTE' set -euo pipefail stamp="$(date -u +%Y%m%dT%H%M%SZ)" snapshot="$RELEASES_DIR/$stamp-$REMOTE_SHA" archive="/tmp/desfoto-$REMOTE_SHA.tgz" # 1. Snapshot the current state so a rollback stays possible. This also runs for # the very first release (no site live yet) so ROLLBACK_TO always has a target. sudo mkdir -p "$snapshot" "$RELEASES_DIR" if sudo test -d "$LIVE_DIR"; then echo "[deploy] snapshot -> $snapshot" sudo cp -a "$LIVE_DIR/." "$snapshot/" else echo "[deploy] no previous release; $snapshot records the initial state" fi sudo ln -sfn "$snapshot" "$RELEASES_DIR/previous" # 2. Unpack the new release. echo "[deploy] unpacking $archive" sudo mkdir -p "$LIVE_DIR" sudo rm -rf "$LIVE_DIR/site" sudo tar -xzf "$archive" -C "$LIVE_DIR" sudo chown -R denny:denny "$LIVE_DIR" sudo rm -f "$archive" printf '%s\n' "$REMOTE_SHA" | sudo tee "$LIVE_DIR/RELEASE" >/dev/null # 3. Start or update the stack. cd "$LIVE_DIR" sudo docker compose -f compose.yml -f compose.vps.yml up -d --remove-orphans # 4. Wait for the container health check. for _ in $(seq 1 30); do status="$(sudo docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' desfoto-web-1 2>/dev/null || echo missing)" [ "$status" = "healthy" ] && break [ "$status" = "missing" ] && break sleep 2 done echo "[deploy] desfoto-web-1: $status" if [ "$status" != "healthy" ]; then echo "[deploy] ERROR: container is not healthy" >&2 sudo docker logs --tail 40 desfoto-web-1 >&2 || true exit 1 fi # 5. Remove the obsolete desfoto.de redirect from the shared stack project. # The new stack already owns desfoto.de through a higher Traefik priority, # this only deletes the now-dead labels so Traefik no longer advertises them. # Stripped are Traefik label lines that mention desfoto (the old routers # "desfoto-redirect"/"desfoto-redirect-http", the middleware # "desfoto-to-website" and any related key) plus the comment that grouped # them; every other line is copied verbatim. The result is validated as a # Compose project *before* it replaces the live file, and the replacement # itself is a single atomic rename. stack_file="$STACK_DIR/docker-compose.yml" if sudo grep -q 'desfoto' "$stack_file"; then echo "[deploy] removing obsolete desfoto redirect labels from $stack_file" sudo cp "$stack_file" "$STACK_DIR/docker-compose.yml.bak.$stamp" sudo cp "$stack_file" "$snapshot/docker-compose.yml.stack-backup" # The temp file lives next to the original (same filesystem, hidden name that # Compose ignores) so the final mv is atomic. stack_tmp="$(sudo mktemp "$STACK_DIR/.docker-compose.yml.desfoto.XXXXXX")" set +e # Strip only lines that are Traefik labels mentioning desfoto (list or map # syntax, key or value side) plus the comment that grouped the old redirect. # The generic "desfoto" substring filter of the previous version could have # deleted unrelated lines; this one is anchored to label lines. sudo awk ' /^[[:space:]]*-?[[:space:]]*"?traefik\..*desfoto/ { next } /# desfoto\.de -> kanonische Fotografen-Website/ { next } { print } ' "$stack_file" | sudo tee "$stack_tmp" >/dev/null strip_status="${PIPESTATUS[0]}" set -e if [ "$strip_status" -ne 0 ]; then sudo rm -f "$stack_tmp" echo "[deploy] ERROR: cannot read $stack_file (awk exit $strip_status)" >&2 exit 1 fi sudo chown --reference="$stack_file" "$stack_tmp" sudo chmod --reference="$stack_file" "$stack_tmp" # Validate the stripped file as the same Compose project before touching the # live configuration. The project directory stays $STACK_DIR because the first # -f file lives there. validate=(docker compose -f "$stack_tmp") if sudo test -f "$STACK_DIR/docker-compose.override.yml"; then validate+=(-f "$STACK_DIR/docker-compose.override.yml") fi validate+=(config --quiet) if ! ( cd "$STACK_DIR" && sudo "${validate[@]}" ); then sudo rm -f "$stack_tmp" echo "[deploy] ERROR: stripped stack file is not valid; live file left untouched" >&2 exit 1 fi sudo mv "$stack_tmp" "$stack_file" remaining="$(sudo grep -n 'desfoto' "$stack_file" || true)" if [ -n "$remaining" ]; then echo "[deploy] WARNING: desfoto references remain in $stack_file:" >&2 printf '%s\n' "$remaining" >&2 fi ( cd "$STACK_DIR" && sudo docker compose up -d --no-deps landing ) echo "[deploy] landing recreated without the desfoto redirect" else echo "[deploy] obsolete redirect labels already absent" fi # 6. Record the release marker. echo "[deploy] done: $REMOTE_SHA" REMOTE log "release $release_sha deployed"