alphax/scripts/migrate_container_db_to_volume.sh
2026-05-14 10:29:18 +08:00

221 lines
6.7 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# Migrate an AlphaX SQLite DB that lives inside a Docker container into the
# docker-compose host volume path: ./data/altcoin_monitor.db.
#
# Safe defaults:
# - uses sqlite3 backup API inside the container, so WAL data is included
# - backs up any existing host ./data/altcoin_monitor.db first
# - refuses to overwrite when the container is already reading from /app/data
# - recreates compose services after migration so the new volume is mounted
#
# Common usage on the server:
# bash scripts/migrate_container_db_to_volume.sh
#
# Useful overrides:
# SOURCE_CONTAINER=old-alphax-web bash scripts/migrate_container_db_to_volume.sh
# SERVICE=alphax-web TARGET_DB=data/altcoin_monitor.db bash scripts/migrate_container_db_to_volume.sh
# RECREATE=0 bash scripts/migrate_container_db_to_volume.sh
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
COMPOSE_CMD="${COMPOSE_CMD:-docker compose}"
SERVICE="${SERVICE:-alphax-web}"
SCHEDULER_SERVICE="${SCHEDULER_SERVICE:-alphax-scheduler}"
SOURCE_CONTAINER="${SOURCE_CONTAINER:-}"
SOURCE_DB="${SOURCE_DB:-/app/data/altcoin_monitor.db}"
TARGET_DB="${TARGET_DB:-data/altcoin_monitor.db}"
BACKUP_DIR="${BACKUP_DIR:-data/backups}"
RECREATE="${RECREATE:-1}"
STOP_SCHEDULER="${STOP_SCHEDULER:-1}"
FORCE="${FORCE:-0}"
compose() {
${COMPOSE_CMD} "$@"
}
die() {
echo "ERROR: $*" >&2
exit 1
}
info() {
echo "[migrate-db] $*"
}
service_exists() {
compose config --services 2>/dev/null | grep -qx "$1"
}
container_id() {
if [ -n "$SOURCE_CONTAINER" ]; then
docker inspect -f '{{.Id}}' "$SOURCE_CONTAINER" >/dev/null 2>&1 || die "SOURCE_CONTAINER not found: $SOURCE_CONTAINER"
docker inspect -f '{{.Id}}' "$SOURCE_CONTAINER"
return
fi
local cid
cid="$(compose ps -q "$SERVICE" 2>/dev/null || true)"
[ -n "$cid" ] || die "service '$SERVICE' is not running. Start it first, or set SOURCE_CONTAINER=<container_name>."
echo "$cid"
}
is_source_db_under_mount() {
local cid="$1"
local mounted=1
while IFS= read -r dest; do
[ -n "$dest" ] || continue
case "$SOURCE_DB" in
"$dest"|"$dest"/*)
mounted=0
break
;;
esac
done < <(docker inspect -f '{{range .Mounts}}{{println .Destination}}{{end}}' "$cid")
return "$mounted"
}
verify_db_in_container() {
local cid="$1"
local db_path="$2"
docker exec -e CHECK_DB="$db_path" "$cid" python - <<'PY'
import os
import sqlite3
import sys
db = os.environ["CHECK_DB"]
conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True, timeout=30)
result = conn.execute("PRAGMA integrity_check").fetchone()[0]
tables = conn.execute("SELECT COUNT(*) FROM sqlite_master WHERE type='table'").fetchone()[0]
conn.close()
if result != "ok":
print(f"integrity_check failed: {result}", file=sys.stderr)
raise SystemExit(2)
print(f"integrity_check=ok tables={tables}")
PY
}
verify_db_on_host_if_possible() {
local db_path="$1"
if command -v python3 >/dev/null 2>&1; then
HOST_DB="$db_path" python3 - <<'PY'
import os
import sqlite3
import sys
db = os.environ["HOST_DB"]
conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True, timeout=30)
result = conn.execute("PRAGMA integrity_check").fetchone()[0]
tables = conn.execute("SELECT COUNT(*) FROM sqlite_master WHERE type='table'").fetchone()[0]
conn.close()
if result != "ok":
print(f"host integrity_check failed: {result}", file=sys.stderr)
raise SystemExit(2)
print(f"host integrity_check=ok tables={tables}")
PY
else
info "python3 not found on host; skipped host integrity check"
fi
}
CID="$(container_id)"
STAMP="$(date +%Y%m%d_%H%M%S)"
TMP_IN_CONTAINER="/tmp/alphax_container_db_${STAMP}.db"
TMP_ON_HOST="${BACKUP_DIR}/altcoin_monitor.from_container.${STAMP}.tmp.db"
info "source container: ${SOURCE_CONTAINER:-$SERVICE} ($CID)"
info "source db: $SOURCE_DB"
info "target db: $TARGET_DB"
docker exec -e SOURCE_DB="$SOURCE_DB" "$CID" sh -lc 'test -s "$SOURCE_DB"' \
|| die "source database does not exist or is empty inside container: $SOURCE_DB"
if is_source_db_under_mount "$CID" && [ "$FORCE" != "1" ]; then
info "container path '$SOURCE_DB' is already under a mounted volume/bind mount."
info "No migration needed. Set FORCE=1 only if you intentionally want to copy it anyway."
exit 0
fi
mkdir -p "$(dirname "$TARGET_DB")" "$BACKUP_DIR"
if [ "$STOP_SCHEDULER" = "1" ] && service_exists "$SCHEDULER_SERVICE"; then
info "stopping scheduler service to avoid concurrent writes"
compose stop "$SCHEDULER_SERVICE" >/dev/null 2>&1 || true
fi
info "creating SQLite backup inside container"
docker exec \
-e SOURCE_DB="$SOURCE_DB" \
-e TMP_DB="$TMP_IN_CONTAINER" \
"$CID" python - <<'PY'
import os
import sqlite3
src = os.environ["SOURCE_DB"]
dst = os.environ["TMP_DB"]
try:
os.remove(dst)
except FileNotFoundError:
pass
src_conn = sqlite3.connect(f"file:{src}?mode=ro", uri=True, timeout=30)
dst_conn = sqlite3.connect(dst, timeout=30)
src_conn.backup(dst_conn)
dst_conn.close()
src_conn.close()
check = sqlite3.connect(f"file:{dst}?mode=ro", uri=True, timeout=30)
result = check.execute("PRAGMA integrity_check").fetchone()[0]
check.close()
if result != "ok":
raise SystemExit(f"container backup integrity_check failed: {result}")
print(f"backup_created={dst}")
PY
info "copying backup from container to host"
docker cp "${CID}:${TMP_IN_CONTAINER}" "$TMP_ON_HOST"
docker exec -e TMP_DB="$TMP_IN_CONTAINER" "$CID" sh -lc 'rm -f "$TMP_DB"' >/dev/null 2>&1 || true
verify_db_on_host_if_possible "$TMP_ON_HOST"
if [ -e "$TARGET_DB" ]; then
EXISTING_BACKUP="${BACKUP_DIR}/altcoin_monitor.before_container_migration.${STAMP}.db"
info "backing up existing target db to $EXISTING_BACKUP"
cp -p "$TARGET_DB" "$EXISTING_BACKUP"
fi
for sidecar in "${TARGET_DB}-wal" "${TARGET_DB}-shm"; do
if [ -e "$sidecar" ]; then
sidecar_backup="${BACKUP_DIR}/$(basename "$sidecar").before_container_migration.${STAMP}"
info "moving stale sidecar $sidecar to $sidecar_backup"
mv "$sidecar" "$sidecar_backup"
fi
done
info "installing migrated db into volume path"
mv "$TMP_ON_HOST" "$TARGET_DB"
chmod 664 "$TARGET_DB" || true
verify_db_on_host_if_possible "$TARGET_DB"
if [ "$RECREATE" = "1" ]; then
info "recreating compose services so the host volume is mounted"
compose up -d --force-recreate "$SERVICE"
if service_exists "$SCHEDULER_SERVICE"; then
compose up -d --force-recreate "$SCHEDULER_SERVICE"
fi
NEW_CID="$(compose ps -q "$SERVICE" 2>/dev/null || true)"
if [ -n "$NEW_CID" ]; then
info "verifying migrated db from recreated container"
verify_db_in_container "$NEW_CID" "$SOURCE_DB"
fi
else
info "RECREATE=0, skipped service recreation"
fi
info "done"
info "volume db is now: $ROOT_DIR/$TARGET_DB"
info "backups are in: $ROOT_DIR/$BACKUP_DIR"