- Up to three chats side by side (#/s/a+b+c), "Open beside" in the sidebar menu
- File manager tabs of a chat stored per user on the server (/sessions/{id}/tabs)
- Personal modes for every user (Settings → My modes, /account/modes)
- Budget meters (total, provider, images, opencode Go windows) with push warnings
for admins at the warning share and 100 %, shown in the status overlay
- scripts/move-server.sh: move to another Docker host with bind mounts (Unraid);
scripts/smoke-test.sh
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
191 lines
9.0 KiB
Bash
Executable File
191 lines
9.0 KiB
Bash
Executable File
#!/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 <dir>/data,
|
||
# 4. syncs the workspace again (only what changed),
|
||
# 5. writes <dir>/docker-compose.yml, <dir>/.env and <dir>/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 <dir>/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 <remote path> < 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" <docker/seccomp-sandbox.json
|
||
grep -vE '^(COMPOSE_FILE|WORKSPACE|UID|GID|PORT|BIND_ADDR)=' .env | {
|
||
cat
|
||
printf '\n# Written by scripts/move-server.sh\nWORKSPACE=%s\nUID=%s\nGID=%s\nPORT=%s\nBIND_ADDR=%s\n' "$ws_target" "$uid" "$gid" "$port" "$bind"
|
||
} | put_file "$dir/.env"
|
||
put_file "$dir/docker-compose.yml" <<EOF
|
||
# CodeCraft with bind mounts (written by scripts/move-server.sh).
|
||
# Update: set CODECRAFT_VERSION in .env, then docker compose pull && docker compose up -d
|
||
services:
|
||
codecraft:
|
||
image: $image_repo:\${CODECRAFT_VERSION:-latest}
|
||
container_name: $name
|
||
restart: unless-stopped
|
||
user: "\${UID}:\${GID}"
|
||
ports:
|
||
- "\${BIND_ADDR}:\${PORT}:8787"
|
||
env_file:
|
||
- path: .env
|
||
required: false
|
||
volumes:
|
||
- $dir/data:/data
|
||
- \${WORKSPACE}:/workspace
|
||
extra_hosts:
|
||
- "host.docker.internal:host-gateway"
|
||
shm_size: "1gb"
|
||
security_opt:
|
||
- seccomp=$dir/docker/seccomp-sandbox.json
|
||
- apparmor=unconfined
|
||
- systempaths=unconfined
|
||
EOF
|
||
put_file "$dir/start.sh" <<EOF
|
||
#!/bin/sh
|
||
# Same as docker-compose.yml, for hosts without Docker Compose.
|
||
set -e
|
||
cd "\$(dirname "\$0")"
|
||
# Read values instead of sourcing .env: UID is read-only in bash.
|
||
val() { sed -n "s/^\$1=//p" .env | tail -1; }
|
||
docker rm -f $name >/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" <scripts/smoke-test.sh
|
||
remote "bash $(q "$dir/smoke-test.sh") http://127.0.0.1:$port ${version#v}" || fail "smoke test failed – the old installation here is stopped but intact (docker start $src_container)"
|
||
|
||
step "Done"
|
||
cat <<EOF
|
||
CodeCraft runs on $host ($bind:$port). Next:
|
||
- point the reverse proxy (e.g. Nginx Proxy Manager) for your domain to the new host and port,
|
||
- check the agents' tools in a chat (git credentials, MCP servers, LAN addresses),
|
||
- when everything works, remove the old container here: docker rm $src_container
|
||
(the volume $src_volume stays until you delete it: docker volume rm $src_volume).
|
||
Rollback: on the target 'docker stop $name', here 'docker start $src_container'.
|
||
EOF
|