migrate code update

This commit is contained in:
kst
2026-08-27 15:47:52 +09:00
committed by GitHub
parent 4fc70e9697
commit 532ae47074
2 changed files with 802 additions and 0 deletions
+736
View File
@@ -0,0 +1,736 @@
#!/bin/bash
set -Eeuo pipefail
umask 077
BACKUP_NAME="workmachine-full-backup"
WORKMACHINE_CONTAINER="workmachine"
CLOUDFLARED_CONTAINER="chatgpt-cloudflared"
WORKMACHINE_IMAGE="workmachine-backup:latest"
CLOUDFLARED_IMAGE="cloudflared-backup:latest"
HELPER_IMAGE="alpine:3.22"
STATE_VOLUME="workmachine_cokacremote-state"
SHARED_SOURCE=""
TEMP_ROOT=""
WORKDIR=""
WM_WAS_RUNNING=0
CF_WAS_RUNNING=0
ORIGINALS_MAY_NEED_RESTART=0
WM_BACKUP_IMAGE_CREATED=0
CF_BACKUP_IMAGE_CREATED=0
WM_BACKUP_IMAGE_ID=""
CF_BACKUP_IMAGE_ID=""
WM_PREVIOUS_IMAGE_ID=""
CF_PREVIOUS_IMAGE_ID=""
die() {
echo "ERROR: $*" >&2
exit 1
}
set_backup_directory() {
local requested_directory="$1"
[ -d "$requested_directory" ] \
|| die "백업 디렉터리가 없습니다: $requested_directory"
BACKUP_DIRECTORY="$(cd "$requested_directory" && pwd -P)"
[ -w "$BACKUP_DIRECTORY" ] \
|| die "백업 디렉터기에 쓸 수 없습니다: $BACKUP_DIRECTORY"
ARCHIVE="$BACKUP_DIRECTORY/$BACKUP_NAME.tar.gz"
CHECKSUM="$BACKUP_DIRECTORY/$BACKUP_NAME.tar.gz.sha256"
}
set_shared_source() {
local requested_directory="$1"
[ -d "$requested_directory" ] \
|| die "공유 디렉터리가 없습니다: $requested_directory"
SHARED_SOURCE="$(cd "$requested_directory" && pwd -P)" \
|| die "공유 디렉터리의 절대 경로를 확인할 수 없습니다: $requested_directory"
}
usage() {
echo "사용법:"
echo " $0 backup <백업 디렉터리> <공유 디렉터리> # 기존 컴퓨터에서 전체 백업"
echo " $0 restore <백업 디렉터리> <공유 디렉터리> # 새 컴퓨터에서 전체 복원"
echo
echo "예:"
echo " $0 backup /Users/kst/Desktop /Users/kst/shared/cokacdircom"
echo " $0 restore /Users/kst/Desktop /Users/kst/shared/cokacdircom"
}
make_temp_root() {
TEMP_ROOT="$(mktemp -d "$BACKUP_DIRECTORY/.${BACKUP_NAME}.XXXXXX")" \
|| die "임시 작업 디렉터리를 만들 수 없습니다: $BACKUP_DIRECTORY"
WORKDIR="$TEMP_ROOT/$BACKUP_NAME"
}
cleanup_temp_root() {
[ -n "${TEMP_ROOT:-}" ] || return 0
case "$TEMP_ROOT" in
"$BACKUP_DIRECTORY"/."$BACKUP_NAME".*)
rm -rf "$TEMP_ROOT"
TEMP_ROOT=""
WORKDIR=""
;;
*)
echo "ERROR: 안전하지 않은 임시 경로는 삭제하지 않습니다: $TEMP_ROOT" >&2
return 1
;;
esac
}
verify_checksum() {
local expected_hash checksum_filename actual_output actual_hash
IFS=' ' read -r expected_hash checksum_filename < "$CHECKSUM" \
|| die "checksum 파일을 읽을 수 없습니다: $CHECKSUM"
[ "${#expected_hash}" -eq 64 ] \
|| die "checksum 형식이 올바르지 않습니다: $CHECKSUM"
case "$expected_hash" in
*[!0-9a-fA-F]*) die "checksum 형식이 올바르지 않습니다: $CHECKSUM" ;;
esac
actual_output="$(shasum -a 256 "$ARCHIVE")"
actual_hash="${actual_output%% *}"
[ "$actual_hash" = "$expected_hash" ] \
|| die "백업 파일 checksum이 일치하지 않습니다: $ARCHIVE"
echo "$(basename "$ARCHIVE"): OK"
}
validate_outer_archive() {
local member
tar tzf "$ARCHIVE" >/dev/null \
|| die "백업 압축파일을 읽을 수 없습니다: $ARCHIVE"
while IFS= read -r member; do
case "$member" in
"$BACKUP_NAME"|"$BACKUP_NAME/"|"$BACKUP_NAME/"*) ;;
*) die "백업 압축파일에 예상 범위 밖의 경로가 있습니다: $member" ;;
esac
case "$member" in
/*|../*|*/../*|*/..)
die "백업 압축파일에 안전하지 않은 경로가 있습니다: $member"
;;
esac
done < <(tar tzf "$ARCHIVE")
}
normalize_architecture() {
case "$1" in
aarch64|arm64) printf '%s\n' arm64 ;;
x86_64|amd64) printf '%s\n' amd64 ;;
*) printf '%s\n' "$1" ;;
esac
}
docker_engine_platform() {
local os architecture
os="$(docker info --format '{{.OSType}}')"
architecture="$(docker info --format '{{.Architecture}}')"
architecture="$(normalize_architecture "$architecture")"
printf '%s/%s\n' "$os" "$architecture"
}
image_platform() {
docker image inspect -f '{{.Os}}/{{.Architecture}}' "$1"
}
validate_loaded_platforms() {
local recorded_platform target_platform workmachine_platform
local cloudflared_platform helper_platform
recorded_platform="$(<"$WORKDIR/platform.txt")"
target_platform="$(docker_engine_platform)"
workmachine_platform="$(image_platform "$WORKMACHINE_IMAGE")"
cloudflared_platform="$(image_platform "$CLOUDFLARED_IMAGE")"
helper_platform="$(image_platform "$HELPER_IMAGE")"
[ "$workmachine_platform" = "$recorded_platform" ] \
|| die "백업 기록과 load된 workmachine 이미지 플랫폼이 다릅니다: 기록=$recorded_platform, 이미지=$workmachine_platform"
[ "$workmachine_platform" = "$target_platform" ] \
|| die "workmachine 이미지와 현재 Docker 플랫폼이 다릅니다: 이미지=$workmachine_platform, Docker=$target_platform"
[ "$cloudflared_platform" = "$target_platform" ] \
|| die "cloudflared 이미지와 현재 Docker 플랫폼이 다릅니다: 이미지=$cloudflared_platform, Docker=$target_platform"
[ "$helper_platform" = "$target_platform" ] \
|| die "Alpine helper 이미지와 현재 Docker 플랫폼이 다릅니다: 이미지=$helper_platform, Docker=$target_platform"
echo "백업/현재 Docker 플랫폼: $target_platform"
}
need_docker() {
command -v docker >/dev/null 2>&1 || die "docker 명령을 찾을 수 없습니다."
command -v curl >/dev/null 2>&1 || die "curl 명령을 찾을 수 없습니다."
command -v shasum >/dev/null 2>&1 || die "shasum 명령을 찾을 수 없습니다."
command -v tar >/dev/null 2>&1 || die "tar 명령을 찾을 수 없습니다."
docker info >/dev/null 2>&1 || die "Docker가 실행 중이 아닙니다."
docker compose version >/dev/null 2>&1 || die "docker compose를 사용할 수 없습니다."
}
container_running() {
[ "$(docker inspect -f '{{.State.Running}}' "$1" 2>/dev/null || true)" = "true" ]
}
container_env_value() {
local container="$1"
local variable="$2"
local entry
while IFS= read -r entry; do
case "$entry" in
"${variable}="*)
printf '%s\n' "${entry#*=}"
return 0
;;
esac
done < <(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$container")
return 1
}
validate_workmachine_mounts() {
local mount_count state_mount shared_mount shared_source_canonical
mount_count="$(docker inspect -f '{{len .Mounts}}' "$WORKMACHINE_CONTAINER")"
[ "$mount_count" = "2" ] \
|| die "$WORKMACHINE_CONTAINER의 mount 수가 예상과 다릅니다. /shared와 /var/lib/cokacremote 외의 데이터는 백업되지 않습니다: $mount_count"
state_mount="$(
docker inspect -f \
'{{range .Mounts}}{{if eq .Destination "/var/lib/cokacremote"}}{{printf "%s|%s|%t" .Type .Name .RW}}{{end}}{{end}}' \
"$WORKMACHINE_CONTAINER"
)"
[ "$state_mount" = "volume|$STATE_VOLUME|true" ] \
|| die "$WORKMACHINE_CONTAINER의 /var/lib/cokacremote mount가 예상과 다릅니다: $state_mount"
shared_source_canonical="$(cd "$SHARED_SOURCE" && pwd -P)"
shared_mount="$(
docker inspect -f \
'{{range .Mounts}}{{if eq .Destination "/shared"}}{{printf "%s|%s|%t" .Type .Source .RW}}{{end}}{{end}}' \
"$WORKMACHINE_CONTAINER"
)"
case "$shared_mount" in
"bind|$SHARED_SOURCE|true"|\
"bind|$shared_source_canonical|true"|\
"bind|/host_mnt$SHARED_SOURCE|true"|\
"bind|/host_mnt$shared_source_canonical|true")
;;
*) die "$WORKMACHINE_CONTAINER의 /shared mount가 예상과 다릅니다: $shared_mount" ;;
esac
}
restart_original() {
local failed=0
if [ "$WM_WAS_RUNNING" -eq 1 ] && ! container_running "$WORKMACHINE_CONTAINER"; then
if ! docker start "$WORKMACHINE_CONTAINER" >/dev/null; then
echo "ERROR: $WORKMACHINE_CONTAINER 재시작에 실패했습니다." >&2
failed=1
fi
fi
if [ "$CF_WAS_RUNNING" -eq 1 ] && ! container_running "$CLOUDFLARED_CONTAINER"; then
if ! docker start "$CLOUDFLARED_CONTAINER" >/dev/null; then
echo "ERROR: $CLOUDFLARED_CONTAINER 재시작에 실패했습니다." >&2
failed=1
fi
fi
return "$failed"
}
cleanup_backup_images() {
local failed=0
local current_image_id
if [ "$WM_BACKUP_IMAGE_CREATED" -eq 1 ]; then
current_image_id="$(docker image inspect -f '{{.Id}}' "$WORKMACHINE_IMAGE" 2>/dev/null || true)"
if [ "$current_image_id" = "$WM_BACKUP_IMAGE_ID" ]; then
docker image rm "$WORKMACHINE_IMAGE" >/dev/null 2>&1 || failed=1
elif [ -n "$current_image_id" ]; then
echo "ERROR: 백업 중 $WORKMACHINE_IMAGE 태그가 다른 이미지로 변경되어 건드리지 않습니다." >&2
failed=1
fi
if [ -n "$WM_PREVIOUS_IMAGE_ID" ] \
&& ! docker image inspect "$WORKMACHINE_IMAGE" >/dev/null 2>&1; then
docker image tag "$WM_PREVIOUS_IMAGE_ID" "$WORKMACHINE_IMAGE" >/dev/null 2>&1 \
|| failed=1
fi
WM_BACKUP_IMAGE_CREATED=0
fi
if [ "$CF_BACKUP_IMAGE_CREATED" -eq 1 ]; then
current_image_id="$(docker image inspect -f '{{.Id}}' "$CLOUDFLARED_IMAGE" 2>/dev/null || true)"
if [ "$current_image_id" = "$CF_BACKUP_IMAGE_ID" ]; then
docker image rm "$CLOUDFLARED_IMAGE" >/dev/null 2>&1 || failed=1
elif [ -n "$current_image_id" ]; then
echo "ERROR: 백업 중 $CLOUDFLARED_IMAGE 태그가 다른 이미지로 변경되어 건드리지 않습니다." >&2
failed=1
fi
if [ -n "$CF_PREVIOUS_IMAGE_ID" ] \
&& ! docker image inspect "$CLOUDFLARED_IMAGE" >/dev/null 2>&1; then
docker image tag "$CF_PREVIOUS_IMAGE_ID" "$CLOUDFLARED_IMAGE" >/dev/null 2>&1 \
|| failed=1
fi
CF_BACKUP_IMAGE_CREATED=0
fi
return "$failed"
}
backup_exit_trap() {
local status=$?
local restart_status=0
trap - EXIT INT TERM HUP
set +e
if [ "$ORIGINALS_MAY_NEED_RESTART" -eq 1 ]; then
restart_original
restart_status=$?
if [ "$restart_status" -ne 0 ]; then
echo "ERROR: 백업 종료 과정에서 원본 환경을 완전히 재시작하지 못했습니다." >&2
[ "$status" -ne 0 ] || status=1
fi
fi
cleanup_backup_images || [ "$status" -ne 0 ] || status=1
cleanup_temp_root || [ "$status" -ne 0 ] || status=1
exit "$status"
}
restore_exit_trap() {
local status=$?
trap - EXIT INT TERM HUP
set +e
cleanup_temp_root || [ "$status" -ne 0 ] || status=1
exit "$status"
}
restore_diagnostics() {
echo >&2
echo "===== workmachine 최근 로그 =====" >&2
docker logs --tail 100 "$WORKMACHINE_CONTAINER" >&2 || true
echo "===== cloudflared 최근 로그 =====" >&2
docker logs --tail 100 "$CLOUDFLARED_CONTAINER" >&2 || true
}
write_restore_compose() {
cat > "$WORKDIR/compose.restore.yml" <<'YAML'
services:
workmachine:
image: workmachine-backup:latest
container_name: workmachine
hostname: workmachine
restart: unless-stopped
working_dir: /shared
stop_grace_period: 30s
volumes:
- workmachine_cokacremote-state:/var/lib/cokacremote
- type: bind
source: ${WORKMACHINE_SHARED_SOURCE:?WORKMACHINE_SHARED_SOURCE is required}
target: /shared
cloudflared:
image: cloudflared-backup:latest
container_name: chatgpt-cloudflared
restart: unless-stopped
network_mode: "service:workmachine"
depends_on:
workmachine:
condition: service_healthy
volumes:
workmachine_cokacremote-state:
external: true
name: workmachine_cokacremote-state
YAML
}
backup() {
need_docker
docker container inspect "$WORKMACHINE_CONTAINER" >/dev/null 2>&1 \
|| die "$WORKMACHINE_CONTAINER 컨테이너가 없습니다."
docker container inspect "$CLOUDFLARED_CONTAINER" >/dev/null 2>&1 \
|| die "$CLOUDFLARED_CONTAINER 컨테이너가 없습니다."
docker volume inspect "$STATE_VOLUME" >/dev/null 2>&1 \
|| die "$STATE_VOLUME 볼륨이 없습니다."
[ -d "$SHARED_SOURCE" ] \
|| die "$SHARED_SOURCE 경로가 없습니다."
validate_workmachine_mounts
WM_ID="$(docker inspect -f '{{.Id}}' "$WORKMACHINE_CONTAINER")"
CF_NETWORK_MODE="$(docker inspect -f '{{.HostConfig.NetworkMode}}' "$CLOUDFLARED_CONTAINER")"
case "$CF_NETWORK_MODE" in
"container:$WM_ID"|"container:${WM_ID:0:12}"|"container:$WORKMACHINE_CONTAINER")
;;
container:*)
die "$CLOUDFLARED_CONTAINER가 현재 $WORKMACHINE_CONTAINER가 아닌 다른 컨테이너의 네트워크를 공유하고 있습니다: $CF_NETWORK_MODE"
;;
*)
die "$CLOUDFLARED_CONTAINER의 네트워크 모드가 예상과 다릅니다: $CF_NETWORK_MODE"
;;
esac
CF_MOUNT_COUNT="$(docker inspect -f '{{len .Mounts}}' "$CLOUDFLARED_CONTAINER")"
[ "$CF_MOUNT_COUNT" = "0" ] \
|| die "$CLOUDFLARED_CONTAINER에 별도 mount가 있습니다. 불완전 백업을 막기 위해 중단합니다."
MCP_PUBLIC_URL="$(container_env_value "$WORKMACHINE_CONTAINER" MCP_PUBLIC_URL)" \
|| die "$WORKMACHINE_CONTAINER에서 MCP_PUBLIC_URL을 찾을 수 없습니다."
MCP_PUBLIC_URL="${MCP_PUBLIC_URL%/}"
case "$MCP_PUBLIC_URL" in
https://*) ;;
*) die "MCP_PUBLIC_URL이 올바른 HTTPS URL이 아닙니다: $MCP_PUBLIC_URL" ;;
esac
[ ! -e "$ARCHIVE" ] \
|| die "기존 백업 파일을 덮어쓰지 않습니다. 먼저 옮기거나 이름을 바꾸세요: $ARCHIVE"
[ ! -e "$CHECKSUM" ] \
|| die "기존 checksum 파일을 덮어쓰지 않습니다. 먼저 옮기거나 이름을 바꾸세요: $CHECKSUM"
WM_PREVIOUS_IMAGE_ID="$(docker image inspect -f '{{.Id}}' "$WORKMACHINE_IMAGE" 2>/dev/null || true)"
CF_PREVIOUS_IMAGE_ID="$(docker image inspect -f '{{.Id}}' "$CLOUDFLARED_IMAGE" 2>/dev/null || true)"
if ! docker image inspect "$HELPER_IMAGE" >/dev/null 2>&1; then
docker pull "$HELPER_IMAGE"
fi
make_temp_root
trap backup_exit_trap EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
trap 'exit 129' HUP
mkdir -p "$WORKDIR"
WM_WAS_RUNNING=0
CF_WAS_RUNNING=0
if container_running "$WORKMACHINE_CONTAINER"; then
WM_WAS_RUNNING=1
fi
if container_running "$CLOUDFLARED_CONTAINER"; then
CF_WAS_RUNNING=1
fi
ORIGINALS_MAY_NEED_RESTART=1
echo "[1/8] 컨테이너 정지"
if [ "$CF_WAS_RUNNING" -eq 1 ]; then
docker stop "$CLOUDFLARED_CONTAINER" >/dev/null
fi
if [ "$WM_WAS_RUNNING" -eq 1 ]; then
docker stop "$WORKMACHINE_CONTAINER" >/dev/null
fi
echo "[2/8] workmachine 현재 상태 이미지화"
WM_BACKUP_IMAGE_ID="$(docker commit "$WORKMACHINE_CONTAINER" "$WORKMACHINE_IMAGE")"
WM_BACKUP_IMAGE_CREATED=1
echo "[3/8] cloudflared 현재 상태 이미지화"
CF_BACKUP_IMAGE_ID="$(docker commit "$CLOUDFLARED_CONTAINER" "$CLOUDFLARED_IMAGE")"
CF_BACKUP_IMAGE_CREATED=1
echo "[4/8] 이미지 저장"
docker save -o "$WORKDIR/workmachine-image.tar" "$WORKMACHINE_IMAGE"
docker save -o "$WORKDIR/cloudflared-image.tar" "$CLOUDFLARED_IMAGE"
docker save -o "$WORKDIR/alpine-image.tar" "$HELPER_IMAGE"
echo "[5/8] /var/lib/cokacremote 볼륨 백업"
docker run --rm \
-v "$STATE_VOLUME:/data:ro" \
-v "$WORKDIR:/backup" \
"$HELPER_IMAGE" \
tar czpf /backup/cokacremote-state.tar.gz -C /data .
echo "[6/8] 설정/검증 정보 저장"
docker inspect "$WORKMACHINE_CONTAINER" > "$WORKDIR/workmachine-inspect.json"
docker inspect "$CLOUDFLARED_CONTAINER" > "$WORKDIR/cloudflared-inspect.json"
docker volume inspect "$STATE_VOLUME" > "$WORKDIR/cokacremote-state-volume-inspect.json"
docker image inspect -f '{{.Os}}/{{.Architecture}}' "$WORKMACHINE_IMAGE" \
> "$WORKDIR/platform.txt"
printf '%s\n' "$MCP_PUBLIC_URL" > "$WORKDIR/mcp-public-url.txt"
write_restore_compose
WORKMACHINE_SHARED_SOURCE="$SHARED_SOURCE" \
docker compose -p workmachine -f "$WORKDIR/compose.restore.yml" config >/dev/null
chmod -R go-rwx "$WORKDIR"
echo "[7/8] 기존 환경 다시 시작"
if ! restart_original; then
die "원본 환경을 완전히 재시작하지 못했습니다."
fi
ORIGINALS_MAY_NEED_RESTART=0
echo "[8/8] 백업 파일 하나로 묶기"
ARCHIVE_TMP="$TEMP_ROOT/$BACKUP_NAME.tar.gz"
CHECKSUM_TMP="$TEMP_ROOT/$BACKUP_NAME.tar.gz.sha256"
tar czf "$ARCHIVE_TMP" \
-C "$TEMP_ROOT" \
"$BACKUP_NAME"
(
cd "$TEMP_ROOT"
shasum -a 256 "$BACKUP_NAME.tar.gz" > "$BACKUP_NAME.tar.gz.sha256"
)
chmod 600 "$ARCHIVE_TMP" "$CHECKSUM_TMP"
[ ! -e "$ARCHIVE" ] \
|| die "백업 도중 같은 이름의 파일이 생성되어 덮어쓰지 않습니다: $ARCHIVE"
[ ! -e "$CHECKSUM" ] \
|| die "백업 도중 같은 이름의 파일이 생성되어 덮어쓰지 않습니다: $CHECKSUM"
mv "$ARCHIVE_TMP" "$ARCHIVE"
if ! mv "$CHECKSUM_TMP" "$CHECKSUM"; then
rm -f "$ARCHIVE"
die "checksum 파일을 최종 위치로 이동하지 못했습니다."
fi
cleanup_backup_images \
|| die "임시 백업 이미지 태그를 원래 상태로 되돌리지 못했습니다."
cleanup_temp_root
trap - EXIT INT TERM HUP
echo
echo "백업 완료:"
echo " $ARCHIVE"
echo " $CHECKSUM"
echo
echo "주의: 백업에는 OAuth 상태/키와 Cloudflare Tunnel 인증정보가 포함될 수 있습니다."
}
confirm_replace() {
FOUND=0
if docker container inspect "$WORKMACHINE_CONTAINER" >/dev/null 2>&1; then
FOUND=1
fi
if docker container inspect "$CLOUDFLARED_CONTAINER" >/dev/null 2>&1; then
FOUND=1
fi
if docker volume inspect "$STATE_VOLUME" >/dev/null 2>&1; then
FOUND=1
fi
if [ "$FOUND" -eq 1 ]; then
echo "기존 workmachine 관련 컨테이너 또는 볼륨이 존재합니다."
echo "복원을 계속하면 해당 컨테이너와 $STATE_VOLUME 볼륨을 삭제하고 백업본으로 교체합니다."
printf "계속하려면 RESTORE 를 입력하세요: "
read -r ANSWER || die "복원 확인 입력을 읽을 수 없습니다."
[ "$ANSWER" = "RESTORE" ] || die "복원을 취소했습니다."
fi
}
restore() {
need_docker
[ -f "$ARCHIVE" ] || die "$ARCHIVE 파일이 없습니다."
[ -f "$CHECKSUM" ] || die "$CHECKSUM 파일이 없습니다. checksum 없이는 복원하지 않습니다."
[ -d "$SHARED_SOURCE" ] || die "$SHARED_SOURCE 경로가 준비되어 있지 않습니다."
chmod 600 "$ARCHIVE" "$CHECKSUM"
echo "[1/11] 백업 파일 무결성 확인"
verify_checksum
echo "[2/11] 안전한 임시 디렉터리에 백업 압축 해제"
make_temp_root
trap restore_exit_trap EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
trap 'exit 129' HUP
validate_outer_archive
tar xzf "$ARCHIVE" -C "$TEMP_ROOT"
[ -d "$WORKDIR" ] \
|| die "$BACKUP_NAME 디렉터리가 백업 안에 없습니다."
chmod -R go-rwx "$WORKDIR"
echo "[3/11] 백업 구성파일 확인"
[ -f "$WORKDIR/workmachine-image.tar" ] \
|| die "workmachine-image.tar가 백업 안에 없습니다."
[ -f "$WORKDIR/cloudflared-image.tar" ] \
|| die "cloudflared-image.tar가 백업 안에 없습니다."
[ -f "$WORKDIR/alpine-image.tar" ] \
|| die "alpine-image.tar가 백업 안에 없습니다."
[ -f "$WORKDIR/cokacremote-state.tar.gz" ] \
|| die "cokacremote-state.tar.gz가 백업 안에 없습니다."
[ -f "$WORKDIR/compose.restore.yml" ] \
|| die "compose.restore.yml이 백업 안에 없습니다."
[ -f "$WORKDIR/mcp-public-url.txt" ] \
|| die "mcp-public-url.txt가 백업 안에 없습니다."
[ -f "$WORKDIR/platform.txt" ] \
|| die "platform.txt가 백업 안에 없습니다."
MCP_PUBLIC_URL="$(<"$WORKDIR/mcp-public-url.txt")"
case "$MCP_PUBLIC_URL" in
https://*) ;;
*) die "백업의 MCP_PUBLIC_URL이 올바른 HTTPS URL이 아닙니다: $MCP_PUBLIC_URL" ;;
esac
confirm_replace
echo "[4/11] Docker 이미지 load"
docker load -i "$WORKDIR/alpine-image.tar"
docker load -i "$WORKDIR/workmachine-image.tar"
docker load -i "$WORKDIR/cloudflared-image.tar"
echo "[5/11] 이미지 및 플랫폼 사전검증"
validate_loaded_platforms
docker run --rm --pull never "$HELPER_IMAGE" true
docker run --rm --pull never --entrypoint /bin/true "$WORKMACHINE_IMAGE"
docker run --rm --pull never "$CLOUDFLARED_IMAGE" version >/dev/null
echo "[6/11] Compose, 공유 경로 및 상태 백업 사전검증"
WORKMACHINE_SHARED_SOURCE="$SHARED_SOURCE" \
docker compose \
-p workmachine \
-f "$WORKDIR/compose.restore.yml" \
config >/dev/null
docker run --rm --pull never \
-v "$SHARED_SOURCE:/shared:ro" \
"$HELPER_IMAGE" \
test -d /shared
docker run --rm --pull never \
-v "$WORKDIR:/backup:ro" \
"$HELPER_IMAGE" \
tar tzf /backup/cokacremote-state.tar.gz >/dev/null
echo "[7/11] 기존 workmachine 환경 제거"
if docker container inspect "$CLOUDFLARED_CONTAINER" >/dev/null 2>&1; then
docker rm -f "$CLOUDFLARED_CONTAINER" >/dev/null \
|| die "$CLOUDFLARED_CONTAINER 컨테이너를 삭제할 수 없습니다."
fi
if docker container inspect "$WORKMACHINE_CONTAINER" >/dev/null 2>&1; then
docker rm -f "$WORKMACHINE_CONTAINER" >/dev/null \
|| die "$WORKMACHINE_CONTAINER 컨테이너를 삭제할 수 없습니다."
fi
if docker volume inspect "$STATE_VOLUME" >/dev/null 2>&1; then
docker volume rm "$STATE_VOLUME" >/dev/null \
|| die "$STATE_VOLUME 볼륨을 삭제할 수 없습니다. 다른 컨테이너에서 사용 중인지 확인하세요."
fi
echo "[8/11] /var/lib/cokacremote 볼륨 생성"
docker volume create "$STATE_VOLUME" >/dev/null
echo "[9/11] /var/lib/cokacremote 내용 복원"
docker run --rm \
-v "$STATE_VOLUME:/data" \
-v "$WORKDIR:/backup:ro" \
"$HELPER_IMAGE" \
tar xzpf /backup/cokacremote-state.tar.gz -C /data
echo "[10/11] workmachine + cloudflared를 Compose 프로젝트로 시작"
WORKMACHINE_SHARED_SOURCE="$SHARED_SOURCE" \
docker compose \
-p workmachine \
-f "$WORKDIR/compose.restore.yml" \
up -d \
--no-build \
--pull never
echo "[11/11] 상태 확인"
HEALTH=""
COUNT=0
while [ "$COUNT" -lt 60 ]; do
HEALTH="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$WORKMACHINE_CONTAINER" 2>/dev/null || true)"
if [ "$HEALTH" = "healthy" ]; then
break
fi
sleep 1
COUNT=$((COUNT + 1))
done
if [ "$HEALTH" != "healthy" ]; then
restore_diagnostics
die "workmachine이 제한 시간 안에 healthy 상태가 되지 않았습니다: $HEALTH"
fi
CLOUDFLARED_STATUS="$(docker inspect -f '{{.State.Status}}' "$CLOUDFLARED_CONTAINER" 2>/dev/null || true)"
if [ "$CLOUDFLARED_STATUS" != "running" ]; then
restore_diagnostics
die "cloudflared가 running 상태가 아닙니다: $CLOUDFLARED_STATUS"
fi
PUBLIC_HEALTH_URL="${MCP_PUBLIC_URL%/}/health"
PUBLIC_HEALTH_OK=0
COUNT=0
while [ "$COUNT" -lt 30 ]; do
if PUBLIC_HEALTH_BODY="$(
curl -fsS \
--connect-timeout 3 \
--max-time 5 \
-H 'Cache-Control: no-cache' \
"${PUBLIC_HEALTH_URL}?migration_check=$(date +%s)" \
2>/dev/null
)"; then
case "$PUBLIC_HEALTH_BODY" in
*'"status":"ok"'*)
PUBLIC_HEALTH_OK=1
break
;;
esac
fi
sleep 2
COUNT=$((COUNT + 1))
done
if [ "$PUBLIC_HEALTH_OK" -ne 1 ]; then
restore_diagnostics
die "Cloudflare Tunnel을 통한 공개 health 확인에 실패했습니다: $PUBLIC_HEALTH_URL"
fi
echo
docker ps --filter "name=workmachine" --filter "name=chatgpt-cloudflared"
echo
echo "workmachine 상태: $HEALTH"
echo "cloudflared 상태: $CLOUDFLARED_STATUS"
echo "공개 health 확인: $PUBLIC_HEALTH_URL"
cleanup_temp_root
trap - EXIT INT TERM HUP
echo "복원 완료."
}
case "${1:-}" in
backup)
[ "$#" -eq 3 ] || { usage; exit 1; }
set_backup_directory "$2"
set_shared_source "$3"
backup
;;
restore)
[ "$#" -eq 3 ] || { usage; exit 1; }
set_backup_directory "$2"
set_shared_source "$3"
restore
;;
*)
usage
exit 1
;;
esac