#!/usr/bin/env bash set -Eeuo pipefail umask 077 # ============================================================ # Configuration # ============================================================ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" SRC="${ROOT}/stacks/nextcloud" LIVE="/srv/nfs/infra/nextcloud" ENV_FILE="${LIVE}/.env" BACKUP_ROOT="/srv/nfs/infra/.deploy-backups/nextcloud" BACKUP_KEEP=3 STAMP="$(date '+%Y%m%d-%H%M%S')" BACKUP_DIR="${BACKUP_ROOT}/${STAMP}" DB_BACKUP_DIR="${BACKUP_DIR}/database" DB_BACKUP_FILE="${DB_BACKUP_DIR}/nextcloud.dump" # ============================================================ # Helpers # ============================================================ log() { echo echo "============================================================" echo "$*" echo "============================================================" } fail() { echo echo "ERROR: $*" >&2 exit 1 } on_error() { rc=$? trap - ERR echo echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" echo " Nextcloud deployment FAILED" echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" if [[ -d "${BACKUP_DIR}" ]]; then echo echo "Pre-upgrade backup:" echo " ${BACKUP_DIR}" fi echo echo "Automatic rollback is intentionally NOT performed." echo "The Nextcloud database schema may already have been modified." echo "Use the pre-upgrade PostgreSQL dump if a rollback is required." exit "${rc}" } cleanup_old_backups() { echo echo "--- Backup retention ---" echo "Keeping last ${BACKUP_KEEP} pre-upgrade backups." mkdir -p "${BACKUP_ROOT}" mapfile -t BACKUPS < <( find "${BACKUP_ROOT}" \ -mindepth 1 \ -maxdepth 1 \ -type d \ -printf '%f\n' | grep -E '^[0-9]{8}-[0-9]{6}$' | sort -r ) if (( ${#BACKUPS[@]} <= BACKUP_KEEP )); then echo "Current backup count: ${#BACKUPS[@]}" echo "Nothing to delete." return 0 fi for BACKUP_NAME in "${BACKUPS[@]:${BACKUP_KEEP}}" do OLD_BACKUP="${BACKUP_ROOT}/${BACKUP_NAME}" echo echo "Removing old backup:" echo " ${OLD_BACKUP}" rm -rf -- "${OLD_BACKUP}" done } version_less_than() { local A="$1" local B="$2" [[ "${A}" == "${B}" ]] && return 1 [[ "$( printf '%s\n%s\n' "${A}" "${B}" | sort -V | head -n1 )" == "${A}" ]] } trap on_error ERR # ============================================================ # Required commands # ============================================================ log "Pre-flight checks" for CMD in \ docker \ psql \ pg_dump \ pg_restore \ sha256sum \ sort \ grep \ find do command -v "${CMD}" >/dev/null 2>&1 || \ fail "Required command not found: ${CMD}" done docker compose version >/dev/null 2>&1 || \ fail "Docker Compose plugin is not available" # ============================================================ # Required files # ============================================================ [[ -f "${SRC}/docker-compose.yml" ]] || \ fail "${SRC}/docker-compose.yml not found" [[ -f "${LIVE}/docker-compose.yml" ]] || \ fail "${LIVE}/docker-compose.yml not found" [[ -f "${ENV_FILE}" ]] || \ fail "${ENV_FILE} not found" # ============================================================ # Backup directory access # ============================================================ mkdir -p "${BACKUP_ROOT}" [[ -w "${BACKUP_ROOT}" ]] || \ fail "${BACKUP_ROOT} is not writable" # ============================================================ # Load PostgreSQL settings # # .env must be shell-compatible. # # Example: # # POSTGRES_HOST=192.168.1.20 # POSTGRES_DB=nextcloud # POSTGRES_USER=nextcloud # POSTGRES_PASSWORD='complex@password' # ============================================================ set -a # shellcheck disable=SC1090 source "${ENV_FILE}" set +a : "${POSTGRES_HOST:?POSTGRES_HOST not defined}" : "${POSTGRES_DB:?POSTGRES_DB not defined}" : "${POSTGRES_USER:?POSTGRES_USER not defined}" : "${POSTGRES_PASSWORD:?POSTGRES_PASSWORD not defined}" export PGPASSWORD="${POSTGRES_PASSWORD}" export PGCONNECT_TIMEOUT=10 # ============================================================ # Validate requested Compose # ============================================================ log "Validate Docker Compose" docker compose \ -f "${SRC}/docker-compose.yml" \ config >/dev/null echo "Docker Compose configuration is valid." # ============================================================ # Detect requested Nextcloud image # # nextcloud and cron MUST use exactly the same image. # ============================================================ log "Detect requested Nextcloud image" mapfile -t NEXTCLOUD_IMAGES < <( docker compose \ -f "${SRC}/docker-compose.yml" \ config --images | grep '^nextcloud:' | sort -u ) if [[ "${#NEXTCLOUD_IMAGES[@]}" -ne 1 ]]; then echo "Detected Nextcloud images:" if (( ${#NEXTCLOUD_IMAGES[@]} > 0 )); then printf ' %s\n' "${NEXTCLOUD_IMAGES[@]}" else echo " none" fi fail "Services nextcloud and cron must use exactly the same Nextcloud image" fi NEW_IMAGE="${NEXTCLOUD_IMAGES[0]}" echo "Requested image:" echo " ${NEW_IMAGE}" # ============================================================ # Current Nextcloud container # ============================================================ docker inspect nextcloud >/dev/null 2>&1 || \ fail "Current Nextcloud container does not exist" CURRENT_RUNNING="$( docker inspect \ -f '{{.State.Running}}' \ nextcloud )" [[ "${CURRENT_RUNNING}" == "true" ]] || \ fail "Current Nextcloud container is not running" OLD_IMAGE="$( docker inspect \ -f '{{.Config.Image}}' \ nextcloud )" # ============================================================ # Detect actual current Nextcloud version # ============================================================ log "Detect current Nextcloud version" CURRENT_NC_VERSION="$( docker exec \ nextcloud \ php -r ' require "/var/www/html/version.php"; echo implode(".", $OC_Version); ' )" [[ -n "${CURRENT_NC_VERSION}" ]] || \ fail "Could not determine current Nextcloud version" echo "Current Docker image:" echo " ${OLD_IMAGE}" echo "Current Nextcloud version:" echo " ${CURRENT_NC_VERSION}" # ============================================================ # Pull target Nextcloud image # # This does not modify the running container. # ============================================================ log "Pull target Nextcloud image" docker pull "${NEW_IMAGE}" # ============================================================ # Determine actual Nextcloud version in target image # ============================================================ log "Detect target Nextcloud version" TARGET_NC_VERSION="$( docker run \ --rm \ --entrypoint php \ "${NEW_IMAGE}" \ -r ' require "/usr/src/nextcloud/version.php"; echo implode(".", $OC_Version); ' )" [[ -n "${TARGET_NC_VERSION}" ]] || \ fail "Could not determine target Nextcloud version" echo "Target Docker image:" echo " ${NEW_IMAGE}" echo "Target Nextcloud version:" echo " ${TARGET_NC_VERSION}" # ============================================================ # Version safety checks # ============================================================ log "Validate version transition" # ------------------------------------------------------------ # Downgrade protection # ------------------------------------------------------------ if version_less_than \ "${TARGET_NC_VERSION}" \ "${CURRENT_NC_VERSION}" then fail \ "Nextcloud downgrade is not allowed: ${CURRENT_NC_VERSION} -> ${TARGET_NC_VERSION}" fi CURRENT_MAJOR="${CURRENT_NC_VERSION%%.*}" TARGET_MAJOR="${TARGET_NC_VERSION%%.*}" # ------------------------------------------------------------ # Prevent skipping major versions # ------------------------------------------------------------ if (( TARGET_MAJOR > CURRENT_MAJOR + 1 )) then fail \ "Skipping Nextcloud major versions is forbidden: ${CURRENT_MAJOR} -> ${TARGET_MAJOR}" fi # ------------------------------------------------------------ # Determine whether actual Nextcloud version changed # ------------------------------------------------------------ VERSION_CHANGED=false if [[ "${CURRENT_NC_VERSION}" != "${TARGET_NC_VERSION}" ]] then VERSION_CHANGED=true fi echo "Current version: ${CURRENT_NC_VERSION}" echo "Target version: ${TARGET_NC_VERSION}" echo "Version changed: ${VERSION_CHANGED}" # ============================================================ # Check current Nextcloud status # ============================================================ log "Check current Nextcloud status" CURRENT_STATUS="$( docker exec \ -u www-data \ nextcloud \ php occ status )" echo "${CURRENT_STATUS}" echo "${CURRENT_STATUS}" | grep -Eq 'needsDbUpgrade:[[:space:]]*false' || \ fail "Current Nextcloud already requires a DB upgrade" echo "${CURRENT_STATUS}" | grep -Eq 'maintenance:[[:space:]]*false' || \ fail "Current Nextcloud is already in maintenance mode" docker exec \ -u www-data \ nextcloud \ php occ status -e # ============================================================ # PRE-UPGRADE BACKUP # # Created ONLY if actual Nextcloud version changes. # ============================================================ if [[ "${VERSION_CHANGED}" == "true" ]] then log "Prepare Nextcloud pre-upgrade backup" # -------------------------------------------------------- # Create backup directories # -------------------------------------------------------- mkdir -p "${DB_BACKUP_DIR}" # -------------------------------------------------------- # Save current Compose # # Do NOT use cp -a because this NFS does not support # preserving all POSIX metadata. # -------------------------------------------------------- echo echo "--- Backup current docker-compose.yml ---" cp \ "${LIVE}/docker-compose.yml" \ "${BACKUP_DIR}/docker-compose.yml" # -------------------------------------------------------- # Save current Nextcloud status # -------------------------------------------------------- printf '%s\n' "${CURRENT_STATUS}" \ > "${BACKUP_DIR}/nextcloud-status-before.txt" # -------------------------------------------------------- # Save metadata # -------------------------------------------------------- cat > "${BACKUP_DIR}/metadata.txt" < "${DB_BACKUP_DIR}/postgresql-version.txt" SERVER_MAJOR="${SERVER_VERSION%%.*}" PG_DUMP_VERSION="$( pg_dump --version | sed -E 's/.* ([0-9]+(\.[0-9]+)?).*/\1/' )" PG_DUMP_MAJOR="${PG_DUMP_VERSION%%.*}" echo "PostgreSQL server:" echo " ${SERVER_VERSION}" echo "pg_dump client:" echo " ${PG_DUMP_VERSION}" if (( PG_DUMP_MAJOR < SERVER_MAJOR )) then fail \ "pg_dump ${PG_DUMP_VERSION} is older than PostgreSQL server ${SERVER_VERSION}" fi # ======================================================== # Database size # ======================================================== echo echo "--- Database size ---" DB_SIZE="$( psql \ -h "${POSTGRES_HOST}" \ -U "${POSTGRES_USER}" \ -d "${POSTGRES_DB}" \ -Atqc \ 'SELECT pg_size_pretty(pg_database_size(current_database()));' )" echo "Database:" echo " ${POSTGRES_DB}" echo "Size:" echo " ${DB_SIZE}" # ======================================================== # PostgreSQL dump # ======================================================== log "Create PostgreSQL backup" pg_dump \ -h "${POSTGRES_HOST}" \ -U "${POSTGRES_USER}" \ -d "${POSTGRES_DB}" \ --format=custom \ --no-owner \ --no-privileges \ --file="${DB_BACKUP_FILE}" [[ -s "${DB_BACKUP_FILE}" ]] || \ fail "PostgreSQL dump does not exist or is empty" # ======================================================== # Validate PostgreSQL dump # ======================================================== echo echo "--- Validate PostgreSQL dump ---" pg_restore \ --list \ "${DB_BACKUP_FILE}" \ > "${DB_BACKUP_DIR}/nextcloud.dump.list" [[ -s "${DB_BACKUP_DIR}/nextcloud.dump.list" ]] || \ fail "pg_restore could not validate PostgreSQL dump" # ======================================================== # SHA256 checksum # ======================================================== echo echo "--- Generate SHA256 ---" ( cd "${DB_BACKUP_DIR}" sha256sum \ nextcloud.dump \ > nextcloud.dump.sha256 ) sha256sum \ -c "${DB_BACKUP_DIR}/nextcloud.dump.sha256" DB_BACKUP_SIZE="$( du -h "${DB_BACKUP_FILE}" | awk '{print $1}' )" echo echo "PostgreSQL backup successful." echo echo "Backup directory:" echo " ${BACKUP_DIR}" echo echo "Database dump:" echo " ${DB_BACKUP_FILE}" echo echo "Dump size:" echo " ${DB_BACKUP_SIZE}" echo echo "SHA256:" cat "${DB_BACKUP_DIR}/nextcloud.dump.sha256" # ======================================================== # Backup retention # # The new backup has already been successfully validated, # so it is now safe to remove older backups. # ======================================================== cleanup_old_backups fi # ============================================================ # Deploy requested Compose # # Production is not touched until pre-upgrade backup succeeds. # ============================================================ log "Deploy docker-compose.yml" cp \ "${SRC}/docker-compose.yml" \ "${LIVE}/docker-compose.yml" cd "${LIVE}" # ============================================================ # Pull all Compose images # ============================================================ log "Pull Docker images" docker compose pull # ============================================================ # NEXTCLOUD VERSION UPGRADE # ============================================================ if [[ "${VERSION_CHANGED}" == "true" ]] then log "Nextcloud upgrade ${CURRENT_NC_VERSION} -> ${TARGET_NC_VERSION}" # ======================================================== # Stop cron during application/DB upgrade # ======================================================== echo echo "--- Stop Nextcloud cron ---" docker compose stop cron || true # ======================================================== # Timestamp used for startup log analysis # ======================================================== UPGRADE_STARTED="$( date -u '+%Y-%m-%dT%H:%M:%SZ' )" # ======================================================== # Recreate ONLY main Nextcloud container first # # Redis remains running. # Cron remains stopped. # ======================================================== echo echo "--- Recreate Nextcloud container ---" docker compose \ up -d \ --no-deps \ nextcloud # ======================================================== # Wait for official Nextcloud Docker entrypoint # # The official image may execute occ upgrade automatically. # # 180 * 5 sec = 15 minutes maximum. # ======================================================== log "Wait for Nextcloud initialization" INITIALIZED=false for _ in $(seq 1 180) do RUNNING="$( docker inspect \ -f '{{.State.Running}}' \ nextcloud 2>/dev/null || echo false )" if [[ "${RUNNING}" != "true" ]] then echo echo "--- Nextcloud container log ---" docker logs \ --since "${UPGRADE_STARTED}" \ nextcloud || true fail "Nextcloud container stopped during upgrade" fi STARTUP_LOG="$( docker logs \ --since "${UPGRADE_STARTED}" \ nextcloud 2>&1 || true )" # Explicit failure from Nextcloud upgrade. if echo "${STARTUP_LOG}" | grep -q 'Update failed' then echo "${STARTUP_LOG}" fail "Nextcloud automatic upgrade failed" fi # Standard completion marker of official image. if echo "${STARTUP_LOG}" | grep -q 'Initializing finished' then INITIALIZED=true break fi sleep 5 done if [[ "${INITIALIZED}" != "true" ]] then echo echo "--- Nextcloud container log ---" docker logs \ --since "${UPGRADE_STARTED}" \ nextcloud || true fail "Timeout waiting for Nextcloud initialization" fi # ======================================================== # Capture entrypoint upgrade result # ======================================================== log "Nextcloud startup upgrade output" STARTUP_LOG="$( docker logs \ --since "${UPGRADE_STARTED}" \ nextcloud 2>&1 )" echo "${STARTUP_LOG}" STARTUP_UPDATE_SUCCESS=false if echo "${STARTUP_LOG}" | grep -q 'Update successful' then STARTUP_UPDATE_SUCCESS=true fi echo echo "Startup log contains 'Update successful':" echo " ${STARTUP_UPDATE_SUCCESS}" # ======================================================== # Explicit OCC upgrade # # If Docker entrypoint already performed the migration, # occ upgrade can report that Nextcloud is already current. # ======================================================== log "Run explicit occ upgrade" set +e OCC_UPGRADE_OUTPUT="$( docker exec \ -u www-data \ nextcloud \ php occ upgrade 2>&1 )" OCC_UPGRADE_RC=$? set -e echo "${OCC_UPGRADE_OUTPUT}" if [[ "${OCC_UPGRADE_RC}" -ne 0 ]] then fail \ "Explicit occ upgrade returned exit code ${OCC_UPGRADE_RC}" fi EXPLICIT_UPDATE_SUCCESS=false EXPLICIT_ALREADY_CURRENT=false if echo "${OCC_UPGRADE_OUTPUT}" | grep -q 'Update successful' then EXPLICIT_UPDATE_SUCCESS=true fi if echo "${OCC_UPGRADE_OUTPUT}" | grep -Eqi \ 'already.*(latest|up.?to.?date)|(is|already).*up.?to.?date' then EXPLICIT_ALREADY_CURRENT=true fi echo echo "Explicit occ upgrade contains 'Update successful':" echo " ${EXPLICIT_UPDATE_SUCCESS}" echo "Explicit occ upgrade reports already current:" echo " ${EXPLICIT_ALREADY_CURRENT}" # ======================================================== # Require confirmed update # # Either: # Docker entrypoint -> Update successful # # or: # explicit occ upgrade -> Update successful # # ======================================================== if [[ "${STARTUP_UPDATE_SUCCESS}" != "true" ]] && [[ "${EXPLICIT_UPDATE_SUCCESS}" != "true" ]] then if [[ "${EXPLICIT_ALREADY_CURRENT}" == "true" ]] then echo echo "occ reports that Nextcloud is already current," echo "but 'Update successful' was not found in either" echo "the startup log or explicit occ upgrade output." fi fail "Could not confirm 'Update successful'" fi # ======================================================== # Validate Nextcloud state after DB migration # ======================================================== log "Validate Nextcloud status after upgrade" FINAL_STATUS="$( docker exec \ -u www-data \ nextcloud \ php occ status )" echo "${FINAL_STATUS}" echo "${FINAL_STATUS}" | grep -Eq 'needsDbUpgrade:[[:space:]]*false' || \ fail "needsDbUpgrade is not false" echo "${FINAL_STATUS}" | grep -Eq 'maintenance:[[:space:]]*false' || \ fail "maintenance is not false" # -------------------------------------------------------- # Nextcloud: # # 0 = normal # 1 = maintenance # 2 = DB upgrade required # -------------------------------------------------------- docker exec \ -u www-data \ nextcloud \ php occ status -e echo echo "Nextcloud database state validated successfully." # ======================================================== # Start cron using upgraded image # ======================================================== log "Start Nextcloud cron" docker compose \ up -d \ --no-deps \ cron # ======================================================== # Apply remaining Compose changes # ======================================================== log "Apply remaining Compose changes" docker compose \ up -d \ --remove-orphans # ============================================================ # NO NEXTCLOUD VERSION CHANGE # ============================================================ else log "No Nextcloud version change" echo "Current version:" echo " ${CURRENT_NC_VERSION}" docker compose \ up -d \ --remove-orphans fi # ============================================================ # Final container checks # ============================================================ log "Final container checks" for CONTAINER in \ nextcloud \ nextcloud-cron \ nextcloud-redis do RUNNING="$( docker inspect \ -f '{{.State.Running}}' \ "${CONTAINER}" 2>/dev/null || echo false )" if [[ "${RUNNING}" != "true" ]] then fail "${CONTAINER} is not running" fi echo "${CONTAINER}: running" done # ============================================================ # Final Nextcloud status # ============================================================ log "Final OCC status" FINAL_STATUS="$( docker exec \ -u www-data \ nextcloud \ php occ status )" echo "${FINAL_STATUS}" echo "${FINAL_STATUS}" | grep -Eq 'needsDbUpgrade:[[:space:]]*false' || \ fail "Final needsDbUpgrade is not false" echo "${FINAL_STATUS}" | grep -Eq 'maintenance:[[:space:]]*false' || \ fail "Final maintenance is not false" docker exec \ -u www-data \ nextcloud \ php occ status -e # ============================================================ # Show actual running version # ============================================================ RUNNING_NC_VERSION="$( docker exec \ nextcloud \ php -r ' require "/var/www/html/version.php"; echo implode(".", $OC_Version); ' )" RUNNING_IMAGE="$( docker inspect \ -f '{{.Config.Image}}' \ nextcloud )" # ============================================================ # Success # ============================================================ log "Nextcloud deployment successful" echo "Previous Nextcloud version:" echo " ${CURRENT_NC_VERSION}" echo echo "Running Nextcloud version:" echo " ${RUNNING_NC_VERSION}" echo echo "Previous Docker image:" echo " ${OLD_IMAGE}" echo echo "Running Docker image:" echo " ${RUNNING_IMAGE}" if [[ "${VERSION_CHANGED}" == "true" ]] then echo echo "Pre-upgrade backup:" echo " ${BACKUP_DIR}" echo echo "PostgreSQL dump:" echo " ${DB_BACKUP_FILE}" echo echo "Backup retention:" echo " last ${BACKUP_KEEP} backups" fi # ============================================================ # Remove credentials from process environment # ============================================================ unset PGPASSWORD unset POSTGRES_PASSWORD