diff --git a/docs/install/docker.md b/docs/install/docker.md index cc5febe1487..61be00a81bf 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -624,6 +624,89 @@ docker compose run --rm openclaw-cli devices list --url ws://127.0.0.1:18789 - Dockerfile CMD uses `--allow-unconfigured`; mounted config with `gateway.mode` not `local` will still start. Override CMD to enforce the guard. - The gateway container is the source of truth for sessions (`~/.openclaw/agents//sessions/`). +## Backup and migration (Intel Mac to Apple Silicon) + +For low-disruption host migration, move OpenClaw data/config and rebuild Docker +images natively on the new machine. + +Use: + +- `scripts/migrate/backup-openclaw.sh` on source host +- `scripts/migrate/restore-openclaw.sh` on target host + +### 1) Create backup on source host + +From repo root: + +```bash +scripts/migrate/backup-openclaw.sh +``` + +The archive includes: + +- OpenClaw config dir (`OPENCLAW_CONFIG_DIR` or `~/.openclaw`) +- OpenClaw workspace dir (`OPENCLAW_WORKSPACE_DIR` or `~/.openclaw/workspace`) +- `.env` and Docker setup files from repo root +- metadata + internal checksum manifest + +Output files: + +- `backups/openclaw-backup-.tar.gz` +- `backups/openclaw-backup-.tar.gz.sha256` + +Optional path overrides: + +```bash +scripts/migrate/backup-openclaw.sh \ + --config-dir "$HOME/.openclaw" \ + --workspace-dir "$HOME/.openclaw/workspace" \ + --output-dir "$HOME/openclaw-backups" +``` + +### 2) Transfer archive to target host + +Copy archive + checksum file to the new Mac using your normal secure transfer +method (scp, encrypted disk, etc.). + +### 3) Restore on target host + +From repo root on target host: + +```bash +scripts/migrate/restore-openclaw.sh --archive /path/to/openclaw-backup-.tar.gz +``` + +Default restore behavior: + +- verifies archive checksums +- stops `openclaw-gateway` container first +- snapshots current config/workspace as `.pre-restore-` +- restores config/workspace from backup +- writes backup env file as `.env.from-backup` for review + +To overwrite `.env` directly: + +```bash +scripts/migrate/restore-openclaw.sh \ + --archive /path/to/openclaw-backup-.tar.gz \ + --apply-env +``` + +### 4) Rebuild and validate on target arch + +Always rebuild on Apple Silicon: + +```bash +docker compose up -d --build --force-recreate openclaw-gateway +docker compose run --rm openclaw-cli health +docker compose run --rm openclaw-cli channels status --probe +``` + +### Architecture migration note + +Do not carry over architecture-specific binary caches from x86 to arm hosts. +Rebuild containers and reinstall native toolchains on the target host. + ## Agent Sandbox (host gateway + Docker tools) Deep dive: [Sandboxing](/gateway/sandboxing) diff --git a/scripts/migrate/backup-openclaw.sh b/scripts/migrate/backup-openclaw.sh new file mode 100755 index 00000000000..c1d6383aaca --- /dev/null +++ b/scripts/migrate/backup-openclaw.sh @@ -0,0 +1,208 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + scripts/migrate/backup-openclaw.sh [options] + +Options: + --repo-root OpenClaw repo root (default: current repo) + --env-file Env file to include (default: /.env) + --config-dir OpenClaw config dir (default: env or ~/.openclaw) + --workspace-dir OpenClaw workspace dir (default: env or ~/.openclaw/workspace) + --output-dir Output directory for backup archive (default: /backups) + --name Backup name prefix (default: openclaw-backup-) + -h, --help Show this help +EOF +} + +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || fail "Missing required command: $1" +} + +strip_quotes() { + local value="$1" + if [[ "${value}" == \"*\" && "${value}" == *\" ]]; then + value="${value:1:${#value}-2}" + elif [[ "${value}" == \'*\' && "${value}" == *\' ]]; then + value="${value:1:${#value}-2}" + fi + printf '%s' "$value" +} + +env_value_from_file() { + local file="$1" + local key="$2" + [[ -f "$file" ]] || return 0 + local line + line="$(grep -E "^(export[[:space:]]+)?${key}=" "$file" | tail -n 1 || true)" + [[ -n "$line" ]] || return 0 + line="${line#export }" + local value="${line#*=}" + strip_quotes "$value" +} + +resolve_abs_path() { + local p="$1" + python3 - "$p" <<'PY' +import os +import sys + +path = sys.argv[1] +print(os.path.abspath(os.path.expanduser(path))) +PY +} + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +REPO_ROOT="$ROOT_DIR" +ENV_FILE="$ROOT_DIR/.env" +CONFIG_DIR="" +WORKSPACE_DIR="" +OUTPUT_DIR="$ROOT_DIR/backups" +BACKUP_NAME="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --repo-root) + REPO_ROOT="$2" + shift 2 + ;; + --env-file) + ENV_FILE="$2" + shift 2 + ;; + --config-dir) + CONFIG_DIR="$2" + shift 2 + ;; + --workspace-dir) + WORKSPACE_DIR="$2" + shift 2 + ;; + --output-dir) + OUTPUT_DIR="$2" + shift 2 + ;; + --name) + BACKUP_NAME="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + fail "Unknown argument: $1" + ;; + esac +done + +require_cmd tar +require_cmd rsync +require_cmd shasum +require_cmd python3 +require_cmd date +require_cmd uname + +REPO_ROOT="$(resolve_abs_path "$REPO_ROOT")" +ENV_FILE="$(resolve_abs_path "$ENV_FILE")" +OUTPUT_DIR="$(resolve_abs_path "$OUTPUT_DIR")" + +if [[ -z "$CONFIG_DIR" ]]; then + CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-$(env_value_from_file "$ENV_FILE" OPENCLAW_CONFIG_DIR)}" +fi +if [[ -z "$WORKSPACE_DIR" ]]; then + WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-$(env_value_from_file "$ENV_FILE" OPENCLAW_WORKSPACE_DIR)}" +fi + +CONFIG_DIR="${CONFIG_DIR:-$HOME/.openclaw}" +WORKSPACE_DIR="${WORKSPACE_DIR:-$HOME/.openclaw/workspace}" +CONFIG_DIR="$(resolve_abs_path "$CONFIG_DIR")" +WORKSPACE_DIR="$(resolve_abs_path "$WORKSPACE_DIR")" + +[[ -d "$CONFIG_DIR" ]] || fail "Config directory does not exist: $CONFIG_DIR" +[[ -d "$WORKSPACE_DIR" ]] || fail "Workspace directory does not exist: $WORKSPACE_DIR" +[[ -d "$REPO_ROOT" ]] || fail "Repo root does not exist: $REPO_ROOT" + +timestamp="$(date -u +%Y%m%dT%H%M%SZ)" +BACKUP_NAME="${BACKUP_NAME:-openclaw-backup-${timestamp}}" +mkdir -p "$OUTPUT_DIR" + +tmpdir="$(mktemp -d)" +trap 'rm -rf "$tmpdir"' EXIT + +stage="$tmpdir/stage" +mkdir -p "$stage/payload/config" "$stage/payload/workspace" "$stage/payload/repo" "$stage/meta" + +echo "==> Copying config directory" +rsync -a "$CONFIG_DIR/" "$stage/payload/config/" + +echo "==> Copying workspace directory" +rsync -a "$WORKSPACE_DIR/" "$stage/payload/workspace/" + +if [[ -f "$ENV_FILE" ]]; then + echo "==> Including env file: $ENV_FILE" + cp "$ENV_FILE" "$stage/payload/repo/.env" +fi + +for file in docker-compose.yml docker-compose.extra.yml Dockerfile docker-setup.sh; do + if [[ -f "$REPO_ROOT/$file" ]]; then + cp "$REPO_ROOT/$file" "$stage/payload/repo/$file" + fi +done + +{ + echo "timestamp_utc=$timestamp" + echo "source_host=$(hostname -s || hostname)" + echo "source_arch=$(uname -m)" + echo "source_os=$(uname -s)" + echo "repo_root=$REPO_ROOT" + echo "config_dir=$CONFIG_DIR" + echo "workspace_dir=$WORKSPACE_DIR" +} >"$stage/meta/backup.env" + +if command -v docker >/dev/null 2>&1; then + { + echo "# docker version" + docker version --format '{{.Server.Version}}' 2>/dev/null || true + echo + echo "# docker compose ps" + docker compose -f "$REPO_ROOT/docker-compose.yml" ps 2>/dev/null || true + } >"$stage/meta/docker.txt" +fi + +if command -v git >/dev/null 2>&1 && [[ -d "$REPO_ROOT/.git" ]]; then + { + echo "branch=$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD)" + echo "commit=$(git -C "$REPO_ROOT" rev-parse HEAD)" + echo + echo "# status" + git -C "$REPO_ROOT" status --short + } >"$stage/meta/git.txt" +fi + +( + cd "$stage" + find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 shasum -a 256 > SHA256SUMS +) + +archive_path="$OUTPUT_DIR/${BACKUP_NAME}.tar.gz" +( + cd "$stage" + tar -czf "$archive_path" . +) +shasum -a 256 "$archive_path" > "${archive_path}.sha256" + +echo +echo "Backup created:" +echo " $archive_path" +echo " ${archive_path}.sha256" +echo +echo "Next step on target host:" +echo " scripts/migrate/restore-openclaw.sh --archive \"$archive_path\"" diff --git a/scripts/migrate/restore-openclaw.sh b/scripts/migrate/restore-openclaw.sh new file mode 100755 index 00000000000..9853e93af9a --- /dev/null +++ b/scripts/migrate/restore-openclaw.sh @@ -0,0 +1,201 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + scripts/migrate/restore-openclaw.sh --archive [options] + +Options: + --archive Backup archive created by backup-openclaw.sh (required) + --repo-root OpenClaw repo root (default: current repo) + --env-file Env file path (default: /.env) + --config-dir OpenClaw config dir (default: env or ~/.openclaw) + --workspace-dir OpenClaw workspace dir (default: env or ~/.openclaw/workspace) + --apply-env Overwrite --env-file with backup .env (default: false) + --no-stop Do not stop gateway container before restore + -h, --help Show this help +EOF +} + +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || fail "Missing required command: $1" +} + +strip_quotes() { + local value="$1" + if [[ "${value}" == \"*\" && "${value}" == *\" ]]; then + value="${value:1:${#value}-2}" + elif [[ "${value}" == \'*\' && "${value}" == *\' ]]; then + value="${value:1:${#value}-2}" + fi + printf '%s' "$value" +} + +env_value_from_file() { + local file="$1" + local key="$2" + [[ -f "$file" ]] || return 0 + local line + line="$(grep -E "^(export[[:space:]]+)?${key}=" "$file" | tail -n 1 || true)" + [[ -n "$line" ]] || return 0 + line="${line#export }" + local value="${line#*=}" + strip_quotes "$value" +} + +resolve_abs_path() { + local p="$1" + python3 - "$p" <<'PY' +import os +import sys + +path = sys.argv[1] +print(os.path.abspath(os.path.expanduser(path))) +PY +} + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +REPO_ROOT="$ROOT_DIR" +ENV_FILE="$ROOT_DIR/.env" +ARCHIVE_PATH="" +CONFIG_DIR="" +WORKSPACE_DIR="" +APPLY_ENV=0 +STOP_FIRST=1 + +while [[ $# -gt 0 ]]; do + case "$1" in + --archive) + ARCHIVE_PATH="$2" + shift 2 + ;; + --repo-root) + REPO_ROOT="$2" + shift 2 + ;; + --env-file) + ENV_FILE="$2" + shift 2 + ;; + --config-dir) + CONFIG_DIR="$2" + shift 2 + ;; + --workspace-dir) + WORKSPACE_DIR="$2" + shift 2 + ;; + --apply-env) + APPLY_ENV=1 + shift + ;; + --no-stop) + STOP_FIRST=0 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + fail "Unknown argument: $1" + ;; + esac +done + +[[ -n "$ARCHIVE_PATH" ]] || fail "--archive is required" + +require_cmd tar +require_cmd rsync +require_cmd shasum +require_cmd python3 +require_cmd date + +ARCHIVE_PATH="$(resolve_abs_path "$ARCHIVE_PATH")" +REPO_ROOT="$(resolve_abs_path "$REPO_ROOT")" +ENV_FILE="$(resolve_abs_path "$ENV_FILE")" + +[[ -f "$ARCHIVE_PATH" ]] || fail "Archive not found: $ARCHIVE_PATH" +[[ -d "$REPO_ROOT" ]] || fail "Repo root does not exist: $REPO_ROOT" + +tmpdir="$(mktemp -d)" +trap 'rm -rf "$tmpdir"' EXIT + +echo "==> Extracting archive" +tar -xzf "$ARCHIVE_PATH" -C "$tmpdir" + +[[ -f "$tmpdir/SHA256SUMS" ]] || fail "Archive missing SHA256SUMS" +( + cd "$tmpdir" + shasum -a 256 -c SHA256SUMS +) + +if [[ -z "$CONFIG_DIR" ]]; then + CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-$(env_value_from_file "$ENV_FILE" OPENCLAW_CONFIG_DIR)}" +fi +if [[ -z "$WORKSPACE_DIR" ]]; then + WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-$(env_value_from_file "$ENV_FILE" OPENCLAW_WORKSPACE_DIR)}" +fi + +CONFIG_DIR="${CONFIG_DIR:-$HOME/.openclaw}" +WORKSPACE_DIR="${WORKSPACE_DIR:-$HOME/.openclaw/workspace}" +CONFIG_DIR="$(resolve_abs_path "$CONFIG_DIR")" +WORKSPACE_DIR="$(resolve_abs_path "$WORKSPACE_DIR")" + +if [[ $STOP_FIRST -eq 1 ]] && command -v docker >/dev/null 2>&1; then + echo "==> Stopping gateway container" + docker compose -f "$REPO_ROOT/docker-compose.yml" stop openclaw-gateway >/dev/null 2>&1 || true +fi + +timestamp="$(date -u +%Y%m%dT%H%M%SZ)" +mkdir -p "$(dirname "$CONFIG_DIR")" "$(dirname "$WORKSPACE_DIR")" + +if [[ -d "$CONFIG_DIR" ]]; then + mv "$CONFIG_DIR" "${CONFIG_DIR}.pre-restore-${timestamp}" +fi +if [[ -d "$WORKSPACE_DIR" ]]; then + mv "$WORKSPACE_DIR" "${WORKSPACE_DIR}.pre-restore-${timestamp}" +fi + +mkdir -p "$CONFIG_DIR" "$WORKSPACE_DIR" + +echo "==> Restoring config" +rsync -a "$tmpdir/payload/config/" "$CONFIG_DIR/" + +echo "==> Restoring workspace" +rsync -a "$tmpdir/payload/workspace/" "$WORKSPACE_DIR/" + +if [[ -f "$tmpdir/payload/repo/.env" ]]; then + if [[ $APPLY_ENV -eq 1 ]]; then + mkdir -p "$(dirname "$ENV_FILE")" + if [[ -f "$ENV_FILE" ]]; then + cp "$ENV_FILE" "${ENV_FILE}.pre-restore-${timestamp}" + fi + cp "$tmpdir/payload/repo/.env" "$ENV_FILE" + echo "==> Applied backed up env file to $ENV_FILE" + else + cp "$tmpdir/payload/repo/.env" "${ENV_FILE}.from-backup" + echo "==> Wrote env candidate to ${ENV_FILE}.from-backup" + fi +fi + +source_arch="$(grep -E '^source_arch=' "$tmpdir/meta/backup.env" | cut -d= -f2- || true)" +target_arch="$(uname -m)" +if [[ -n "$source_arch" && "$source_arch" != "$target_arch" ]]; then + echo + echo "NOTE: source arch (${source_arch}) differs from target arch (${target_arch})." + echo "Rebuild the Docker image on this host; do not reuse old binary caches or volumes." +fi + +echo +echo "Restore completed." +echo "Next steps:" +echo " 1) docker compose -f \"$REPO_ROOT/docker-compose.yml\" up -d --build --force-recreate openclaw-gateway" +echo " 2) docker compose -f \"$REPO_ROOT/docker-compose.yml\" run --rm openclaw-cli health" +echo " 3) docker compose -f \"$REPO_ROOT/docker-compose.yml\" run --rm openclaw-cli channels status --probe"