Last active
June 1, 2026 15:52
-
-
Save andrebrait/7fb9ef01a1eef2a234c57d3f5bd94601 to your computer and use it in GitHub Desktop.
Arcane bidirectional migrator
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| set -euo pipefail | |
| # ╔══════════════════════════════════════════════════════════════════════════╗ | |
| # ║ Arcane bidirectional migrator ║ | |
| # ║ ║ | |
| # ║ ── DISCLAIMER ── ║ | |
| # ║ This script was generated by Claude (an AI assistant by Anthropic) in ║ | |
| # ║ collaboration with the user. It is NOT an official Arcane tool and is ║ | |
| # ║ not affiliated with or endorsed by the Arcane project (getarcaneapp). ║ | |
| # ║ It is provided as-is, without warranty of any kind. It performs ║ | |
| # ║ destructive operations (stopping services, removing installs). Review it ║ | |
| # ║ in full, ensure you have working backups, and test on a non-critical ║ | |
| # ║ host before relying on it. You run it at your own risk. ║ | |
| # ║ ║ | |
| # ║ ── WHAT IT DOES ── ║ | |
| # ║ Migrates an Arcane install between host-binary (systemd) and Docker ║ | |
| # ║ Compose forms. Direction auto-detected where unambiguous, else asked. ║ | |
| # ║ Live compose dir detected from the running container's compose label; ║ | |
| # ║ when the container is down, falls back to parsing the compose file's ║ | |
| # ║ /app/data bind mount. Creates the 'arcane' user/group (as the official ║ | |
| # ║ installer does) and normalizes data/projects/builds ownership to ║ | |
| # ║ arcane:arcane EARLY, before any failure-prone step. An ERR trap reports ║ | |
| # ║ the exact failing line. Data and secrets preserved; consistent tar ║ | |
| # ║ snapshot taken before changes. Old variant never auto-removed — run with ║ | |
| # ║ --teardown after verifying. ║ | |
| # ║ ║ | |
| # ║ ── USAGE ── ║ | |
| # ║ sudo bash arcane-migrate.sh # detect, choose, migrate ║ | |
| # ║ sudo bash arcane-migrate.sh --teardown # after verifying, remove old ║ | |
| # ║ ║ | |
| # ║ NOTE ON `curl ... | bash`: all interactive prompts read from /dev/tty, ║ | |
| # ║ so piping the script into bash works. Even so, downloading then running ║ | |
| # ║ (so you can review it first) is recommended for a script this powerful. ║ | |
| # ║ ║ | |
| # ║ Assumptions: Docker is installed, or installed on request if missing. ║ | |
| # ╚══════════════════════════════════════════════════════════════════════════╝ | |
| # ── ERR trap: never exit silently ───────────────────────────────────────────── | |
| trap 'rc=$?; printf "\n\033[1;31mScript failed (exit %s) at line %s: %s\033[0m\n" \ | |
| "$rc" "$LINENO" "$BASH_COMMAND" >&2' ERR | |
| ARCANE_DIR=/opt/arcane | |
| HOST_ENV="$ARCANE_DIR/.env" | |
| HOST_UNIT=/etc/systemd/system/arcane.service | |
| BUILDS_DIR="$ARCANE_DIR/builds" | |
| INSTALL_URL="https://getarcane.app/install.sh" | |
| INSTALL_RAW="https://raw.githubusercontent.com/getarcaneapp/website/refs/heads/main/static/install.sh" | |
| ARCANE_DATA_DIR=/var/lib/arcane | |
| COMPOSE_CANDIDATES=(/opt/arcane-docker /opt/arcane-compose) | |
| DEFAULT_COMPOSE_DIR=/opt/arcane-compose | |
| say() { printf '\n\033[1;36m== %s\033[0m\n' "$*"; } | |
| warn() { printf '\n\033[1;33m%s\033[0m\n' "$*"; } | |
| die() { printf '\n\033[1;31mERROR: %s\033[0m\n' "$*" >&2; exit 1; } | |
| getfrom() { grep -E "^$2=" "$1" 2>/dev/null | head -n1 | cut -d= -f2- || true ; } | |
| # All interactive input comes from the controlling terminal, so the script works | |
| # even when its body is piped in via `curl ... | bash` (stdin = the pipe). | |
| ask() { local __var="$1" __prompt="$2"; printf '%s' "$__prompt"; read -r "$__var" </dev/tty; } | |
| [ "$(id -u)" -eq 0 ] || die "Run as root (sudo)." | |
| [ -e /dev/tty ] || die "No controlling terminal (/dev/tty). Run interactively, not in a non-tty pipeline." | |
| MODE=migrate | |
| if [ "${1:-}" = "--teardown" ]; then | |
| MODE=teardown | |
| elif [ -n "${1:-}" ]; then | |
| die "Unknown arg '$1'. Use no args, or --teardown." | |
| fi | |
| # ── Compose-file / project-dir resolution ───────────────────────────────────── | |
| find_compose_file() { | |
| local d="$1" f | |
| for f in compose.yaml compose.yml docker-compose.yml docker-compose.yaml; do | |
| [ -f "$d/$f" ] && { echo "$d/$f"; return 0; } | |
| done | |
| return 1 | |
| } | |
| detect_compose_dir() { | |
| local wd="" d | |
| if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx arcane; then | |
| wd=$(docker inspect arcane \ | |
| --format '{{index .Config.Labels "com.docker.compose.project.working_dir"}}' \ | |
| 2>/dev/null || true) | |
| if [ -n "$wd" ] && [ -d "$wd" ]; then echo "$wd"; return 0; fi | |
| fi | |
| for d in "${COMPOSE_CANDIDATES[@]}"; do | |
| if find_compose_file "$d" >/dev/null; then echo "$d"; return 0; fi | |
| done | |
| return 1 | |
| } | |
| # Echoes: "<host:yes|no> <container:yes|project|no> <compose_dir|->" | |
| detect_variant() { | |
| local has_host="no" has_container="no" cdir="-" | |
| if [ -f "$HOST_UNIT" ] && [ -x "$ARCANE_DIR/arcane" ]; then has_host="yes"; fi | |
| if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx arcane; then | |
| has_container="yes"; cdir=$(detect_compose_dir || echo "-") | |
| elif cdir=$(detect_compose_dir 2>/dev/null); then | |
| has_container="project" | |
| else | |
| cdir="-" | |
| fi | |
| echo "$has_host $has_container $cdir" | |
| } | |
| # Real host path mounted to /app/data, from the LIVE container (authoritative). | |
| detect_data_mount_live() { | |
| docker ps --format '{{.Names}}' 2>/dev/null | grep -qx arcane || return 1 | |
| local src | |
| src=$(docker inspect arcane --format \ | |
| '{{range .Mounts}}{{if eq .Destination "/app/data"}}{{.Source}}{{end}}{{end}}' \ | |
| 2>/dev/null || true) | |
| [ -n "$src" ] && { echo "$src"; return 0; } | |
| return 1 | |
| } | |
| # Fallback: parse a compose file's volumes for the host side of a `:/app/data` | |
| # bind mount, OR a pass-through mount of $ARCANE_DIR. Handles both the script's | |
| # own format and hand-rolled compose files. Best-effort, no YAML parser. | |
| detect_data_mount_from_file() { | |
| local cf="$1" line host dest | |
| [ -f "$cf" ] || return 1 | |
| # Look for "<host>:/app/data" (optionally with :ro/:rw and leading "- "). | |
| line=$(grep -E '^[[:space:]]*-?[[:space:]]*[^[:space:]#]+:/app/data([:[:space:]]|$)' "$cf" 2>/dev/null | head -n1 || true) | |
| if [ -n "$line" ]; then | |
| host=$(echo "$line" | sed -E 's/^[[:space:]]*-?[[:space:]]*//; s#:/app/data.*##') | |
| # Strip surrounding quotes if present. | |
| host=${host%\"}; host=${host#\"}; host=${host%\'}; host=${host#\'} | |
| if [ -n "$host" ] && [ "${host#/}" != "$host" ]; then echo "$host"; return 0; fi | |
| fi | |
| # Pass-through case: "<ARCANE_DIR>:<ARCANE_DIR>" → data lives at $ARCANE_DIR/data. | |
| if grep -qE "^[[:space:]]*-?[[:space:]]*${ARCANE_DIR}:${ARCANE_DIR}([:[:space:]]|$)" "$cf" 2>/dev/null; then | |
| echo "$ARCANE_DIR/data"; return 0 | |
| fi | |
| return 1 | |
| } | |
| # ── Shared helpers ───────────────────────────────────────────────────────────── | |
| ensure_arcane_user() { | |
| if ! getent group arcane >/dev/null; then | |
| say "Creating system group 'arcane'"; groupadd --system arcane | |
| fi | |
| if ! id arcane >/dev/null 2>&1; then | |
| say "Creating system user 'arcane'" | |
| useradd --system --gid arcane --shell /bin/false \ | |
| --home-dir "$ARCANE_DATA_DIR" --create-home arcane | |
| fi | |
| usermod -aG docker arcane 2>/dev/null || true | |
| mkdir -p "$ARCANE_DIR" "$ARCANE_DATA_DIR" "$ARCANE_DATA_DIR/data" "$BUILDS_DIR" /var/log/arcane | |
| chown -R arcane:arcane "$ARCANE_DIR" "$ARCANE_DATA_DIR" /var/log/arcane 2>/dev/null || true | |
| } | |
| normalize_ownership() { | |
| local uid gid p | |
| uid=$(id -u arcane); gid=$(id -g arcane) | |
| for p in "$@"; do | |
| if [ -e "$p" ]; then | |
| chown -R "$uid:$gid" "$p" 2>/dev/null \ | |
| || warn "Could not fully chown $p to $uid:$gid — check manually." | |
| fi | |
| done | |
| } | |
| ensure_docker() { | |
| if command -v docker >/dev/null && docker compose version >/dev/null 2>&1; then return 0; fi | |
| warn "Docker (or the compose plugin) is missing." | |
| local ans; ask ans 'Install Docker now via get.docker.com? [y/N] ' | |
| [[ "$ans" =~ ^[Yy]$ ]] || die "Docker is required. Install it and re-run." | |
| say "Installing Docker via get.docker.com" | |
| curl -fsSL https://get.docker.com | sh | |
| systemctl enable --now docker 2>/dev/null || true | |
| docker compose version >/dev/null 2>&1 || die "Compose plugin still missing; install docker-compose-plugin." | |
| } | |
| snapshot() { | |
| local bdir="$1"; shift | |
| local tar="$bdir/arcane-data-projects.tar.gz" rels=() p | |
| for p in "$@"; do [ -e "$p" ] && rels+=("${p#/}"); done | |
| [ "${#rels[@]}" -gt 0 ] || die "Nothing to back up — refusing to continue." | |
| tar -czf "$tar" -C / "${rels[@]}" 2>/dev/null \ | |
| || die "Backup archive failed — aborting before any change." | |
| echo "$tar" | |
| } | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # TEARDOWN | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| if [ "$MODE" = teardown ]; then | |
| read -r HAS_HOST HAS_CONTAINER CDIR < <(detect_variant) | |
| say "Teardown — host=$HAS_HOST container=$HAS_CONTAINER compose_dir=$CDIR" | |
| if systemctl is-active --quiet arcane 2>/dev/null && [ "$HAS_CONTAINER" != yes ]; then | |
| [ "$CDIR" != "-" ] && [ -d "$CDIR" ] || { warn "No compose project to remove."; exit 0; } | |
| local_c=""; ask local_c "Host service is live. Remove the compose project at $CDIR? Type \"yes\": " | |
| [ "$local_c" = yes ] || die "Aborted." | |
| ( cd "$CDIR" && docker compose down ) || warn "compose down had an issue." | |
| rm -rf "$CDIR" | |
| say "Removed compose project at $CDIR. Host service remains."; exit 0 | |
| elif [ "$HAS_CONTAINER" = yes ]; then | |
| local_c=""; ask local_c 'Container is live. Remove the host-binary install (unit + binary)? Type "yes": ' | |
| [ "$local_c" = yes ] || die "Aborted." | |
| systemctl stop arcane 2>/dev/null || true | |
| systemctl disable arcane 2>/dev/null || true | |
| rm -f "$HOST_UNIT"; systemctl daemon-reload | |
| rm -f "$ARCANE_DIR/arcane" /usr/local/bin/arcane "$ARCANE_DIR"/arcane.bak.* 2>/dev/null || true | |
| say "Removed host binary + unit. Kept data, projects, .env, and the arcane user."; exit 0 | |
| else | |
| die "Couldn't determine a healthy live variant. Verify state manually before teardown." | |
| fi | |
| fi | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # MIGRATE — detect & choose direction | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| read -r HAS_HOST HAS_CONTAINER CDIR < <(detect_variant) | |
| say "Detected — host-binary: $HAS_HOST compose: $HAS_CONTAINER compose_dir: $CDIR" | |
| # When the container is DOWN, detection of the compose dir is a directory-scan | |
| # guess and may be ambiguous (e.g. a stale leftover project dir). Warn & confirm. | |
| if [ "$HAS_CONTAINER" = project ]; then | |
| warn "No running Arcane container — compose dir was guessed by scanning: $CDIR" | |
| warn "If this is the wrong project, Ctrl-C and either remove the stale dir or" | |
| warn "start the intended container first ('docker compose up -d') so detection" | |
| warn "can use the authoritative compose label." | |
| fi | |
| DIRECTION="" | |
| if [ "$HAS_HOST" = yes ] && [ "$HAS_CONTAINER" = no ]; then | |
| DIRECTION="to-compose" | |
| echo "Found a host-binary install. Will migrate: host binary → Docker Compose." | |
| elif { [ "$HAS_CONTAINER" = yes ] || [ "$HAS_CONTAINER" = project ]; } && [ "$HAS_HOST" != yes ]; then | |
| DIRECTION="to-host" | |
| echo "Found a Docker Compose deployment ($CDIR). Will migrate: Docker Compose → host binary." | |
| elif [ "$HAS_HOST" = yes ] && { [ "$HAS_CONTAINER" = yes ] || [ "$HAS_CONTAINER" = project ]; }; then | |
| warn "Both variants appear present. You must choose." | |
| d=""; ask d 'Migrate which way? [1] host→compose [2] compose→host : ' | |
| case "$d" in 1) DIRECTION=to-compose;; 2) DIRECTION=to-host;; *) die "Invalid choice.";; esac | |
| else | |
| die "No existing Arcane install detected (neither host binary nor compose project)." | |
| fi | |
| go=""; ask go "$(printf '\nProceed with migration "%s"? Type "yes": ' "$DIRECTION")" | |
| [ "$go" = yes ] || die "Aborted." | |
| # ── EARLY, IDEMPOTENT PREP (before any failure-prone extraction) ────────────── | |
| say "Preparing: ensuring arcane user exists and normalizing ownership" | |
| ensure_arcane_user | |
| EARLY_DATA="$(detect_data_mount_live 2>/dev/null || true)" | |
| if [ -z "$EARLY_DATA" ] && [ "$CDIR" != "-" ]; then | |
| EARLY_CF="$(find_compose_file "$CDIR" 2>/dev/null || true)" | |
| [ -n "$EARLY_CF" ] && EARLY_DATA="$(detect_data_mount_from_file "$EARLY_CF" 2>/dev/null || true)" | |
| fi | |
| [ -z "$EARLY_DATA" ] && EARLY_DATA="$ARCANE_DIR/data" | |
| EARLY_PROJ="" | |
| for ef in "$HOST_ENV" "/opt/arcane-docker/.env" "/opt/arcane-compose/.env"; do | |
| if [ -f "$ef" ]; then EARLY_PROJ="$(getfrom "$ef" PROJECTS_DIRECTORY)"; [ -n "$EARLY_PROJ" ] && break; fi | |
| done | |
| [ -z "$EARLY_PROJ" ] && EARLY_PROJ="$ARCANE_DIR/projects" | |
| normalize_ownership "$ARCANE_DIR" "$ARCANE_DATA_DIR" "$EARLY_DATA" "$EARLY_PROJ" "$BUILDS_DIR" /var/log/arcane | |
| BACKUP_DIR="/root/arcane-migrate-backup-$(date +%Y%m%d-%H%M%S)" | |
| mkdir -p "$BACKUP_DIR" | |
| # ────────────────────────────────────────────────────────────────────────────── | |
| # HOST → COMPOSE | |
| # ────────────────────────────────────────────────────────────────────────────── | |
| if [ "$DIRECTION" = to-compose ]; then | |
| TARGET_DIR="$DEFAULT_COMPOSE_DIR" | |
| COMPOSE_FILE="$TARGET_DIR/docker-compose.yml" | |
| COMPOSE_ENV="$TARGET_DIR/.env" | |
| [ -f "$HOST_ENV" ] || die "$HOST_ENV not found — cannot recover secrets." | |
| [ -d "$ARCANE_DIR/data" ] || die "$ARCANE_DIR/data not found." | |
| ENCRYPTION_KEY=$(getfrom "$HOST_ENV" ENCRYPTION_KEY) | |
| JWT_SECRET=$(getfrom "$HOST_ENV" JWT_SECRET) | |
| APP_URL=$(getfrom "$HOST_ENV" APP_URL); APP_URL=${APP_URL:-http://localhost:3552} | |
| PROJECTS_DIRECTORY=$(getfrom "$HOST_ENV" PROJECTS_DIRECTORY); PROJECTS_DIRECTORY=${PROJECTS_DIRECTORY:-$ARCANE_DIR/projects} | |
| DISK_USAGE_PATH=$(getfrom "$HOST_ENV" DISK_USAGE_PATH); DISK_USAGE_PATH=${DISK_USAGE_PATH:-$PROJECTS_DIRECTORY} | |
| [ -n "$ENCRYPTION_KEY" ] || die "ENCRYPTION_KEY missing in $HOST_ENV." | |
| [ -n "$JWT_SECRET" ] || die "JWT_SECRET missing in $HOST_ENV." | |
| ensure_docker | |
| ARCANE_UID=$(id -u arcane); ARCANE_GID=$(id -g arcane) | |
| DOCKER_GID=$(getent group docker | cut -d: -f3) | |
| say "Normalizing data/projects/builds ownership to arcane:arcane" | |
| normalize_ownership "$ARCANE_DIR/data" "$PROJECTS_DIRECTORY" "$BUILDS_DIR" | |
| say "Backing up secrets, unit, and data/projects" | |
| cp "$HOST_ENV" "$BACKUP_DIR/arcane.env"; chmod 600 "$BACKUP_DIR/arcane.env" | |
| cp "$HOST_UNIT" "$BACKUP_DIR/" 2>/dev/null || true | |
| systemctl stop arcane | |
| TAR=$(snapshot "$BACKUP_DIR" "$ARCANE_DIR/data" "$PROJECTS_DIRECTORY") | |
| echo "Snapshot: $TAR ($(du -h "$TAR" | cut -f1)). Copy $BACKUP_DIR off-box." | |
| say "Writing compose project to $TARGET_DIR" | |
| mkdir -p "$TARGET_DIR" | |
| cat > "$COMPOSE_FILE" <<'YAML' | |
| services: | |
| arcane: | |
| image: ghcr.io/getarcaneapp/arcane:latest | |
| container_name: arcane | |
| restart: unless-stopped | |
| user: "${ARCANE_UID}:${ARCANE_GID}" | |
| group_add: | |
| - "${DOCKER_GID}" | |
| ports: | |
| - "3552:3552" | |
| volumes: | |
| - /var/run/docker.sock:/var/run/docker.sock | |
| - ${ARCANE_DATA}:/app/data | |
| - ${PROJECTS_DIRECTORY}:${PROJECTS_DIRECTORY} | |
| - ${BUILDS_DIR}:/builds | |
| environment: | |
| - APP_URL=${APP_URL} | |
| - PROJECTS_DIRECTORY=${PROJECTS_DIRECTORY} | |
| - DISK_USAGE_PATH=${DISK_USAGE_PATH} | |
| - ENCRYPTION_KEY=${ENCRYPTION_KEY} | |
| - JWT_SECRET=${JWT_SECRET} | |
| YAML | |
| cat > "$COMPOSE_ENV" <<EOF | |
| ARCANE_UID=$ARCANE_UID | |
| ARCANE_GID=$ARCANE_GID | |
| DOCKER_GID=$DOCKER_GID | |
| ARCANE_DATA=$ARCANE_DIR/data | |
| PROJECTS_DIRECTORY=$PROJECTS_DIRECTORY | |
| DISK_USAGE_PATH=$DISK_USAGE_PATH | |
| BUILDS_DIR=$BUILDS_DIR | |
| APP_URL=$APP_URL | |
| ENCRYPTION_KEY=$ENCRYPTION_KEY | |
| JWT_SECRET=$JWT_SECRET | |
| EOF | |
| chmod 600 "$COMPOSE_ENV" | |
| ( cd "$TARGET_DIR" && docker compose config >/dev/null ) || die "compose config invalid." | |
| systemctl disable arcane | |
| say "Starting container" | |
| ( cd "$TARGET_DIR" && docker compose up -d ) | |
| sleep 5 | |
| LOGS=$(cd "$TARGET_DIR" && docker compose logs --tail 200 arcane 2>&1 || true) | |
| if echo "$LOGS" | grep -qiE 'decrypt|invalid key|encryption'; then | |
| warn "Possible key mismatch:"; echo "$LOGS" | grep -iE 'decrypt|invalid key|encryption' || true | |
| printf 'Roll back:\n cd %s && docker compose down\n systemctl enable --now arcane\n' "$TARGET_DIR" | |
| printf 'Restore data if needed:\n tar -xzf %s -C /\n' "$TAR" | |
| die "Stopping before teardown. Investigate." | |
| fi | |
| ( cd "$TARGET_DIR" && docker compose ps --status running | grep -q arcane ) \ | |
| || die "Container not running. Check: cd $TARGET_DIR && docker compose logs arcane" | |
| cat <<EOF | |
| ──────────────────────────────────────────────────────────────────────────── | |
| Migrated host → compose. Verify the UI at $APP_URL (projects, users, settings). | |
| Then remove the old host install: sudo bash $0 --teardown | |
| Backups: $BACKUP_DIR | |
| Rollback (pre-teardown): cd $TARGET_DIR && docker compose down ; systemctl enable --now arcane | |
| ──────────────────────────────────────────────────────────────────────────── | |
| EOF | |
| exit 0 | |
| fi | |
| # ────────────────────────────────────────────────────────────────────────────── | |
| # COMPOSE → HOST | |
| # ────────────────────────────────────────────────────────────────────────────── | |
| if [ "$DIRECTION" = to-host ]; then | |
| [ "$CDIR" != "-" ] && [ -d "$CDIR" ] || die "Could not resolve the live compose dir." | |
| TARGET_DIR="$CDIR" | |
| COMPOSE_FILE=$(find_compose_file "$TARGET_DIR") || die "No compose file in $TARGET_DIR." | |
| COMPOSE_ENV="$TARGET_DIR/.env" | |
| say "Using compose project: $TARGET_DIR (file: $COMPOSE_FILE)" | |
| # Resolve the real data path: live container mount first, then compose-file parse. | |
| ARCANE_DATA="$(detect_data_mount_live 2>/dev/null || true)" | |
| [ -z "$ARCANE_DATA" ] && ARCANE_DATA="$(detect_data_mount_from_file "$COMPOSE_FILE" 2>/dev/null || true)" | |
| [ -f "$COMPOSE_ENV" ] || die "No $COMPOSE_ENV — cannot recover secrets/paths." | |
| ENCRYPTION_KEY=$(getfrom "$COMPOSE_ENV" ENCRYPTION_KEY) | |
| JWT_SECRET=$(getfrom "$COMPOSE_ENV" JWT_SECRET) | |
| APP_URL=$(getfrom "$COMPOSE_ENV" APP_URL); APP_URL=${APP_URL:-http://localhost:3552} | |
| PROJECTS_DIRECTORY=$(getfrom "$COMPOSE_ENV" PROJECTS_DIRECTORY); PROJECTS_DIRECTORY=${PROJECTS_DIRECTORY:-$ARCANE_DIR/projects} | |
| DISK_USAGE_PATH=$(getfrom "$COMPOSE_ENV" DISK_USAGE_PATH); DISK_USAGE_PATH=${DISK_USAGE_PATH:-$PROJECTS_DIRECTORY} | |
| [ -z "$ARCANE_DATA" ] && ARCANE_DATA="$(getfrom "$COMPOSE_ENV" ARCANE_DATA)" | |
| [ -z "$ARCANE_DATA" ] && ARCANE_DATA="$ARCANE_DIR/data" | |
| [ -n "$ENCRYPTION_KEY" ] || die "ENCRYPTION_KEY missing in $COMPOSE_ENV." | |
| [ -n "$JWT_SECRET" ] || die "JWT_SECRET missing in $COMPOSE_ENV." | |
| [ -d "$ARCANE_DATA" ] || die "Data dir $ARCANE_DATA not found." | |
| ensure_docker | |
| say "Backing up compose project, secrets, and data/projects" | |
| cp "$COMPOSE_ENV" "$BACKUP_DIR/compose.env"; chmod 600 "$BACKUP_DIR/compose.env" | |
| cp "$COMPOSE_FILE" "$BACKUP_DIR/$(basename "$COMPOSE_FILE")" 2>/dev/null || true | |
| cp "$HOST_ENV" "$BACKUP_DIR/host-arcane.env.pre-existing" 2>/dev/null || true | |
| ( cd "$TARGET_DIR" && docker compose down ) || warn "compose down had an issue." | |
| TAR=$(snapshot "$BACKUP_DIR" "$ARCANE_DATA" "$PROJECTS_DIRECTORY") | |
| echo "Snapshot: $TAR ($(du -h "$TAR" | cut -f1)). Copy $BACKUP_DIR off-box." | |
| say "Ensuring $HOST_ENV exists so the installer preserves secrets" | |
| if [ ! -f "$HOST_ENV" ]; then | |
| cat > "$HOST_ENV" <<EOF | |
| ENVIRONMENT=production | |
| PORT=3552 | |
| APP_URL=$APP_URL | |
| DATABASE_URL=file:data/arcane.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(2500)&_txlock=immediate | |
| ENCRYPTION_KEY=$ENCRYPTION_KEY | |
| JWT_SECRET=$JWT_SECRET | |
| DOCKER_HOST=unix:///var/run/docker.sock | |
| PROJECTS_DIRECTORY=$PROJECTS_DIRECTORY | |
| DISK_USAGE_PATH=$DISK_USAGE_PATH | |
| LOG_LEVEL=info | |
| EOF | |
| chown arcane:arcane "$HOST_ENV"; chmod 600 "$HOST_ENV" | |
| else | |
| warn "$HOST_ENV already exists; leaving it untouched (installer will preserve it)." | |
| fi | |
| if [ "$ARCANE_DATA" != "$ARCANE_DIR/data" ]; then | |
| warn "Container data path ($ARCANE_DATA) != host default ($ARCANE_DIR/data)." | |
| warn "The host binary expects data at $ARCANE_DIR/data (WorkingDirectory-relative)." | |
| warn "If these differ, after install: stop service, move data into $ARCANE_DIR/data, restart." | |
| fi | |
| say "Downloading installer for review (saved; not auto-piped)" | |
| INSTALLER="$BACKUP_DIR/install.sh" | |
| curl -fsSL "$INSTALL_URL" -o "$INSTALLER" 2>/dev/null \ | |
| || curl -fsSL "$INSTALL_RAW" -o "$INSTALLER" \ | |
| || die "Could not download installer. Fetch manually to $INSTALLER and re-run." | |
| echo "Saved to $INSTALLER. Review with: less $INSTALLER" | |
| r=""; ask r 'Run the installer now? Type "yes": ' | |
| [ "$r" = yes ] || die "Aborted. Roll back: cd $TARGET_DIR && docker compose up -d" | |
| say "Running installer" | |
| bash "$INSTALLER" | |
| say "Verifying secrets in $HOST_ENV" | |
| if [ "$(getfrom "$HOST_ENV" ENCRYPTION_KEY)" != "$ENCRYPTION_KEY" ] || \ | |
| [ "$(getfrom "$HOST_ENV" JWT_SECRET)" != "$JWT_SECRET" ]; then | |
| warn "Installer changed the secrets — restoring originals." | |
| systemctl stop arcane 2>/dev/null || true | |
| tmp="$HOST_ENV.tmp" | |
| grep -vE '^(ENCRYPTION_KEY|JWT_SECRET)=' "$HOST_ENV" > "$tmp" | |
| printf 'ENCRYPTION_KEY=%s\nJWT_SECRET=%s\n' "$ENCRYPTION_KEY" "$JWT_SECRET" >> "$tmp" | |
| mv "$tmp" "$HOST_ENV" | |
| chown arcane:arcane "$HOST_ENV"; chmod 600 "$HOST_ENV" | |
| systemctl restart arcane | |
| fi | |
| say "Normalizing data/projects/builds ownership to arcane:arcane" | |
| normalize_ownership "$ARCANE_DIR/data" "$ARCANE_DATA" "$PROJECTS_DIRECTORY" "$BUILDS_DIR" | |
| sleep 5 | |
| systemctl is-active --quiet arcane || die "arcane.service failed. Check: journalctl -u arcane -n 100" | |
| if [ -f /var/log/arcane/arcane-error.log ] && \ | |
| grep -qiE 'decrypt|invalid key|encryption' /var/log/arcane/arcane-error.log; then | |
| warn "Possible key mismatch in /var/log/arcane/arcane-error.log:" | |
| grep -iE 'decrypt|invalid key|encryption' /var/log/arcane/arcane-error.log | tail -n 20 || true | |
| printf 'Roll back:\n systemctl stop arcane && systemctl disable arcane\n cd %s && docker compose up -d\n' "$TARGET_DIR" | |
| printf 'Restore data if needed:\n tar -xzf %s -C /\n' "$TAR" | |
| die "Stopping before teardown. Investigate." | |
| fi | |
| cat <<EOF | |
| ──────────────────────────────────────────────────────────────────────────── | |
| Migrated compose → host. Verify the UI at $APP_URL (projects, users, settings). | |
| Then remove the unused compose project: sudo bash $0 --teardown | |
| Backups: $BACKUP_DIR | |
| Rollback (pre-teardown): systemctl stop arcane && systemctl disable arcane ; cd $TARGET_DIR && docker compose up -d | |
| ──────────────────────────────────────────────────────────────────────────── | |
| EOF | |
| exit 0 | |
| fi |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment