#!/usr/bin/env bash set -Eeuo pipefail case "$0" in */*) SCRIPT_PATH="$0" ;; *) SCRIPT_PATH="./$0" ;; esac SRC_DIR=$(CDPATH= cd -- "${SCRIPT_PATH%/*}" && pwd) INSTALL_DIR=${INSTALL_DIR:-/srv/taiko-web} DATA_DIR=${DATA_DIR:-/srv/taiko-web-data} SERVICE_NAME=taiko-web COMPOSE_PROJECT_NAME=taiko-web APP_USER=${APP_USER:-www-data} APP_GROUP=${APP_GROUP:-www-data} BACKUP_ROOT=${BACKUP_ROOT:-$INSTALL_DIR/backups/mongodb} DESTRUCTIVE_CONFIRMATION=I_UNDERSTAND_THIS_WILL_DELETE_MONGODB_DATA LAST_BACKUP_DIR= UPDATE_BEFORE_SONGS_COUNT=unknown APP_WRITES_QUIESCED=false APP_DIRECT_WAS_ACTIVE=false APP_CONTAINER_WAS_RUNNING=false log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [taiko-web] $*" } require_cmd() { command -v "$1" >/dev/null 2>&1 || { echo "Missing command: $1" exit 1 } } confirm_destructive_action() { echo "This action will DELETE or replace MongoDB data." echo "Type ${DESTRUCTIVE_CONFIRMATION} to continue:" read -r confirmation if [ "$confirmation" != "$DESTRUCTIVE_CONFIRMATION" ]; then echo "Cancelled." exit 1 fi } load_existing_env() { local env_file="$INSTALL_DIR/.env" if [ -f "$env_file" ]; then # shellcheck disable=SC1090 set -a . "$env_file" set +a DATA_DIR=${TAIKO_WEB_DATA_DIR:-$DATA_DIR} COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME:-$COMPOSE_PROJECT_NAME} BACKUP_ROOT=${BACKUP_ROOT:-$INSTALL_DIR/backups/mongodb} fi } ensure_env_value() { local key="$1" local value="$2" local env_file="$INSTALL_DIR/.env" if grep -q "^${key}=" "$env_file" 2>/dev/null; then log ".env already has ${key}; keeping existing value." else printf '%s=%s\n' "$key" "$value" >>"$env_file" log "Added missing ${key} to .env." fi } mongo_data_exists() { if command -v docker >/dev/null 2>&1; then if docker volume inspect taiko_mongodb_data >/dev/null 2>&1; then return 0 fi if docker ps -a --format '{{.Names}}' | grep -Eq '^(taiko-web-mongo|taiko-web-mongo-direct)$'; then return 0 fi fi if [ -f "$DATA_DIR/mongo/WiredTiger" ]; then return 0 fi if [ -d "$DATA_DIR/mongo" ] && [ "$(find "$DATA_DIR/mongo" -mindepth 1 -maxdepth 1 2>/dev/null | head -n 1)" ]; then return 0 fi if [ -f "/srv/taiko-web-data/mongo/WiredTiger" ]; then return 0 fi if [ -d "/srv/taiko-web-data/mongo" ] && [ "$(find /srv/taiko-web-data/mongo -mindepth 1 -maxdepth 1 2>/dev/null | head -n 1)" ]; then return 0 fi return 1 } compose() { if docker compose version >/dev/null 2>&1; then docker compose "$@" elif command -v docker-compose >/dev/null 2>&1; then docker-compose "$@" else echo "docker compose is not available." exit 1 fi } compose_flavor() { if docker compose version >/dev/null 2>&1; then echo "v2" elif command -v docker-compose >/dev/null 2>&1; then echo "v1" else echo "missing" fi } mongodb_container() { if ! command -v docker >/dev/null 2>&1; then return 1 fi if docker ps --format '{{.Names}}' | grep -q '^taiko-web-mongo$'; then echo "taiko-web-mongo" return 0 fi if docker ps --format '{{.Names}}' | grep -q '^taiko-web-mongo-direct$'; then echo "taiko-web-mongo-direct" return 0 fi return 1 } mongodb_container_exists() { if ! command -v docker >/dev/null 2>&1; then return 1 fi docker ps -a --format '{{.Names}}' | grep -Eq '^(taiko-web-mongo|taiko-web-mongo-direct)$' } mongo_auth_args() { MONGO_AUTH_ARGS=() if [ -n "${MONGO_ROOT_USERNAME:-}" ] && [ -n "${MONGO_ROOT_PASSWORD:-}" ]; then MONGO_AUTH_ARGS=( --username "$MONGO_ROOT_USERNAME" --password "$MONGO_ROOT_PASSWORD" --authenticationDatabase admin ) fi } wait_for_mongodb() { local attempts=30 local container mongo_auth_args for _ in $(seq 1 "$attempts"); do container=$(mongodb_container || true) if [ -n "$container" ]; then if docker exec "$container" mongosh "${MONGO_AUTH_ARGS[@]}" --quiet --eval "db.adminCommand({ ping: 1 }).ok" >/dev/null 2>&1; then return 0 fi elif command -v mongosh >/dev/null 2>&1; then if mongosh "${MONGO_AUTH_ARGS[@]}" --quiet --eval "db.adminCommand({ ping: 1 }).ok" >/dev/null 2>&1; then return 0 fi fi sleep 2 done return 1 } ensure_mongodb_running() { load_existing_env if [ -n "$(mongodb_container || true)" ]; then wait_for_mongodb return $? fi if mongodb_container_exists; then log "Starting existing MongoDB container for backup/health check." docker start taiko-web-mongo >/dev/null 2>&1 || docker start taiko-web-mongo-direct >/dev/null 2>&1 || true wait_for_mongodb return $? fi if [ -f "$INSTALL_DIR/docker-compose.yml" ] && command -v docker >/dev/null 2>&1; then log "Starting Compose MongoDB service for backup/health check." ( cd "$INSTALL_DIR" compose up -d mongo ) wait_for_mongodb return $? fi if systemctl list-unit-files mongod.service >/dev/null 2>&1; then log "Starting system MongoDB service for backup/health check." systemctl start mongod || true wait_for_mongodb return $? fi return 1 } mongodb_eval() { local script="$1" local container mongo_auth_args container=$(mongodb_container || true) if [ -n "$container" ]; then docker exec "$container" mongosh "${MONGO_AUTH_ARGS[@]}" --quiet --eval "$script" else mongosh "${MONGO_AUTH_ARGS[@]}" --quiet --eval "$script" fi } mongodb_collection_count() { local collection="$1" mongodb_eval "const dbname = db.getSiblingDB('taiko'); const names = dbname.getCollectionNames(); print(names.includes('${collection}') ? dbname.getCollection('${collection}').countDocuments({}) : 0)" 2>/dev/null | tail -n 1 } quiesce_app_writes() { if [ "$APP_WRITES_QUIESCED" = "true" ]; then return 0 fi log "Quiescing application writes before MongoDB backup/update." if command -v systemctl >/dev/null 2>&1 && systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then APP_DIRECT_WAS_ACTIVE=true systemctl stop "$SERVICE_NAME" log "Stopped systemd service $SERVICE_NAME." fi if command -v docker >/dev/null 2>&1 && docker ps --format '{{.Names}}' | grep -q '^taiko-web-app$'; then APP_CONTAINER_WAS_RUNNING=true docker stop taiko-web-app >/dev/null log "Stopped container taiko-web-app." fi APP_WRITES_QUIESCED=true log "Application writes are paused; MongoDB remains online for backup." } resume_app_writes() { local failed=0 if [ "$APP_CONTAINER_WAS_RUNNING" = "true" ] && command -v docker >/dev/null 2>&1; then docker start taiko-web-app >/dev/null 2>&1 || failed=1 fi if [ "$APP_DIRECT_WAS_ACTIVE" = "true" ] && command -v systemctl >/dev/null 2>&1; then systemctl start "$SERVICE_NAME" >/dev/null 2>&1 || failed=1 fi APP_WRITES_QUIESCED=false APP_DIRECT_WAS_ACTIVE=false APP_CONTAINER_WAS_RUNNING=false return "$failed" } abort_update_after_quiesce() { local status=$? trap - ERR if [ "$APP_WRITES_QUIESCED" = "true" ]; then log "Update failed while application writes were paused; attempting to restart the previous app service." resume_app_writes || log "Could not restart one or more previously running app services. Please inspect manually." fi exit "$status" } begin_update_guard() { trap abort_update_after_quiesce ERR } finish_update_guard() { trap - ERR APP_WRITES_QUIESCED=false APP_DIRECT_WAS_ACTIVE=false APP_CONTAINER_WAS_RUNNING=false } backup_mongodb() { load_existing_env if ! mongo_data_exists; then log "No existing MongoDB data detected; skipping backup." LAST_BACKUP_DIR= return 0 fi ensure_mongodb_running || { echo "MongoDB data exists, but MongoDB could not be started for backup. Update aborted." return 1 } local backup_dir="$BACKUP_ROOT/$(date +%Y%m%d-%H%M%S)" mkdir -p "$backup_dir" log "Creating MongoDB backup at $backup_dir" mongo_auth_args local container container=$(mongodb_container || true) if [ -n "$container" ]; then docker exec "$container" rm -rf /tmp/mongodump docker exec "$container" mongodump "${MONGO_AUTH_ARGS[@]}" --out /tmp/mongodump docker cp "$container:/tmp/mongodump" "$backup_dir" docker exec "$container" rm -rf /tmp/mongodump else require_cmd mongodump mongodump "${MONGO_AUTH_ARGS[@]}" --out "$backup_dir/mongodump" fi LAST_BACKUP_DIR="$backup_dir" log "MongoDB backup completed: $backup_dir" log "Restore command: $0 restore-db $backup_dir/mongodump" } restore_mongodb() { local backup_path="${1:-}" if [ -z "$backup_path" ]; then echo "Usage: $0 restore-db $BACKUP_ROOT/YYYYMMDD-HHMMSS/mongodump" exit 1 fi if [ ! -d "$backup_path" ]; then echo "Backup path not found: $backup_path" exit 1 fi confirm_destructive_action ensure_mongodb_running || { echo "MongoDB could not be started for restore." exit 1 } mongo_auth_args local container container=$(mongodb_container || true) if [ -n "$container" ]; then docker exec "$container" rm -rf /tmp/restore docker cp "$backup_path" "$container:/tmp/restore" docker exec "$container" mongorestore "${MONGO_AUTH_ARGS[@]}" --drop /tmp/restore docker exec "$container" rm -rf /tmp/restore else require_cmd mongorestore mongorestore "${MONGO_AUTH_ARGS[@]}" --drop "$backup_path" fi log "MongoDB restored from $backup_path" } check_mongodb_health() { local before_songs_count="${1:-unknown}" local backup_dir="${2:-}" ensure_mongodb_running || { echo "MongoDB health check failed: cannot connect." [ -n "$backup_dir" ] && echo "Latest backup: $backup_dir" exit 1 } mongodb_eval "db.adminCommand({ ping: 1 })" >/dev/null local after_songs_count after_songs_count=$(mongodb_collection_count songs || echo "unknown") log "MongoDB songs count before update: $before_songs_count" log "MongoDB songs count after update: $after_songs_count" if [ "$before_songs_count" != "unknown" ] && [ "$before_songs_count" != "0" ] && [ "$after_songs_count" = "0" ]; then echo "MongoDB data health check failed: songs count dropped to 0." [ -n "$backup_dir" ] && echo "Restore command: $0 restore-db $backup_dir/mongodump" exit 1 fi } prepare_mongodb_update() { local skip_backup="${1:-false}" load_existing_env ensure_data_dirs UPDATE_BEFORE_SONGS_COUNT=unknown LAST_BACKUP_DIR= if ! mongo_data_exists; then log "No existing MongoDB data detected; update will continue without a database backup." return 0 fi quiesce_app_writes ensure_mongodb_running || { echo "MongoDB data exists, but MongoDB could not be started for pre-update checks." exit 1 } UPDATE_BEFORE_SONGS_COUNT=$(mongodb_collection_count songs || echo "unknown") if [ "$skip_backup" = "true" ]; then echo "WARNING: --skip-backup was requested. MongoDB update backup is being skipped." echo "This is high risk and should only be used after a verified manual backup." return 0 fi backup_mongodb } backup_db_command() { begin_update_guard load_existing_env ensure_data_dirs if mongo_data_exists; then quiesce_app_writes fi backup_mongodb if [ "$APP_WRITES_QUIESCED" = "true" ]; then resume_app_writes || { log "MongoDB backup completed, but one or more app services could not be restarted." exit 1 } log "Application writes resumed after MongoDB backup." fi finish_update_guard } apt_install() { export DEBIAN_FRONTEND=noninteractive apt-get update -y apt-get install -y "$@" } ensure_base_sync_tools() { apt_install rsync curl ca-certificates gnupg } sync_source() { mkdir -p "$INSTALL_DIR" # A deployment may keep the checkout directly in INSTALL_DIR. rsync # refuses to copy a directory onto itself, so treat that as an already # synchronized source instead of failing an otherwise safe update. local source_real install_real source_real=$(CDPATH= cd -- "$SRC_DIR" && pwd -P) install_real=$(CDPATH= cd -- "$INSTALL_DIR" && pwd -P) if [ "$source_real" = "$install_real" ]; then log "Source and install directories are identical; skipping self-sync." return 0 fi rsync -a --delete \ --exclude '.git' \ --exclude '.venv' \ --exclude '.env' \ --exclude '.taiko-secret-key' \ --exclude 'flask_session' \ --exclude 'backups' \ --exclude 'config.py' \ --exclude 'public/songs' \ "$SRC_DIR/" "$INSTALL_DIR/" } ensure_persistent_secret_key() { mkdir -p "$INSTALL_DIR" local secret_path="$INSTALL_DIR/.taiko-secret-key" if [ -s "$secret_path" ] && [ "$(wc -c <"$secret_path")" -ge 32 ]; then chmod 600 "$secret_path" 2>/dev/null || true return 0 fi # Older container installs generated the key inside the app container. # Preserve it before the container is recreated so existing sessions remain # valid after an update. if command -v docker >/dev/null 2>&1 && docker ps -a --format '{{.Names}}' 2>/dev/null | grep -q '^taiko-web-app$'; then local recovered_path="${secret_path}.recovered.$$" if docker cp taiko-web-app:/app/.taiko-secret-key "$recovered_path" >/dev/null 2>&1 && [ -s "$recovered_path" ] && [ "$(wc -c <"$recovered_path")" -ge 32 ]; then chmod 600 "$recovered_path" mv -f "$recovered_path" "$secret_path" log "Preserved the existing container session key." return 0 fi rm -f "$recovered_path" 2>/dev/null || true fi local generated generated=$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n') if [ "${#generated}" -lt 32 ]; then echo "Could not create a persistent session key." >&2 return 1 fi umask 077 printf '%s\n' "$generated" >"$secret_path" chmod 600 "$secret_path" log "Created a persistent session key." } ensure_config() { if [ ! -f "$INSTALL_DIR/config.py" ] && [ -f "$INSTALL_DIR/config.example.py" ]; then cp "$INSTALL_DIR/config.example.py" "$INSTALL_DIR/config.py" fi } ensure_data_dirs() { mkdir -p "$DATA_DIR/songs" "$DATA_DIR/mongo" "$DATA_DIR/redis" } write_compose_env() { mkdir -p "$INSTALL_DIR" if [ ! -f "$INSTALL_DIR/.env" ]; then : >"$INSTALL_DIR/.env" log ".env created. Existing values will be preserved on future runs." else log ".env already exists; keeping existing values." fi ensure_env_value COMPOSE_PROJECT_NAME "$COMPOSE_PROJECT_NAME" ensure_env_value TAIKO_WEB_DATA_DIR "$DATA_DIR" load_existing_env } write_systemd_service() { cat >/etc/systemd/system/${SERVICE_NAME}.service </dev/null 2>&1 || true systemctl disable "$SERVICE_NAME" >/dev/null 2>&1 || true } remove_direct_service() { stop_direct_service rm -f "/etc/systemd/system/${SERVICE_NAME}.service" systemctl daemon-reload || true } ensure_docker() { apt_install docker.io if ! docker compose version >/dev/null 2>&1; then if ! apt-get install -y docker-compose-plugin; then install_docker_apt_repo || true apt-get update -y || true apt-get install -y docker-compose-plugin || install_compose_plugin_manual || true fi fi if ! docker compose version >/dev/null 2>&1 && ! command -v docker-compose >/dev/null 2>&1; then apt-get install -y docker-compose || true fi systemctl enable --now docker require_cmd docker if [ "$(compose_flavor)" = "missing" ]; then echo "docker compose is not available." exit 1 fi log "Using Docker Compose $(compose_flavor)." if [ "$(compose_flavor)" = "v1" ]; then log "Compose v2 could not be installed; falling back to docker-compose v1." fi } install_docker_apt_repo() { . /etc/os-release || true local codename="${UBUNTU_CODENAME:-${VERSION_CODENAME:-}}" [ -n "$codename" ] || return 1 install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc chmod a+r /etc/apt/keyrings/docker.asc cat >/etc/apt/sources.list.d/docker.sources </dev/null 2>&1; then return 0 fi . /etc/os-release || true local codename="${VERSION_CODENAME:-}" if [ -n "$codename" ] && echo "$codename" | grep -Eq '^(focal|jammy)$'; then curl -fsSL https://pgp.mongodb.com/server-7.0.asc | gpg --dearmor -o /usr/share/keyrings/mongodb-server-7.0.gpg echo "deb [ signed-by=/usr/share/keyrings/mongodb-server-7.0.gpg ] https://repo.mongodb.org/apt/ubuntu ${codename}/mongodb-org/7.0 multiverse" \ > /etc/apt/sources.list.d/mongodb-org-7.0.list apt-get update -y apt-get install -y mongodb-org return 0 fi return 1 } remove_container_stack() { if ! command -v docker >/dev/null 2>&1; then return 0 fi if [ -f "$INSTALL_DIR/docker-compose.yml" ]; then ( cd "$INSTALL_DIR" compose down --remove-orphans >/dev/null 2>&1 || true ) fi docker rm -f taiko-web-app taiko-web-mongo taiko-web-redis >/dev/null 2>&1 || true } remove_named_stack_containers() { if ! command -v docker >/dev/null 2>&1; then return 0 fi docker rm -f taiko-web-app taiko-web-mongo taiko-web-redis >/dev/null 2>&1 || true } compose_up_or_recreate_named() { local log_file log_file=$(mktemp) if compose "$@" 2>"$log_file"; then rm -f "$log_file" return 0 fi cat "$log_file" >&2 if grep -q "container name .* is already in use\\|Conflict. The container name" "$log_file"; then log "Detected stale named containers from an older Compose run; removing containers only, preserving volumes." remove_named_stack_containers rm -f "$log_file" compose "$@" return $? fi rm -f "$log_file" return 1 } ensure_direct_datastores() { apt_install redis-server systemctl enable redis-server || true systemctl restart redis-server || systemctl start redis-server || true if install_mongodb_direct; then systemctl enable mongod || true systemctl restart mongod || systemctl start mongod || true return 0 fi log "MongoDB direct install is unavailable on this system; using a MongoDB container instead." ensure_docker if ! docker ps -a --format '{{.Names}}' | grep -q '^taiko-web-mongo-direct$'; then docker run -d \ --name taiko-web-mongo-direct \ --restart unless-stopped \ -p 27017:27017 \ -v "$DATA_DIR/mongo:/data/db" \ mongo:7.0 else docker start taiko-web-mongo-direct >/dev/null 2>&1 || true fi } run_app_python() { if command -v docker >/dev/null 2>&1 && docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^taiko-web-app$'; then docker exec -i taiko-web-app python - return fi if [ -x "$INSTALL_DIR/.venv/bin/python" ]; then ( cd "$INSTALL_DIR" "$INSTALL_DIR/.venv/bin/python" - ) return fi echo "Cannot run application Python for admin setup." return 1 } admin_user_count() { run_app_python <<'PY' from app import db print(db.users.count_documents({'user_level': {'$gte': 100}})) PY } create_admin_user() { local username="$1" local password="$2" if command -v docker >/dev/null 2>&1 && docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^taiko-web-app$'; then docker exec -i \ -e "TAIKO_ADMIN_USERNAME=$username" \ -e "TAIKO_ADMIN_PASSWORD=$password" \ taiko-web-app python - <<'PY' import bcrypt import os import re from app import db username = os.environ['TAIKO_ADMIN_USERNAME'].strip() password = os.environ['TAIKO_ADMIN_PASSWORD'] if not re.match(r'^[A-Za-z0-9_]{3,20}$', username): raise SystemExit('Invalid admin username.') if len(password.encode('utf-8')) < 6: raise SystemExit('Admin password must be at least 6 bytes.') hashed = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()) existing = db.users.find_one({'username_lower': username.lower()}) session_id = os.urandom(24).hex() if existing: try: current_level = int(existing.get('user_level', 0)) except (TypeError, ValueError): current_level = 0 db.users.update_one({'_id': existing['_id']}, {'$set': { 'password': hashed, 'username': username, 'username_lower': username.lower(), 'user_level': max(current_level, 100), 'session_id': existing.get('session_id') or session_id }}) print('updated') else: db.users.insert_one({ 'username': username, 'username_lower': username.lower(), 'password': hashed, 'user_level': 100, 'session_id': session_id }) print('created') PY return fi if [ -x "$INSTALL_DIR/.venv/bin/python" ]; then ( cd "$INSTALL_DIR" TAIKO_ADMIN_USERNAME="$username" TAIKO_ADMIN_PASSWORD="$password" "$INSTALL_DIR/.venv/bin/python" - <<'PY' import bcrypt import os import re from app import db username = os.environ['TAIKO_ADMIN_USERNAME'].strip() password = os.environ['TAIKO_ADMIN_PASSWORD'] if not re.match(r'^[A-Za-z0-9_]{3,20}$', username): raise SystemExit('Invalid admin username.') if len(password.encode('utf-8')) < 6: raise SystemExit('Admin password must be at least 6 bytes.') hashed = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()) existing = db.users.find_one({'username_lower': username.lower()}) session_id = os.urandom(24).hex() if existing: try: current_level = int(existing.get('user_level', 0)) except (TypeError, ValueError): current_level = 0 db.users.update_one({'_id': existing['_id']}, {'$set': { 'password': hashed, 'username': username, 'username_lower': username.lower(), 'user_level': max(current_level, 100), 'session_id': existing.get('session_id') or session_id }}) print('updated') else: db.users.insert_one({ 'username': username, 'username_lower': username.lower(), 'password': hashed, 'user_level': 100, 'session_id': session_id }) print('created') PY ) return fi echo "Cannot run application Python for admin setup." return 1 } prompt_admin_credentials() { ADMIN_USERNAME_RESULT="${TAIKO_WEB_ADMIN_USERNAME:-}" ADMIN_PASSWORD_RESULT="${TAIKO_WEB_ADMIN_PASSWORD:-}" if [ -n "$ADMIN_USERNAME_RESULT" ] && [ -n "$ADMIN_PASSWORD_RESULT" ]; then return fi if [ ! -t 0 ]; then echo "No administrator account found. Set TAIKO_WEB_ADMIN_USERNAME and TAIKO_WEB_ADMIN_PASSWORD, then rerun setup/update." exit 1 fi echo "No administrator account found. Create one for /1128admin1128." while true; do read -r -p "Admin username [admin]: " ADMIN_USERNAME_RESULT ADMIN_USERNAME_RESULT=${ADMIN_USERNAME_RESULT:-admin} if printf '%s' "$ADMIN_USERNAME_RESULT" | grep -Eq '^[A-Za-z0-9_]{3,20}$'; then break fi echo "Use 3-20 letters, numbers, or underscores." done while true; do read -r -s -p "Admin password: " ADMIN_PASSWORD_RESULT echo read -r -s -p "Confirm admin password: " admin_password_confirm echo if [ "$ADMIN_PASSWORD_RESULT" != "$admin_password_confirm" ]; then echo "Passwords do not match." elif [ "${#ADMIN_PASSWORD_RESULT}" -lt 6 ]; then echo "Password must be at least 6 characters." else break fi done } ensure_admin_user() { local attempt count output output="" count="" # Compose reports success as soon as the containers have started. On a # large existing database Gunicorn may still be creating/checking indexes, # so importing app.py immediately can race application startup. Retry the # read instead of turning a healthy deployment into a failed setup run. attempt=1 while [ "$attempt" -le 12 ]; do output=$(admin_user_count 2>&1 || true) count=$(printf '%s\n' "$output" | tail -n 1) if printf '%s' "$count" | grep -Eq '^[0-9]+$'; then break fi if [ "$attempt" -lt 12 ]; then log "Application is not ready for the administrator check yet (${attempt}/12); retrying in 5 seconds." sleep 5 fi attempt=$((attempt + 1)) done if ! printf '%s' "$count" | grep -Eq '^[0-9]+$'; then echo "Could not check administrator accounts after 12 attempts." [ -n "$output" ] && printf '%s\n' "$output" >&2 if command -v docker >/dev/null 2>&1; then echo "Recent taiko-web-app logs:" >&2 docker logs --tail 100 taiko-web-app >&2 2>/dev/null || true fi exit 1 fi if [ "$count" -gt 0 ]; then log "Administrator account exists; skipping admin creation." return fi prompt_admin_credentials create_admin_user "$ADMIN_USERNAME_RESULT" "$ADMIN_PASSWORD_RESULT" >/dev/null log "Administrator account is ready: $ADMIN_USERNAME_RESULT" } deploy_direct() { begin_update_guard log "Starting direct deployment." ensure_base_sync_tools apt_install python3 python3-venv python3-pip git libcap2-bin ensure_persistent_secret_key load_existing_env ensure_data_dirs if mongo_data_exists; then log "Existing MongoDB data detected during install; switching to safe update behavior." prepare_mongodb_update false fi ensure_direct_datastores remove_container_stack sync_source ensure_config python3 -m venv "$INSTALL_DIR/.venv" "$INSTALL_DIR/.venv/bin/pip" install -U pip "$INSTALL_DIR/.venv/bin/pip" install -r "$INSTALL_DIR/requirements.txt" chown -R "$APP_USER:$APP_GROUP" "$INSTALL_DIR" "$DATA_DIR" write_systemd_service systemctl daemon-reload systemctl enable "$SERVICE_NAME" systemctl restart "$SERVICE_NAME" check_mongodb_health "$UPDATE_BEFORE_SONGS_COUNT" "$LAST_BACKUP_DIR" ensure_admin_user log "Direct deployment completed." log "Persistent data directory: $DATA_DIR" [ -n "$LAST_BACKUP_DIR" ] && log "MongoDB backup: $LAST_BACKUP_DIR" finish_update_guard } deploy_container() { begin_update_guard log "Starting container deployment." ensure_base_sync_tools ensure_docker ensure_persistent_secret_key load_existing_env ensure_data_dirs if mongo_data_exists; then log "Existing MongoDB data detected during install; switching to safe update behavior." prepare_mongodb_update false fi stop_direct_service sync_source ensure_config write_compose_env ( cd "$INSTALL_DIR" compose_up_or_recreate_named up -d --build --force-recreate --remove-orphans ) check_mongodb_health "$UPDATE_BEFORE_SONGS_COUNT" "$LAST_BACKUP_DIR" ensure_admin_user log "Container deployment completed." log "Persistent data directory: $DATA_DIR" [ -n "$LAST_BACKUP_DIR" ] && log "MongoDB backup: $LAST_BACKUP_DIR" finish_update_guard } upgrade_container() { local skip_backup="${1:-false}" begin_update_guard log "Starting container-only upgrade." ensure_base_sync_tools ensure_docker ensure_persistent_secret_key prepare_mongodb_update "$skip_backup" sync_source ensure_config write_compose_env ( cd "$INSTALL_DIR" compose_up_or_recreate_named up -d --build --force-recreate --remove-orphans ) check_mongodb_health "$UPDATE_BEFORE_SONGS_COUNT" "$LAST_BACKUP_DIR" ensure_admin_user log "Container upgrade completed." log "Persistent data directory kept intact: $DATA_DIR" [ -n "$LAST_BACKUP_DIR" ] && log "MongoDB backup: $LAST_BACKUP_DIR" finish_update_guard } upgrade_direct() { local skip_backup="${1:-false}" begin_update_guard log "Starting direct upgrade." ensure_base_sync_tools apt_install python3 python3-venv python3-pip git libcap2-bin ensure_persistent_secret_key prepare_mongodb_update "$skip_backup" ensure_direct_datastores remove_container_stack sync_source ensure_config if [ ! -x "$INSTALL_DIR/.venv/bin/python3" ]; then python3 -m venv "$INSTALL_DIR/.venv" fi "$INSTALL_DIR/.venv/bin/pip" install -U pip "$INSTALL_DIR/.venv/bin/pip" install -r "$INSTALL_DIR/requirements.txt" chown -R "$APP_USER:$APP_GROUP" "$INSTALL_DIR" "$DATA_DIR" write_systemd_service systemctl daemon-reload systemctl enable "$SERVICE_NAME" systemctl restart "$SERVICE_NAME" check_mongodb_health "$UPDATE_BEFORE_SONGS_COUNT" "$LAST_BACKUP_DIR" ensure_admin_user log "Direct upgrade completed." log "Persistent data directory kept intact: $DATA_DIR" [ -n "$LAST_BACKUP_DIR" ] && log "MongoDB backup: $LAST_BACKUP_DIR" finish_update_guard } repair_installation() { log "Repairing containers, permissions, and generated environment without deleting MongoDB data." ensure_base_sync_tools ensure_data_dirs mkdir -p "$INSTALL_DIR" write_compose_env if [ -d "$INSTALL_DIR" ]; then chown -R "$APP_USER:$APP_GROUP" "$INSTALL_DIR" "$DATA_DIR" || true fi if [ -f "$INSTALL_DIR/docker-compose.yml" ] && command -v docker >/dev/null 2>&1; then ( cd "$INSTALL_DIR" compose_up_or_recreate_named up -d --remove-orphans ) fi check_mongodb_health unknown "$LAST_BACKUP_DIR" log "Repair completed. MongoDB data directory preserved: $DATA_DIR/mongo" } reset_db() { load_existing_env confirm_destructive_action begin_update_guard if mongo_data_exists; then quiesce_app_writes fi if mongo_data_exists; then backup_mongodb fi ensure_data_dirs log "Resetting MongoDB data directory after explicit confirmation." if command -v docker >/dev/null 2>&1; then docker rm -f taiko-web-mongo taiko-web-mongo-direct >/dev/null 2>&1 || true fi local mongo_dir mongo_dir=$(readlink -f "$DATA_DIR/mongo") local data_root data_root=$(readlink -f "$DATA_DIR") if [ -z "$mongo_dir" ] || [ -z "$data_root" ] || [ "${mongo_dir#"$data_root"/}" = "$mongo_dir" ]; then echo "Refusing to reset MongoDB: resolved path is outside DATA_DIR." exit 1 fi rm -rf "$mongo_dir" mkdir -p "$mongo_dir" log "MongoDB data reset completed." [ -n "$LAST_BACKUP_DIR" ] && log "Pre-reset backup: $LAST_BACKUP_DIR" finish_update_guard } uninstall_all() { log "Starting uninstall." remove_direct_service if command -v docker >/dev/null 2>&1; then remove_container_stack docker rm -f taiko-web-mongo-direct >/dev/null 2>&1 || true fi rm -rf "$INSTALL_DIR" log "Application files removed." log "Persistent data directory preserved: $DATA_DIR" } print_menu() { printf '%s\n' \ "Available actions:" \ " install First install with persistent MongoDB data directory" \ " update [--skip-backup]" \ " Safe update; pauses app writes and backs up MongoDB" \ " backup-db Pause app writes, create a MongoDB backup, then resume" \ " restore-db PATH Restore MongoDB from a backup; requires full confirmation" \ " repair Repair containers/env/permissions without deleting data" \ " reset-db Delete MongoDB data; requires full confirmation" \ " deploy-container Legacy alias for install" \ " deploy-direct Direct-system install" \ " upgrade-container Legacy alias for update" \ " upgrade-direct Direct-system update" \ " uninstall Remove app files; preserves persistent data directory" } main() { local action="${1:-}" if [ "$#" -gt 0 ]; then shift fi local skip_backup=false local positional=() while [ "$#" -gt 0 ]; do case "$1" in --skip-backup) skip_backup=true ;; --help|-h) print_menu exit 0 ;; *) positional+=("$1") ;; esac shift done if [ -z "$action" ]; then load_existing_env if mongo_data_exists || [ -d "$INSTALL_DIR" ]; then action="update" log "Existing installation or MongoDB data detected; defaulting to safe update." else action="install" log "No existing installation detected; defaulting to install." fi fi case "$action" in menu|help|--help|-h) print_menu exit 0 ;; esac if [ "${EUID:-$(id -u)}" -ne 0 ]; then echo "Root privileges are required." exit 1 fi case "$action" in install) deploy_container ;; update) upgrade_container "$skip_backup" ;; backup-db) backup_db_command ;; restore-db) restore_mongodb "${positional[0]:-}" ;; repair) repair_installation ;; reset-db) reset_db ;; deploy-container) deploy_container ;; deploy-direct) deploy_direct ;; upgrade-container) upgrade_container "$skip_backup" ;; upgrade-direct) upgrade_direct "$skip_backup" ;; uninstall) uninstall_all ;; *) echo "Unknown command: $action" print_menu exit 1 ;; esac } main "$@"