1169 lines
33 KiB
Bash
1169 lines
33 KiB
Bash
#!/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 <<EOF
|
|
[Unit]
|
|
Description=taiko.su
|
|
After=network.target mongod.service redis-server.service docker.service
|
|
Wants=network.target
|
|
|
|
[Service]
|
|
Type=simple
|
|
WorkingDirectory=$INSTALL_DIR
|
|
Environment=PYTHONUNBUFFERED=1
|
|
Environment=TAIKO_WEB_SONGS_DIR=$DATA_DIR/songs
|
|
Environment=TAIKO_WEB_MONGO_HOST=127.0.0.1:27017
|
|
Environment=TAIKO_WEB_REDIS_HOST=127.0.0.1
|
|
Environment=REDIS_URI=redis://127.0.0.1:6379/0
|
|
ExecStart=$INSTALL_DIR/.venv/bin/gunicorn -c gunicorn.conf.py app:app
|
|
Restart=always
|
|
User=$APP_USER
|
|
Group=$APP_GROUP
|
|
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
|
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
EOF
|
|
}
|
|
|
|
stop_direct_service() {
|
|
systemctl stop "$SERVICE_NAME" >/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 <<EOF
|
|
Types: deb
|
|
URIs: https://download.docker.com/linux/ubuntu
|
|
Suites: $codename
|
|
Components: stable
|
|
Architectures: $(dpkg --print-architecture)
|
|
Signed-By: /etc/apt/keyrings/docker.asc
|
|
EOF
|
|
}
|
|
|
|
install_compose_plugin_manual() {
|
|
local arch
|
|
case "$(uname -m)" in
|
|
x86_64|amd64) arch="x86_64" ;;
|
|
aarch64|arm64) arch="aarch64" ;;
|
|
armv7l|armv7*) arch="armv7" ;;
|
|
*) return 1 ;;
|
|
esac
|
|
install -m 0755 -d /usr/local/lib/docker/cli-plugins
|
|
curl -fL "https://github.com/docker/compose/releases/download/v2.40.3/docker-compose-linux-${arch}" \
|
|
-o /usr/local/lib/docker/cli-plugins/docker-compose
|
|
chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
|
|
}
|
|
|
|
install_mongodb_direct() {
|
|
if command -v mongod >/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 "$@"
|