#!/usr/bin/env bash # Move this CodeCraft installation to another Docker host (e.g. Unraid) that keeps its # data in bind mounts instead of the Docker volume "codecraft-data". # # Run it on the CURRENT host, in the repository folder (where .env lives): # # scripts/move-server.sh root@unraid:/mnt/user/appdata/codecraft \ # --workspace /mnt/user/projects --uid 99 --gid 100 --bind 0.0.0.0 # # What it does: # 1. copies the workspace while CodeCraft keeps running (rsync, first pass), # 2. stops the CodeCraft container here (it is kept for a rollback), # 3. streams the data volume (database, config, backups, agent home) to /data, # 4. syncs the workspace again (only what changed), # 5. writes /docker-compose.yml, /.env and /start.sh (docker run, for hosts # without Compose) with bind mounts, and starts the published image there, # 6. runs scripts/smoke-test.sh on the new host. # Afterwards point the reverse proxy to the new host. To roll back: start the old container # here again (docker compose start) – nothing is deleted on this host. # # Target: user@host:/dir (SSH; rsync and docker on the target), or local:/dir to try it on # this host (use --name/--port so it does not clash with the running installation). # # Options: # --workspace DIR project folder on the target (default /workspace) # --uid N --gid N owner of data and workspace on the target (default: UID/GID of .env) # --port N host port on the target (default: PORT of .env) # --bind ADDR bind address on the target (default: BIND_ADDR of .env or 127.0.0.1) # --name NAME container name on the target (default codecraft) # --source-container C container to stop here (default codecraft) # --source-volume V data volume here (default codecraft-data) # --source-workspace D project folder here (default: WORKSPACE of .env) # --no-stop do not stop the source (only for tests – the copy may be inconsistent) # --dry-run show the steps only set -euo pipefail target="" ws_target="" uid="" gid="" port="" bind="" name="codecraft" src_container="codecraft" src_volume="codecraft-data" ws_source="" stop=1 dry=0 while (($#)); do case "$1" in --workspace) ws_target="$2"; shift ;; --uid) uid="$2"; shift ;; --gid) gid="$2"; shift ;; --port) port="$2"; shift ;; --bind) bind="$2"; shift ;; --name) name="$2"; shift ;; --source-container) src_container="$2"; shift ;; --source-volume) src_volume="$2"; shift ;; --source-workspace) ws_source="$2"; shift ;; --no-stop) stop=0 ;; --dry-run) dry=1 ;; -h|--help) sed -n '2,36p' "$0"; exit 0 ;; -*) echo "unknown option $1" >&2; exit 2 ;; *) target="$1" ;; esac shift done [[ "$target" == *:/* ]] || { echo "usage: $0 user@host:/dir | local:/dir [options] (see --help)" >&2; exit 2; } cd "$(git rev-parse --show-toplevel)" [[ -f .env ]] || { echo "no .env in $(pwd)" >&2; exit 1; } env_get() { sed -n "s/^$1=//p" .env | tail -1; } host="${target%%:*}" dir="${target#*:}" dir="${dir%/}" ws_target="${ws_target:-$dir/workspace}" uid="${uid:-$(env_get UID)}"; uid="${uid:-1000}" gid="${gid:-$(env_get GID)}"; gid="${gid:-1000}" port="${port:-$(env_get PORT)}"; port="${port:-8787}" bind="${bind:-$(env_get BIND_ADDR)}"; bind="${bind:-127.0.0.1}" version="$(env_get CODECRAFT_VERSION)"; version="${version:-latest}" ws_source="${ws_source:-$(env_get WORKSPACE)}"; ws_source="${ws_source:-./workspace}" image="$(docker inspect "$src_container" --format '{{.Config.Image}}' 2>/dev/null || true)" image_repo="${image%:*}" [[ -n "$image_repo" && "$image_repo" != codecraft ]] || image_repo="git.familie-zink.org/zink/codecraft" step() { printf '\n==> %s\n' "$*"; } fail() { echo "error: $*" >&2; exit 1; } # Run a shell command on the target. remote() { if (( dry )); then echo "[dry-run] on $host: $1"; return 0; fi if [[ "$host" == local ]]; then bash -c "$1"; else ssh -o BatchMode=yes "$host" "$1"; fi } q() { printf '%q' "$1"; } sync_workspace() { if (( dry )); then echo "[dry-run] rsync $ws_source/ -> $host:$ws_target/"; return; fi if [[ "$host" == local ]]; then rsync -a --numeric-ids "$ws_source/" "$ws_target/" else rsync -a --numeric-ids -e "ssh -o BatchMode=yes" "$ws_source/" "$host:$ws_target/"; fi } put_file() { # put_file < content if (( dry )); then echo "[dry-run] write $host:$1"; cat >/dev/null; return; fi if [[ "$host" == local ]]; then cat >"$1"; else ssh -o BatchMode=yes "$host" "cat > $(q "$1")"; fi } step "Checking source and target" docker volume inspect "$src_volume" >/dev/null || fail "volume $src_volume not found" [[ -d "$ws_source" ]] || fail "workspace $ws_source not found" echo "source: container $src_container, volume $src_volume, workspace $ws_source" echo "target: $host:$dir (data in $dir/data, workspace $ws_target), uid $uid:$gid, ${bind}:${port}, image $image_repo:$version" remote "command -v docker >/dev/null && command -v tar >/dev/null && command -v rsync >/dev/null" || fail "the target needs docker, tar and rsync" remote "mkdir -p $(q "$dir/data") $(q "$dir/docker") $(q "$ws_target") && test -z \"\$(ls -A $(q "$dir/data"))\"" \ || fail "$dir/data on the target is not empty – refusing to overwrite (move it away first)" step "Copying the workspace (first pass, CodeCraft keeps running)" sync_workspace if (( stop )); then step "Stopping $src_container here (running agents and terminals end)" (( dry )) && echo "[dry-run] docker stop $src_container" || docker stop "$src_container" >/dev/null fi step "Copying the data volume" if (( dry )); then echo "[dry-run] tar of volume $src_volume -> $host:$dir/data" else docker run --rm --entrypoint tar --user 0:0 -v "$src_volume:/data:ro" "${image:-$image_repo:$version}" -C /data -cf - . \ | remote "tar -C $(q "$dir/data") -xpf -" fi step "Copying the workspace (changes since the first pass)" sync_workspace remote "chown -R $uid:$gid $(q "$dir/data") $(q "$ws_target") 2>/dev/null || echo 'note: could not chown (not root) – check file owners'" step "Writing docker-compose.yml, .env and start.sh" put_file "$dir/docker/seccomp-sandbox.json" /dev/null 2>&1 || true exec docker run -d --name $name --restart unless-stopped --user "\$(val UID):\$(val GID)" \\ -p "\$(val BIND_ADDR):\$(val PORT):8787" --env-file .env \\ -v $dir/data:/data -v "\$(val WORKSPACE):/workspace" \\ --add-host host.docker.internal:host-gateway --shm-size 1g \\ --security-opt seccomp=$dir/docker/seccomp-sandbox.json \\ --security-opt apparmor=unconfined --security-opt systempaths=unconfined \\ $image_repo:\$(val CODECRAFT_VERSION) EOF remote "chmod +x $(q "$dir/start.sh")" step "Starting CodeCraft on the target" remote "cd $(q "$dir") && docker pull -q $image_repo:$version >/dev/null && if docker compose version >/dev/null 2>&1; then docker compose up -d; else ./start.sh; fi" \ || fail "start failed – if the image pull was refused, run 'docker login ${image_repo%%/*}' on the target and start again with: cd $dir && docker compose up -d (or ./start.sh)" step "Smoke test on the target" put_file "$dir/smoke-test.sh"