Files
awoooi/scripts/reboot-recovery/deploy-agent99-via-windows99-ssh.sh

331 lines
11 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
readonly SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
readonly DEFAULT_SOURCE_ROOT="$(CDPATH= cd -- "${SCRIPT_DIR}/../.." && pwd)"
readonly TARGET="Administrator@192.168.0.99"
readonly SOURCE_HOST_IPV4="192.168.0.110"
readonly GITEA_ORIGIN_HTTPS="https://gitea.wooo.work/wooo/awoooi.git"
readonly GITEA_ORIGIN_HTTP_INTERNAL="http://192.168.0.110:3000/wooo/awoooi.git"
readonly GITEA_ORIGIN_SSH_INTERNAL="ssh://git@192.168.0.110:2222/wooo/awoooi.git"
readonly RECEIVER_RELATIVE="scripts/reboot-recovery/agent99-remote-atomic-deploy-receiver.ps1"
readonly PREFLIGHT_RELATIVE="scripts/reboot-recovery/agent99-live-preflight.ps1"
readonly WRAPPER_RELATIVE="scripts/reboot-recovery/deploy-agent99-via-windows99-ssh.sh"
RUNTIME_FILES=(
"agent99-bootstrap.ps1"
"agent99-contract-check.ps1"
"agent99-control-plane.ps1"
"agent99-deploy.ps1"
"agent99-register-tasks.ps1"
"agent99-sre-alert-inbox.ps1"
"agent99-sre-alert-relay.ps1"
"agent99-submit-request.ps1"
"agent99-synthetic-tests.ps1"
"agent99-telegram-inbox.ps1"
"agent99-windows-update-policy.ps1"
"host-reboot-scorecard.ps1"
"agent99.config.example.json"
"agent99.config.99.example.json"
)
MODE="check"
MODE_SELECTION="default"
SOURCE_ROOT="${DEFAULT_SOURCE_ROOT}"
SOURCE_REVISION=""
TRACE_ID=""
RUN_ID=""
WORK_ITEM_ID=""
IDENTITY_FILE=""
KNOWN_HOSTS_FILE="${HOME:-}/.ssh/known_hosts"
CONNECT_TIMEOUT_SECONDS="8"
REMOTE_EXECUTION_TIMEOUT_SECONDS="45"
usage() {
printf '%s\n' \
"Usage: $0 [--check|--apply] [options]" \
"" \
"Default is a remote no-write check/plan against fixed target ${TARGET}." \
"Apply requires --apply plus --trace-id, --run-id, and --work-item-id." \
"" \
"Options:" \
" --check No-write transport and envelope check (default)." \
" --apply Stage, ValidateOnly, bounded promote, and verify." \
" --trace-id ID Controlled-apply trace identity." \
" --run-id ID Controlled-apply run identity." \
" --work-item-id ID Controlled-apply work-item identity." \
" --source-root PATH Checkout root (default: repository root)." \
" --source-revision SHA Exact 40-character Git commit (default: HEAD)." \
" --identity-file PATH Existing SSH identity; wrapper never reads or prints its contents." \
" --known-hosts-file PATH Pinned known_hosts path (default: ~/.ssh/known_hosts)." \
" --connect-timeout SECONDS SSH connect timeout from 1 through 30 (default: 8)." \
" -h, --help Show this help."
}
fail() {
printf 'agent99_transport_error=%s\n' "$1" >&2
exit 64
}
require_value() {
[[ $# -ge 2 && -n "$2" ]] || fail "missing_option_value"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--check)
[[ "${MODE_SELECTION}" != "apply" ]] || fail "conflicting_mode_flags"
MODE="check"
MODE_SELECTION="check"
shift
;;
--apply)
[[ "${MODE_SELECTION}" != "check" ]] || fail "conflicting_mode_flags"
MODE="apply"
MODE_SELECTION="apply"
shift
;;
--trace-id)
require_value "$@"
TRACE_ID="$2"
shift 2
;;
--run-id)
require_value "$@"
RUN_ID="$2"
shift 2
;;
--work-item-id)
require_value "$@"
WORK_ITEM_ID="$2"
shift 2
;;
--source-root)
require_value "$@"
SOURCE_ROOT="$2"
shift 2
;;
--source-revision)
require_value "$@"
SOURCE_REVISION="$2"
shift 2
;;
--identity-file)
require_value "$@"
IDENTITY_FILE="$2"
shift 2
;;
--known-hosts-file)
require_value "$@"
KNOWN_HOSTS_FILE="$2"
shift 2
;;
--connect-timeout)
require_value "$@"
CONNECT_TIMEOUT_SECONDS="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
fail "unsupported_option"
;;
esac
done
[[ "${CONNECT_TIMEOUT_SECONDS}" =~ ^[0-9]+$ ]] || fail "invalid_connect_timeout"
(( CONNECT_TIMEOUT_SECONDS >= 1 && CONNECT_TIMEOUT_SECONDS <= 30 )) || fail "invalid_connect_timeout"
if [[ "${MODE}" == "apply" ]]; then
REMOTE_EXECUTION_TIMEOUT_SECONDS="2400"
readonly SAFE_ID_PATTERN='^[A-Za-z0-9][A-Za-z0-9._:@+-]{0,127}$'
[[ "${TRACE_ID}" =~ ${SAFE_ID_PATTERN} ]] || fail "apply_trace_id_missing_or_invalid"
[[ "${RUN_ID}" =~ ${SAFE_ID_PATTERN} ]] || fail "apply_run_id_missing_or_invalid"
[[ "${WORK_ITEM_ID}" =~ ${SAFE_ID_PATTERN} ]] || fail "apply_work_item_id_missing_or_invalid"
fi
for dependency in git python3 ssh mktemp hostname timeout; do
command -v "${dependency}" >/dev/null 2>&1 || fail "required_command_missing_${dependency}"
done
SOURCE_HOST_VERIFIED=0
read -r -a LOCAL_ADDRESSES <<<"$(hostname -I 2>/dev/null || true)"
for local_address in "${LOCAL_ADDRESSES[@]}"; do
if [[ "${local_address%%/*}" == "${SOURCE_HOST_IPV4}" ]]; then
SOURCE_HOST_VERIFIED=1
break
fi
done
[[ "${SOURCE_HOST_VERIFIED}" == "1" ]] || fail "source_host_is_not_110"
[[ -d "${SOURCE_ROOT}" ]] || fail "source_root_missing"
SOURCE_ROOT="$(CDPATH= cd -- "${SOURCE_ROOT}" && pwd)"
ORIGIN_URL="$(git -C "${SOURCE_ROOT}" remote get-url origin 2>/dev/null)" || fail "origin_remote_missing"
case "${ORIGIN_URL}" in
"${GITEA_ORIGIN_HTTPS}"|"${GITEA_ORIGIN_HTTP_INTERNAL}"|"${GITEA_ORIGIN_SSH_INTERNAL}") ;;
*) fail "origin_is_not_fixed_internal_gitea" ;;
esac
git -C "${SOURCE_ROOT}" fetch --quiet --no-tags origin \
'+refs/heads/main:refs/remotes/origin/main' || fail "fresh_origin_main_fetch_failed"
ORIGIN_MAIN_REVISION="$(git -C "${SOURCE_ROOT}" rev-parse --verify 'refs/remotes/origin/main^{commit}' 2>/dev/null)" \
|| fail "fresh_origin_main_revision_unavailable"
ORIGIN_MAIN_REVISION="$(printf '%s' "${ORIGIN_MAIN_REVISION}" | tr '[:upper:]' '[:lower:]')"
[[ -f "${KNOWN_HOSTS_FILE}" ]] || fail "known_hosts_file_missing"
if [[ -n "${IDENTITY_FILE}" ]]; then
[[ -f "${IDENTITY_FILE}" ]] || fail "identity_file_missing"
fi
if [[ -z "${SOURCE_REVISION}" ]]; then
SOURCE_REVISION="${ORIGIN_MAIN_REVISION}"
fi
SOURCE_REVISION="$(printf '%s' "${SOURCE_REVISION}" | tr '[:upper:]' '[:lower:]')"
[[ "${SOURCE_REVISION}" =~ ^[0-9a-f]{40}$ ]] || fail "source_revision_must_be_full_commit_sha"
git -C "${SOURCE_ROOT}" cat-file -e "${SOURCE_REVISION}^{commit}" 2>/dev/null || fail "source_revision_not_found"
[[ "${SOURCE_REVISION}" == "${ORIGIN_MAIN_REVISION}" ]] || fail "source_revision_is_not_fresh_origin_main"
SOURCE_BOUND_FILES=(
"${RUNTIME_FILES[@]}"
"${PREFLIGHT_RELATIVE}"
"${RECEIVER_RELATIVE}"
"${WRAPPER_RELATIVE}"
)
for relative_path in "${SOURCE_BOUND_FILES[@]}"; do
[[ -f "${SOURCE_ROOT}/${relative_path}" ]] || fail "source_bound_file_missing"
git -C "${SOURCE_ROOT}" ls-files --error-unmatch -- "${relative_path}" >/dev/null 2>&1 || fail "source_bound_file_untracked"
done
git -C "${SOURCE_ROOT}" diff --quiet "${SOURCE_REVISION}" -- "${SOURCE_BOUND_FILES[@]}" || fail "source_bundle_differs_from_revision"
umask 077
WORK_DIR="$(mktemp -d /tmp/awoooi-agent99-transport.XXXXXXXX)"
cleanup() {
rm -rf -- "${WORK_DIR}"
}
trap cleanup EXIT HUP INT TERM
ENVELOPE_PATH="${WORK_DIR}/envelope.json"
BOOTSTRAP_PATH="${WORK_DIR}/remote-bootstrap.ps1"
python3 - \
"${SOURCE_ROOT}" \
"${SOURCE_REVISION}" \
"${MODE}" \
"${TRACE_ID}" \
"${RUN_ID}" \
"${WORK_ITEM_ID}" \
"${ENVELOPE_PATH}" \
"${RUNTIME_FILES[@]}" <<'PY'
from __future__ import annotations
import base64
import hashlib
import json
import sys
from pathlib import Path
source_root = Path(sys.argv[1])
source_revision, mode = sys.argv[2:4]
trace_id, run_id, work_item_id = sys.argv[4:7]
output_path = Path(sys.argv[7])
runtime_names = sys.argv[8:]
if len(runtime_names) != 14 or len(set(runtime_names)) != 14:
raise SystemExit("fixed_runtime_file_contract_failed")
files: list[dict[str, str]] = []
manifest_rows: list[str] = []
for name in runtime_names:
if Path(name).name != name:
raise SystemExit("runtime_filename_not_flat")
content = (source_root / name).read_bytes()
digest = hashlib.sha256(content).hexdigest()
files.append(
{
"name": name,
"sha256": digest,
"contentBase64": base64.b64encode(content).decode("ascii"),
}
)
manifest_rows.append(f"{name}\t{digest}\n")
manifest_sha256 = hashlib.sha256("".join(manifest_rows).encode("utf-8")).hexdigest()
preflight_path = source_root / "scripts/reboot-recovery/agent99-live-preflight.ps1"
preflight_content = preflight_path.read_bytes()
envelope = {
"schemaVersion": "agent99_remote_atomic_deploy_envelope_v1",
"sourceHostAlias": "110",
"targetHostAlias": "99",
"transport": "110_to_99_ssh_publickey",
"mode": mode,
"traceId": trace_id,
"runId": run_id,
"workItemId": work_item_id,
"sourceRevision": source_revision,
"expectedRuntimeFileCount": 14,
"manifestSha256": manifest_sha256,
"files": files,
"livePreflight": {
"name": preflight_path.name,
"sha256": hashlib.sha256(preflight_content).hexdigest(),
"contentBase64": base64.b64encode(preflight_content).decode("ascii"),
},
}
output_path.write_text(
json.dumps(envelope, separators=(",", ":"), ensure_ascii=True),
encoding="utf-8",
)
PY
python3 - \
"${SOURCE_ROOT}/${RECEIVER_RELATIVE}" \
"${ENVELOPE_PATH}" \
"${BOOTSTRAP_PATH}" <<'PY'
from __future__ import annotations
import base64
import sys
from pathlib import Path
receiver_path, envelope_path, output_path = map(Path, sys.argv[1:4])
receiver_base64 = base64.b64encode(receiver_path.read_bytes()).decode("ascii")
envelope_base64 = base64.b64encode(envelope_path.read_bytes()).decode("ascii")
bootstrap = (
"$ErrorActionPreference = 'Stop'\r\n"
f"$receiverText = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{receiver_base64}'))\r\n"
f"$envelopeText = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{envelope_base64}'))\r\n"
"& ([ScriptBlock]::Create($receiverText)) -EnvelopeJson $envelopeText\r\n"
)
output_path.write_text(bootstrap, encoding="utf-8")
PY
SSH_OPTIONS=(
-T
-o BatchMode=yes
-o PreferredAuthentications=publickey
-o PubkeyAuthentication=yes
-o PasswordAuthentication=no
-o KbdInteractiveAuthentication=no
-o NumberOfPasswordPrompts=0
-o StrictHostKeyChecking=yes
-o "UserKnownHostsFile=${KNOWN_HOSTS_FILE}"
-o "ConnectTimeout=${CONNECT_TIMEOUT_SECONDS}"
-o ConnectionAttempts=1
-o ServerAliveInterval=5
-o ServerAliveCountMax=2
-o LogLevel=ERROR
-o IdentitiesOnly=yes
)
if [[ -n "${IDENTITY_FILE}" ]]; then
SSH_OPTIONS+=( -i "${IDENTITY_FILE}" )
fi
readonly REMOTE_COMMAND='powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "$inputText=[Console]::In.ReadToEnd(); & ([ScriptBlock]::Create($inputText))"'
set +e
timeout --signal=TERM --kill-after=10 "${REMOTE_EXECUTION_TIMEOUT_SECONDS}s" \
ssh "${SSH_OPTIONS[@]}" "${TARGET}" "${REMOTE_COMMAND}" <"${BOOTSTRAP_PATH}"
status=$?
set -e
exit "${status}"