#!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" APP_HOME="$(cd "${SCRIPT_DIR}/.." && pwd)" RUN_DIR="${APP_HOME}/run" DEPLOY_DIR="${APP_HOME}/deploy" LOCK_DIR="${RUN_DIR}/deploy.lock" APP_DIR="${APP_HOME}/app" CONFIG_DIR="${APP_HOME}/config" DATA_DIR="${APP_HOME}/data" CONFIG_FILE="${CONFIG_DIR}/music_server.env" LEGACY_CONFIG_FILE="${APP_DIR}/config/music_server.env" LEGACY_CONFIG_EXAMPLE="${APP_DIR}/config/music_server.env.example" LEGACY_DATA_DIR="${APP_DIR}/data" DEFAULT_STAGING_DIR="${APP_HOME}/deploy/staging/music-server-app" BACKUP_ROOT="${APP_HOME}/deploy/backups" COMPOSE_FILE="${APP_DIR}/docker-compose.nas.yml" DOCKER_BIN_DIR="/var/packages/Docker/target/usr/bin" STAGING_DIR="${DEFAULT_STAGING_DIR}" HEALTH_URL="http://127.0.0.1:18081/healthz" HEALTH_RETRIES=45 HEALTH_INTERVAL_SECONDS=2 KEEP_BACKUPS=3 SKIP_HEALTH_CHECK=0 BACKUP_DIR="" HAS_BACKUP=0 usage() { cat <&2 exit 1 } validate_positive_integer() { local value="$1" local name="$2" if ! [[ "${value}" =~ ^[0-9]+$ ]] || (( value < 1 )); then fail "${name} must be a positive integer: ${value}" fi } acquire_deploy_lock() { mkdir -p "${RUN_DIR}" if mkdir "${LOCK_DIR}" 2>/dev/null; then echo "$$" > "${LOCK_DIR}/owner_pid" return 0 fi local owner_pid="" if [[ -f "${LOCK_DIR}/owner_pid" ]]; then owner_pid="$(cat "${LOCK_DIR}/owner_pid" 2>/dev/null || true)" fi if [[ -n "${owner_pid}" ]] && kill -0 "${owner_pid}" 2>/dev/null; then fail "Another deploy is running (owner_pid=${owner_pid})" fi rm -rf "${LOCK_DIR}" if ! mkdir "${LOCK_DIR}" 2>/dev/null; then fail "Cannot acquire deploy lock: ${LOCK_DIR}" fi echo "$$" > "${LOCK_DIR}/owner_pid" } cleanup_lock() { rm -rf "${LOCK_DIR}" } ensure_runtime_layout() { mkdir -p "${CONFIG_DIR}" "${DATA_DIR}" "${DEPLOY_DIR}" "${BACKUP_ROOT}" "${RUN_DIR}" if [[ ! -f "${CONFIG_FILE}" ]]; then if [[ -f "${LEGACY_CONFIG_FILE}" ]]; then cp -a "${LEGACY_CONFIG_FILE}" "${CONFIG_FILE}" log "Copied legacy runtime config to ${CONFIG_FILE}" elif [[ -f "${LEGACY_CONFIG_EXAMPLE}" ]]; then cp -a "${LEGACY_CONFIG_EXAMPLE}" "${CONFIG_FILE}" log "Bootstrapped runtime config from example: ${CONFIG_FILE}" fi fi if [[ -d "${LEGACY_DATA_DIR}" ]] && [[ -z "$(ls -A "${DATA_DIR}" 2>/dev/null || true)" ]]; then cp -a "${LEGACY_DATA_DIR}/." "${DATA_DIR}/" log "Copied legacy runtime data into ${DATA_DIR}" fi } require_staging_checkout() { if [[ ! -d "${STAGING_DIR}" ]]; then fail "Staging directory not found: ${STAGING_DIR}" fi if [[ ! -f "${STAGING_DIR}/docker-compose.nas.yml" ]]; then fail "Invalid staging checkout (missing docker-compose.nas.yml): ${STAGING_DIR}" fi if [[ ! -f "${STAGING_DIR}/Dockerfile" ]]; then fail "Invalid staging checkout (missing Dockerfile): ${STAGING_DIR}" fi } sync_app_checkout() { require_staging_checkout BACKUP_DIR="${BACKUP_ROOT}/app_$(date +%Y%m%d_%H%M%S)" if [[ -d "${APP_DIR}" ]]; then mv "${APP_DIR}" "${BACKUP_DIR}" HAS_BACKUP=1 log "Backed up current app to ${BACKUP_DIR}" fi cp -a "${STAGING_DIR}" "${APP_DIR}" log "Synced new app checkout from ${STAGING_DIR} -> ${APP_DIR}" } run_compose() { export PATH="${DOCKER_BIN_DIR}:${PATH}" cd "${APP_DIR}" docker-compose -f "${COMPOSE_FILE}" "$@" } remove_conflicting_named_containers() { export PATH="${DOCKER_BIN_DIR}:${PATH}" docker rm -f music-server >/dev/null 2>&1 || true } restart_service() { run_compose build catalog-export music-server remove_conflicting_named_containers run_compose down --remove-orphans || true run_compose run --rm catalog-export run_compose up -d music-server } wait_health() { if (( SKIP_HEALTH_CHECK == 1 )); then log "Health check skipped by --skip-health-check" return 0 fi if ! command -v curl >/dev/null 2>&1; then fail "curl is required for health check" fi log "Health checking: ${HEALTH_URL}" for _ in $(seq 1 "${HEALTH_RETRIES}"); do if curl -fsS "${HEALTH_URL}" >/dev/null; then log "Health check passed" return 0 fi sleep "${HEALTH_INTERVAL_SECONDS}" done log "Health check failed: ${HEALTH_URL}" return 1 } rollback() { log "Starting rollback..." if (( HAS_BACKUP == 0 )) || [[ ! -d "${BACKUP_DIR}" ]]; then log "No backup available; rollback skipped" return 1 fi rm -rf "${APP_DIR}" mv "${BACKUP_DIR}" "${APP_DIR}" HAS_BACKUP=0 log "Restored backup to ${APP_DIR}" restart_service wait_health } prune_backups() { if (( KEEP_BACKUPS < 1 )) || [[ ! -d "${BACKUP_ROOT}" ]]; then return 0 fi mapfile -t backups < <(ls -1dt "${BACKUP_ROOT}"/app_* 2>/dev/null || true) if (( ${#backups[@]} <= KEEP_BACKUPS )); then return 0 fi for old_backup in "${backups[@]:KEEP_BACKUPS}"; do rm -rf "${old_backup}" log "Pruned old backup: ${old_backup}" done } while [[ $# -gt 0 ]]; do case "$1" in --staging-dir) STAGING_DIR="${2:-}" shift 2 ;; --health-url) HEALTH_URL="${2:-}" shift 2 ;; --health-retries) HEALTH_RETRIES="${2:-}" shift 2 ;; --health-interval-sec) HEALTH_INTERVAL_SECONDS="${2:-}" shift 2 ;; --keep-backups) KEEP_BACKUPS="${2:-}" shift 2 ;; --skip-health-check) SKIP_HEALTH_CHECK=1 shift ;; -h|--help) usage exit 0 ;; *) fail "Unknown argument: $1" ;; esac done validate_positive_integer "${HEALTH_RETRIES}" "HEALTH_RETRIES" validate_positive_integer "${HEALTH_INTERVAL_SECONDS}" "HEALTH_INTERVAL_SECONDS" validate_positive_integer "${KEEP_BACKUPS}" "KEEP_BACKUPS" ensure_runtime_layout acquire_deploy_lock trap cleanup_lock EXIT INT TERM LOG_FILE="${DEPLOY_DIR}/deploy_$(date +%Y%m%d_%H%M%S).log" exec > >(tee -a "${LOG_FILE}") 2>&1 log "Starting deploy. staging=${STAGING_DIR}" log "App home: ${APP_HOME}" log "Runtime config: ${CONFIG_FILE}" log "Runtime data: ${DATA_DIR}" log "Deploy log: ${LOG_FILE}" if ! sync_app_checkout; then fail "Sync step failed" fi ensure_runtime_layout if [[ ! -f "${CONFIG_FILE}" ]]; then fail "Runtime config not found: ${CONFIG_FILE}" fi if ! restart_service; then log "Restart failed; attempting rollback." if rollback; then fail "Deploy failed during restart; rollback succeeded." fi fail "Deploy failed during restart; rollback failed." fi if ! wait_health; then log "New version failed health check; attempting rollback." if rollback; then fail "Deploy failed during health check; rollback succeeded." fi fail "Deploy failed during health check; rollback failed." fi prune_backups log "Deploy succeeded."