#!/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 } phase() { printf '%s\n' "QingLong Local Alpha cutover phase: $1" >&2 } 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] [--capture-after-write /absolute/new/capture-root]' >&2 exit 2 } safe_absolute_path() { case "$1" in /|*[!A-Za-z0-9_./@-]*|*'/../'*|*'/./'*|*'/..'|*'/.'|*'//'*|*/) return 1 ;; /*) return 0 ;; *) return 1 ;; esac } 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" } extract_unsigned() { result_file=$1 field=$2 value=$(sed -n "s/^.*\"$field\":\([0-9][0-9]*\).*$/\1/p" "$result_file") case "$value" in ''|*[!0-9]*) fail "$field is missing or invalid in $result_file" ;; esac printf '%s' "$value" } container_name() { case "$1" in ''|[_.-]*|*[!A-Za-z0-9_.-]*) return 1 ;; *) return 0 ;; esac } case "$#" in 5|6|7) capture_after_write=false capture_root= ;; 9) [ "$8" = '--capture-after-write' ] || usage capture_after_write=true capture_root=$9 ;; *) usage ;; esac case "$VARIANT" in headless|console) ;; *) fail 'embedded Trial Kit variant is invalid' ;; esac 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' if [ "$capture_after_write" = true ]; then safe_absolute_path "$capture_root" || fail 'capture root is not a safe canonical absolute path' [ "$capture_root" != "$legacy_root" ] || fail 'capture and legacy roots must be distinct' [ "$capture_root" != "$rehearsal_root" ] || fail 'capture and rehearsal roots must be distinct' case "$capture_root/" in "$legacy_root"/*|"$rehearsal_root"/*) fail 'capture root must not be inside a data root' ;; esac case "$legacy_root/" in "$capture_root"/*) fail 'legacy root must not be inside the capture root' ;; esac case "$rehearsal_root/" in "$capture_root"/*) fail 'rehearsal root must not be inside the capture root' ;; esac [ ! -e "$capture_root" ] || fail 'capture root must not already exist' capture_parent=${capture_root%/*} [ -n "$capture_parent" ] || capture_parent=/ [ -d "$capture_parent" ] || fail 'capture root parent does not exist' capture_parent_real=$(CDPATH= cd -- "$capture_parent" && pwd -P) [ "$capture_parent_real/${capture_root##*/}" = "$capture_root" ] || fail 'capture root parent is not canonical' fi [ "$(uname -s)" = Linux ] || fail 'cutover rehearsal requires a Linux Docker host' for tool in docker sha256sum grep sed stat date realpath tr; 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 if [ "$capture_after_write" = true ]; then mkdir -m 0700 "$capture_root" fi 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" } run_reconciliation_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" \ --mount "type=bind,src=$capture_root,dst=$capture_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_entry_config_path="$rehearsal_root/local-application.json" target_entrypoint=local_application target_api_request= if [ "$VARIANT" = console ]; then target_entry_config_path="$rehearsal_root/local-api.json" target_entrypoint=local_api cat >"$target_entry_config_path" <"$rehearsal_root/commands/target-start.json" <&2 sed -n '1p' "$rehearsal_root/results/target-start.result.json" >&2 grep -q '"state":"target_active"' "$rehearsal_root/results/target-start.result.json" || fail 'target did not become active' if [ "$capture_after_write" = true ]; then write_ms=$((start_ms + 1)) cat >"$rehearsal_root/commands/post-cutover-task.json" <"$rehearsal_root/commands/target-stop.json" chmod 0600 "$rehearsal_root/commands/target-stop.json" if [ "$capture_after_write" = true ]; then phase 'stop written target and require reconciliation' else phase 'stop target and prove rollback candidate' fi run_deploy cutover-target-stop target-stop.json target-stop.result.json printf '%s' 'QingLong Local Alpha target-stop result: ' >&2 sed -n '1p' "$rehearsal_root/results/target-stop.result.json" >&2 printf '%s' 'QingLong Local Alpha target-stop evidence: ' >&2 tr -d '\n' <"$rehearsal_root/service/cutovers/alpha-upgrade-cutover/0006-target-stop-outcome.json" >&2 printf '\n' >&2 [ "$(sha256sum "$legacy_root/db/database.sqlite" | sed 's/ .*//')" = "$legacy_sha256" ] || fail 'legacy database changed during rehearsal' if [ "$capture_after_write" = true ]; then grep -q '"reconciliation":"reconciliation_required"' "$rehearsal_root/results/target-stop.result.json" || fail 'written target was not fenced for reconciliation' stopped_head_digest=$(extract_digest "$rehearsal_root/results/target-stop.result.json" instanceHeadDigest) stopped_record_digest=$(extract_digest "$rehearsal_root/results/target-stop.result.json" recordDigest) capture_id='019f8680-143d-4000-8000-000000000161' capture_prepare_ms=$((stop_ms + 1)) cat >"$rehearsal_root/commands/reconciliation-capture-prepare.json" <"$rehearsal_root/commands/reconciliation-capture-commit.json" <"$rehearsal_root/commands/reconciliation-capture-verify.json" <"$rehearsal_root/reconciliation-capture-summary.json" <"$rehearsal_root/cutover-summary.json" <