#!/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"