#!/bin/sh set -eu APPLICATION_IMAGE='@@APPLICATION_IMAGE@@' APPLICATION_ID='@@APPLICATION_ID@@' OPERATOR_IMAGE='@@OPERATOR_IMAGE@@' OPERATOR_ID='@@OPERATOR_ID@@' ARCHITECTURE='@@ARCHITECTURE@@' SOURCE_REVISION='@@SOURCE_REVISION@@' ARCHIVE='@@ARCHIVE@@' VARIANT='@@VARIANT@@' fail() { printf '%s\n' "QingLong Local Alpha cutover rehearsal failed: $*" >&2 exit 1 } usage() { printf '%s\n' \ 'usage: sh upgrade-cutover-rehearsal.sh edge|standalone /absolute/legacy-data-root /absolute/new/rehearsal-root [legacy-container-name] [target-container-name]' >&2 exit 2 } valid_digest() { [ "${#1}" -eq 64 ] || return 1 case "$1" in *[!0-9a-f]*) return 1 ;; *) return 0 ;; esac } extract_digest() { result_file=$1 field=$2 value=$(sed -n "s/^.*\"$field\":\"\([0-9a-f][0-9a-f]*\)\".*$/\1/p" "$result_file") valid_digest "$value" || fail "$field is missing or invalid in $result_file" printf '%s' "$value" } container_name() { case "$1" in ''|[_.-]*|*[!A-Za-z0-9_.-]*) return 1 ;; *) return 0 ;; esac } [ "$#" -ge 5 ] && [ "$#" -le 7 ] || usage [ "$VARIANT" = headless ] || fail 'cutover rehearsal is available only in the headless Trial Kit' profile=$1 legacy_root=$2 rehearsal_root=$3 sqlite_plan_digest=$4 directory_plan_digest=$5 legacy_name=${6:-ql3-alpha-upgrade-legacy} target_name=${7:-ql3-alpha-upgrade-target} case "$profile" in edge) memory=128m; pids=64 ;; standalone) memory=256m; pids=256 ;; *) usage ;; esac container_name "$legacy_name" || fail 'legacy container name is invalid' container_name "$target_name" || fail 'target container name is invalid' [ "$legacy_name" != "$target_name" ] || fail 'container names must be distinct' valid_digest "$sqlite_plan_digest" || fail 'reviewed SQLite plan digest is invalid' valid_digest "$directory_plan_digest" || fail 'reviewed data-directory plan digest is invalid' [ "$(uname -s)" = Linux ] || fail 'cutover rehearsal requires a Linux Docker host' for tool in docker sha256sum grep sed stat date realpath; do command -v "$tool" >/dev/null 2>&1 || fail "$tool is required" done docker_socket=$(realpath /var/run/docker.sock) [ -S "$docker_socket" ] || fail 'canonical Docker socket is unavailable' operator_docker_socket=/run/docker.sock script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P) (CDPATH= cd -- "$script_dir" && sha256sum --check SHA256SUMS) docker info >/dev/null 2>&1 || fail 'docker daemon is unavailable' docker load --input "$script_dir/$ARCHIVE" >/dev/null application_identity=$(docker image inspect --format '{{.Id}}|{{.Architecture}}|{{.Config.User}}|{{index .Config.Labels "org.opencontainers.image.revision"}}' "$APPLICATION_IMAGE") operator_identity=$(docker image inspect --format '{{.Id}}|{{.Architecture}}|{{.Config.User}}|{{index .Config.Labels "org.opencontainers.image.revision"}}|{{index .Config.Labels "io.qinglong.lifecycle"}}|{{index .Config.Labels "io.qinglong.network"}}' "$OPERATOR_IMAGE") [ "$application_identity" = "$APPLICATION_ID|$ARCHITECTURE|65532:65532|$SOURCE_REVISION" ] || fail 'application image identity is incompatible' [ "$operator_identity" = "$OPERATOR_ID|$ARCHITECTURE|65532:65532|$SOURCE_REVISION|short-lived|none-by-default" ] || fail 'operator image identity is incompatible' docker run --rm --read-only --network none --cap-drop ALL \ --security-opt no-new-privileges --entrypoint /usr/bin/docker \ "$OPERATOR_IMAGE" --version >/dev/null [ ! -e "$rehearsal_root" ] || fail 'rehearsal root must not already exist' sh "$script_dir/upgrade-rehearsal.sh" \ "$profile" "$legacy_root" "$rehearsal_root" \ "$sqlite_plan_digest" "$directory_plan_digest" legacy_sha256=$(sha256sum "$legacy_root/db/database.sqlite" | sed 's/ .*//') uid=$(id -u) gid=$(id -g) [ "$uid" -eq 0 ] && allow_root_service=true || allow_root_service=false socket_gid=$(stat -c %g "$docker_socket") old_umask=$(umask) umask 077 for directory in owner-peppers owner-pepper-backup owner-delivery receipts artifacts plugin-staging plugin-activation service service/cutovers service/cutovers/alpha-upgrade-cutover; do [ -d "$rehearsal_root/$directory" ] || mkdir -m 0700 "$rehearsal_root/$directory" done run_operator() { command_name=$1 command_file=$2 result_file=$3 docker run --rm --read-only --user "$uid:$gid" --network none \ --cap-drop ALL --security-opt no-new-privileges \ --memory 128m --memory-swap 128m --cpus 0.5 --pids-limit 32 \ --tmpfs /tmp:rw,nosuid,nodev,noexec,size=8m \ --mount "type=bind,src=$legacy_root,dst=$legacy_root,readonly" \ --mount "type=bind,src=$rehearsal_root,dst=$rehearsal_root" \ "$OPERATOR_IMAGE" "$command_name" run \ --command-file "$rehearsal_root/commands/$command_file" \ >"$rehearsal_root/results/$result_file" } run_deploy() { subcommand=$1 command_file=$2 result_file=$3 docker run --rm --read-only --user "$uid:$gid" \ --group-add "$socket_gid" --network none \ --cap-drop ALL --security-opt no-new-privileges \ --memory 128m --memory-swap 128m --cpus 0.5 --pids-limit 32 \ --tmpfs /tmp:rw,nosuid,nodev,noexec,size=8m \ --mount "type=bind,src=$docker_socket,dst=$operator_docker_socket" \ --mount "type=bind,src=$legacy_root,dst=$legacy_root,readonly" \ --mount "type=bind,src=$rehearsal_root,dst=$rehearsal_root" \ "$OPERATOR_IMAGE" deploy "$subcommand" \ --command-file "$rehearsal_root/commands/$command_file" \ >"$rehearsal_root/results/$result_file" } run_deployment_offline() { subcommand=$1 command_file=$2 result_file=$3 docker run --rm --read-only --user "$uid:$gid" --network none \ --cap-drop ALL --security-opt no-new-privileges \ --memory 128m --memory-swap 128m --cpus 0.5 --pids-limit 32 \ --tmpfs /tmp:rw,nosuid,nodev,noexec,size=8m \ --mount "type=bind,src=$legacy_root,dst=$legacy_root,readonly" \ --mount "type=bind,src=$rehearsal_root,dst=$rehearsal_root" \ "$OPERATOR_IMAGE" deploy "$subcommand" \ --command-file "$rehearsal_root/commands/$command_file" \ >"$rehearsal_root/results/$result_file" } cat >"$rehearsal_root/commands/setup.json" <"$rehearsal_root/commands/owner-provision.json" <"$rehearsal_root/commands/owner-challenge.json" <"$rehearsal_root/commands/owner-claim.json" <"$rehearsal_root/commands/owner-credential-install.json" <"$rehearsal_root/commands/data-directory-transform.json" <"$rehearsal_root/commands/data-directory-transform-verify.json" chmod 0600 "$rehearsal_root/commands/data-directory-transform-verify.json" run_operator adoption data-directory-transform-verify.json data-directory-transform-verify.result.json grep -q '"status":"verified"' "$rehearsal_root/results/data-directory-transform-verify.result.json" || fail 'data-directory transformation verification failed' cleanup_required=1 cleanup() { if [ "$cleanup_required" -eq 1 ]; then docker rm --force "$target_name" "$legacy_name" >/dev/null 2>&1 || true fi } trap cleanup EXIT trap 'exit 130' HUP INT TERM legacy_id=$(docker run --detach --name "$legacy_name" --restart no \ --read-only --user "$uid:$gid" --network none --cap-drop ALL \ --security-opt no-new-privileges --memory 64m --memory-swap 64m \ --pids-limit 16 --tmpfs /tmp:rw,nosuid,nodev,noexec,size=4m \ --mount "type=bind,src=$legacy_root,dst=$legacy_root,readonly" \ --entrypoint /usr/local/bin/node "$OPERATOR_IMAGE" \ -e 'setInterval(() => {}, 60000)') [ "${#legacy_id}" -eq 64 ] || fail 'legacy container ID is invalid' case "$legacy_id" in *[!0-9a-f]*) fail 'legacy container ID is invalid' ;; esac now_ms=$(($(date +%s) * 1000)) cat >"$rehearsal_root/commands/legacy-stop.json" <"$rehearsal_root/commands/data-directory-apply.json" <"$rehearsal_root/commands/data-directory-apply-verify.json" chmod 0600 "$rehearsal_root/commands/data-directory-apply-verify.json" run_operator adoption data-directory-apply-verify.json data-directory-apply-verify.result.json grep -q '"status":"verified"' "$rehearsal_root/results/data-directory-apply-verify.result.json" || fail 'authenticated data application verification failed' prepared_ms=$((now_ms + 1)) cat >"$rehearsal_root/commands/adopted-prepare.json" <"$rehearsal_root/commands/adopted-verify.json" chmod 0600 "$rehearsal_root/commands/adopted-verify.json" run_deployment_offline adopted-verify adopted-verify.json adopted-verify.result.json grep -q '"status":"verified"' "$rehearsal_root/results/adopted-verify.result.json" || fail 'adopted bundle verification failed' bundle_digest=$(extract_digest "$rehearsal_root/results/adopted-verify.result.json" bundleDigest) grep -Fq "\"reference\": \"$APPLICATION_IMAGE\"" "$rehearsal_root/service/docker-target.json" || fail 'target descriptor image reference drifted' grep -Fq "\"imageId\": \"$APPLICATION_ID\"" "$rehearsal_root/service/docker-target.json" || fail 'target descriptor image ID drifted' target_id=$(docker create --name "$target_name" --restart no \ --read-only --user "$uid:$gid" --network none --cap-drop ALL \ --security-opt no-new-privileges --memory "$memory" --memory-swap "$memory" \ --cpus 0.5 --pids-limit "$pids" \ --tmpfs /tmp:rw,nosuid,nodev,noexec,size=16m \ --mount "type=bind,src=$rehearsal_root,dst=$rehearsal_root" \ --mount "type=bind,src=$legacy_root,dst=$legacy_root,readonly" \ "$APPLICATION_IMAGE" --config "$rehearsal_root/local-application.json") [ "${#target_id}" -eq 64 ] || fail 'target container ID is invalid' case "$target_id" in *[!0-9a-f]*) fail 'target container ID is invalid' ;; esac start_ms=$((prepared_ms + 1)) cat >"$rehearsal_root/commands/target-start.json" <"$rehearsal_root/commands/target-stop.json" chmod 0600 "$rehearsal_root/commands/target-stop.json" run_deploy cutover-target-stop target-stop.json target-stop.result.json grep -q '"reconciliation":"rollback_candidate"' "$rehearsal_root/results/target-stop.result.json" || fail 'target stop did not produce a clean rollback candidate' [ "$(sha256sum "$legacy_root/db/database.sqlite" | sed 's/ .*//')" = "$legacy_sha256" ] || fail 'legacy database changed during rehearsal' cat >"$rehearsal_root/cutover-summary.json" <