#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
TARGETS=(
"km:120.48.85.61:km:~/.local/bin/xwcode"
"loopback:::loopback"
)
CUR_NAME="" CUR_HOST="" CUR_USER="" CUR_DEPLOY=""
log() { echo "[install-media] $*" >&2; }
usage() {
cat <<'USAGE'
用法: bash scripts/install.sh <subcommand> <target> [version] [options]
子命令:
install <target> [version] [--allow-dirty] 首装/覆盖安装(原子部署 + 验证)
upgrade <target> [version] [--allow-dirty] 升级(备份→构建→部署→验证→失败自动回滚)
rollback <target> [version] 回滚到最近/指定备份点
verify <target> [version] [build_sha256] 仅验证(三项检查)
status [target] 查看部署版本 + 备份点(无 target=全部)
选项:
--allow-dirty 允许脏工作区构建(默认拒绝,防部署位版本与 tag 漂移)
示例:
bash scripts/install.sh upgrade km v0.98.6
bash scripts/install.sh install km
bash scripts/install.sh verify km v0.98.6
bash scripts/install.sh status
退出码: 0 成功 / 1 失败 / 2 升级验证失败且自动回滚也失败
USAGE
exit "${1:-1}"
}
resolve_target() {
local name="$1" entry found="" n h u p
for entry in "${TARGETS[@]}"; do
IFS=: read -r n h u p <<< "$entry"
if [ "$n" = "$name" ]; then
found=1; CUR_NAME="$n"; CUR_HOST="$h"; CUR_USER="$u"; CUR_DEPLOY="$p"; break
fi
done
if [ -z "$found" ]; then
log "error: 未知目标 '$name'"
return 1
fi
local up vh vu vp
up="$(echo "$name" | tr '[:lower:]' '[:upper:]')"
vh="TARGET_${up}_HOST"; vu="TARGET_${up}_USER"; vp="TARGET_${up}_PATH"
CUR_HOST="${!vh:-$CUR_HOST}"
CUR_USER="${!vu:-$CUR_USER}"
CUR_DEPLOY="${!vp:-$CUR_DEPLOY}"
return 0
}
is_local() { [ -z "$CUR_HOST" ]; }
rsh() {
if is_local; then bash -c "$1"; else ssh -o ConnectTimeout=5 "${CUR_USER}@${CUR_HOST}" "$1"; fi
}
rcp() {
if is_local; then cp "$1" "$2"; else scp "$1" "${CUR_USER}@${CUR_HOST}:$2"; fi
}
resolve_version() {
local req="${1:-}"
if [ -z "$req" ]; then
VTAG="$(git -C "$PROJECT_ROOT" describe --tags --abbrev=0 2>/dev/null || echo HEAD)"
else
VTAG="$req"
fi
VCOMMIT="$(git -C "$PROJECT_ROOT" rev-parse -q --verify "${VTAG}^{commit}" 2>/dev/null)"
[ "${#VCOMMIT}" -eq 40 ] || VCOMMIT=""
if [ -z "$VCOMMIT" ]; then
VCOMMIT="$(git -C "$PROJECT_ROOT" rev-parse -q --verify HEAD 2>/dev/null)" \
|| { log "error: 无法解析任何 commit"; return 1; }
log "version: 标签 '$VTAG' 非 git ref, 构建当前 HEAD"
fi
VCOMMIT7="${VCOMMIT:0:7}"
log "version: $VTAG -> $VCOMMIT ($VCOMMIT7)"
}
precheck_local() {
local version="$1" allow_dirty="$2" fail=0
for tool in git cargo ssh; do
command -v "$tool" >/dev/null 2>&1 || { log "precheck: 缺少本地工具 '$tool'"; fail=1; }
done
if [ -z "$VCOMMIT" ]; then log "precheck: 目标版本不可解析"; fail=1; fi
if [ "$allow_dirty" != "1" ]; then
if ! git -C "$PROJECT_ROOT" diff --quiet 2>/dev/null || ! git -C "$PROJECT_ROOT" diff --cached --quiet 2>/dev/null; then
log "precheck: 工作区有未提交修改(脏树)。构建脏树会致部署位版本与 tag 漂移(L4 教训)。"
log "precheck: 请提交/暂存,或加 --allow-dirty 显式确认"
fail=1
fi
fi
return $fail
}
precheck_remote() {
local fail=0
if is_local; then
local parent; parent="$(dirname "$CUR_DEPLOY")"
if [ ! -d "$parent" ]; then log "precheck(remote): 部署位父目录不存在: $parent"; fail=1; fi
if [ -d "$parent" ] && ! ( touch "$parent/.xw_install_probe" 2>/dev/null && rm -f "$parent/.xw_install_probe" ); then
log "precheck(remote): 部署位父目录不可写: $parent"; fail=1
fi
else
if ! rsh "true" 2>/dev/null; then log "precheck(remote): 目标不可达 ${CUR_USER}@${CUR_HOST}"; fail=1; return $fail; fi
local parent; parent="$(dirname "$CUR_DEPLOY")"
if ! rsh "test -d '$parent' && touch '$parent/.xw_install_probe' && rm -f '$parent/.xw_install_probe'" 2>/dev/null; then
log "precheck(remote): 部署位父目录缺失或不可写: $parent"; fail=1
fi
fi
return $fail
}
build_artifact() {
local commit="$1"
if [ -n "${INSTALL_PREBUILT:-}" ]; then
log "build: 使用预置产物 shim $INSTALL_PREBUILT(跳过 cargo build)"
echo "$INSTALL_PREBUILT"
return 0
fi
local wt; wt="$(mktemp -d "${TMPDIR:-/tmp}/xwcode-build.XXXXXX")"
log "build: worktree $wt @ $commit(隔离源码,共享 target 缓存)"
git -C "$PROJECT_ROOT" worktree add --detach "$wt" "$commit" >/dev/null
if ! ( cd "$wt" && CARGO_TARGET_DIR="$PROJECT_ROOT/target" CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-2}" cargo build --release ); then
git -C "$PROJECT_ROOT" worktree remove --force "$wt" >/dev/null 2>&1 || true
log "build: cargo build 失败"
return 1
fi
git -C "$PROJECT_ROOT" worktree remove --force "$wt" >/dev/null 2>&1 || true
echo "$PROJECT_ROOT/target/release/xwcode"
}
do_backup() {
local deploy="$1" tag="$2" commit7="$3" version="$4"
if ! rsh "test -f '$deploy'" 2>/dev/null; then
log "backup: 无既有 $deploy,跳过备份"
echo ""
return 0
fi
local ts bdir; ts="$(date +%s)"
bdir="${deploy}.backups/${ts}-${tag}-${commit7}"
rsh "mkdir -p '$bdir'"
rcp "$deploy" "$bdir/xwcode"
local meta; meta="$(mktemp -p "${TMPDIR:-/tmp}" 2>/dev/null || mktemp)"
printf '{"version":"%s","tag":"%s","commit":"%s","backed_up_at":"%s"}\n' \
"$version" "$tag" "$commit7" "$(date -u +%FT%TZ)" > "$meta"
rcp "$meta" "$bdir/meta.json"
rm -f "$meta"
log "backup: $bdir"
rotate_backups "$deploy"
echo "$bdir"
}
rotate_backups() {
local deploy="$1"
local d bname; d="$(dirname "$deploy")"; bname="$(basename "$deploy").backups"
rsh "cd '$d' 2>/dev/null && ls -1d '$bname'/*/ 2>/dev/null | sort | head -n -3 | xargs -r rm -rf" || true
}
do_restore() {
local bdir="$1" deploy="$2"
local staging="${deploy}.tmp.restore.$$"
rcp "$bdir/xwcode" "$staging"
rsh "mv '$staging' '$deploy' && chmod +x '$deploy'"
log "restore: $bdir -> $deploy"
}
remote_verify() {
local expected="$1" build_sha="$2" deploy="$CUR_DEPLOY" rc=0
local exp="${expected#v}"
local inst
inst="$(rsh "$deploy --version 2>/dev/null" || echo NOT_FOUND)"
if echo "$inst" | grep "$exp" >/dev/null; then
log "VERIFY: PASS (version: $inst)"
else
log "VERIFY: FAIL (version: 期望含 '$exp', 实际 '$inst')"
rc=1
fi
local rsha
rsha="$(rsh "sha256sum '$deploy' 2>/dev/null | awk '{print \$1}'" || echo MISSING)"
if [ "$rsha" = "$build_sha" ]; then
log "VERIFY: PASS (artifact: sha256=$rsha)"
else
log "VERIFY: FAIL (artifact: 部署位=$rsha 构建=$build_sha)"
rc=1
fi
if rsh "test -x '$deploy'" 2>/dev/null; then
log "VERIFY: PASS (exec: $deploy 可执行)"
else
log "VERIFY: FAIL (exec: $deploy 不可执行)"
rc=1
fi
return $rc
}
acquire_lock() {
local lockdir="$1" parent i=0
parent="$(dirname "$lockdir")"
rsh "mkdir -p '$parent'"
while ! rsh "mkdir '$lockdir' 2>/dev/null"; do
i=$((i+1))
if [ "$i" -ge 50 ]; then log "lock: 获取超时 $lockdir"; return 1; fi
rsh "sleep 0.2"
done
return 0
}
release_lock() {
rsh "rmdir '$1' 2>/dev/null || true"
}
deploy_stage() {
local staging="$1" expected="$2" build_sha="$3" tag="$4" commit7="$5" version="$6"
local deploy="$CUR_DEPLOY"
local bdir
bdir="$(do_backup "$deploy" "$tag" "$commit7" "$version")"
rsh "mkdir -p '$(dirname "$deploy")'"
rsh "mv '$staging' '$deploy' && chmod +x '$deploy'"
log "deploy: $staging -> $deploy"
if remote_verify "$expected" "$build_sha"; then
log "deploy: 验证通过"
return 0
fi
if [ -n "$bdir" ]; then
log "deploy: 验证失败,自动回滚到备份 $bdir"
do_restore "$bdir" "$deploy"
return 1
else
log "deploy: 验证失败且无备份可回滚(首装)"
return 1
fi
}
cmd_install() {
local target="$1" version="$2" allow_dirty="$3"
resolve_target "$target" || return 1
resolve_version "$version" || return 1
precheck_local "$version" "$allow_dirty" || { log "install: 本地预检失败"; return 1; }
precheck_remote || { log "install: 远程预检失败"; return 1; }
local artifact local_sha
artifact="$(build_artifact "$VCOMMIT")" || { log "install: 构建失败"; return 1; }
local_sha="$(sha256sum "$artifact" | awk '{print $1}')"
log "install: 产物 $artifact sha256=$local_sha"
local staging="${CUR_DEPLOY}.tmp.$$"
rcp "$artifact" "$staging"
local lockdir="${CUR_DEPLOY}.backups/.lock"
acquire_lock "$lockdir" || { rm -f "$staging" 2>/dev/null || rsh "rm -f '$staging'" 2>/dev/null || true; return 1; }
local rc=0
deploy_stage "$staging" "$version" "$local_sha" "$VTAG" "$VCOMMIT7" "$version" || rc=1
release_lock "$lockdir"
if [ $rc -eq 0 ]; then log "install: ✅ 完成 ($target)"; else log "install: ❌ 失败 ($target)"; fi
return $rc
}
cmd_upgrade() {
local target="$1" version="$2" allow_dirty="$3"
resolve_target "$target" || return 1
resolve_version "$version" || return 1
precheck_local "$version" "$allow_dirty" || { log "upgrade: 本地预检失败"; return 1; }
precheck_remote || { log "upgrade: 远程预检失败"; return 1; }
local artifact local_sha
artifact="$(build_artifact "$VCOMMIT")" || { log "upgrade: 构建失败"; return 1; }
local_sha="$(sha256sum "$artifact" | awk '{print $1}')"
log "upgrade: 产物 $artifact sha256=$local_sha"
local staging="${CUR_DEPLOY}.tmp.$$"
rcp "$artifact" "$staging"
local lockdir="${CUR_DEPLOY}.backups/.lock"
acquire_lock "$lockdir" || { rsh "rm -f '$staging'" 2>/dev/null || rm -f "$staging" 2>/dev/null || true; return 1; }
local rc=0
deploy_stage "$staging" "$version" "$local_sha" "$VTAG" "$VCOMMIT7" "$version" || rc=1
release_lock "$lockdir"
if [ $rc -eq 0 ]; then log "upgrade: ✅ 完成 ($target -> $version)"; else
log "upgrade: ❌ 失败 ($target),已尝试自动回滚"
return 2
fi
return 0
}
cmd_rollback() {
local target="$1" version="${2:-}"
resolve_target "$target" || return 1
local deploy="$CUR_DEPLOY"
local broot="${deploy}.backups"
local bdir
if [ -n "$version" ]; then
bdir="$(rsh "cd '$(dirname "$deploy")' && ls -1d '$(basename "$deploy").backups'/*-${version}-* 2>/dev/null | sort | tail -1" || true)"
else
bdir="$(rsh "cd '$(dirname "$deploy")' && ls -1d '$(basename "$deploy").backups'/*/ 2>/dev/null | sort | tail -1" || true)"
fi
if [ -z "$bdir" ]; then log "rollback: 无可用备份点 $broot"; return 1; fi
local bver; bver="$(basename "$bdir" | awk -F- '{print $(NF-1)}')"
local meta_raw meta_version
meta_raw="$(rsh "cat '$bdir/meta.json' 2>/dev/null" || true)"
meta_version="$(echo "$meta_raw" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p' | head -1)"
[ -n "$meta_version" ] && bver="$meta_version"
local lockdir="${CUR_DEPLOY}.backups/.lock"
acquire_lock "$lockdir" || return 1
local rc=0
do_backup "$deploy" "$bver" "rollback" "$(date +%s)" >/dev/null || true
do_restore "$bdir" "$deploy" || rc=1
release_lock "$lockdir"
# 回滚后 verify(回滚不级联:verify 失败仅报错留现场,不自动再回滚)
if [ $rc -eq 0 ]; then
# 无法取构建产物 sha(历史备份),跳过 artifact 检查做 version+exec 轻量验证
local inst; inst="$(rsh "$deploy --version 2>/dev/null" || echo NOT_FOUND)"
if echo "$inst" | grep "${bver#v}" >/dev/null; then
log "rollback: ✅ 完成 (恢复到 $bver, $inst)"
return 0
else
log "rollback: ⚠️ 已恢复 $bdir 但版本实证异常: $inst(现场保留)"
return 1
fi
fi
log "rollback: ❌ 恢复失败(现场保留,不级联回滚)"
return 1
}
cmd_verify() {
local target="$1" version="$2" build_sha="${3:-}"
resolve_target "$target" || return 1
local exp="${version#v}"
[ -z "$exp" ] && { log "verify: 需要指定 version"; return 1; }
if [ -z "$build_sha" ]; then
# 无构建产物 sha → 跳过检查 2(artifact 一致性需构建产物在场)
local rc=0
local inst; inst="$(rsh "$CUR_DEPLOY --version 2>/dev/null" || echo NOT_FOUND)"
if echo "$inst" | grep "$exp" >/dev/null; then log "VERIFY: PASS (version: $inst)"; else log "VERIFY: FAIL (version: 期望 $exp 实际 $inst)"; rc=1; fi
if rsh "test -x '$CUR_DEPLOY'" 2>/dev/null; then log "VERIFY: PASS (exec)"; else log "VERIFY: FAIL (exec)"; rc=1; fi
log "VERIFY: SKIP (artifact: 未提供 build_sha256)"
return $rc
fi
local rc=0
remote_verify "$version" "$build_sha" || rc=1
return $rc
}
cmd_status() {
local target="${1:-}"
local names=()
if [ -n "$target" ]; then names=("$target"); else names=(); for e in "${TARGETS[@]}"; do IFS=: read -r n _ _ _ <<< "$e"; names+=("$n"); done; fi
local n
for n in "${names[@]}"; do
resolve_target "$n" || continue
local inst bline
inst="$(rsh "$CUR_DEPLOY --version 2>/dev/null" || echo NOT_DEPLOYED)"
bline="$(rsh "cd '$(dirname "$CUR_DEPLOY")' && ls -1 '$(basename "$CUR_DEPLOY").backups' 2>/dev/null | grep -v '^\.lock$' | tr '\n' ' '" || true)"
echo "[$n] host=${CUR_HOST:-local} deploy=$CUR_DEPLOY"
echo " version: $inst"
[ -n "$bline" ] && echo " backups: $bline" || echo " backups: (无)"
done
return 0
}
# ── 主分发 ──────────────────────────────────────────────────
main() {
local sub="${1:-}"; shift || true
case "$sub" in
install)
_t=""; _v=""; _ad=0
for _a in "$@"; do
if [ "$_a" = "--allow-dirty" ]; then _ad=1
elif [ -z "$_t" ]; then _t="$_a"
elif [ -z "$_v" ]; then _v="$_a"
fi
done
[ -z "$_t" ] && usage; cmd_install "$_t" "$_v" "$_ad" ;;
upgrade)
_t=""; _v=""; _ad=0
for _a in "$@"; do
if [ "$_a" = "--allow-dirty" ]; then _ad=1
elif [ -z "$_t" ]; then _t="$_a"
elif [ -z "$_v" ]; then _v="$_a"
fi
done
[ -z "$_t" ] && usage; cmd_upgrade "$_t" "$_v" "$_ad" ;;
rollback) local t="${1:-}"; local v="${2:-}"; [ -z "$t" ] && usage; cmd_rollback "$t" "$v" ;;
verify) local t="${1:-}"; local v="${2:-}"; local bs="${3:-}"; [ -z "$t" ] && usage; cmd_verify "$t" "$v" "$bs" ;;
status) cmd_status "${1:-}" ;;
-h|--help|help) usage 0 ;;
*) usage 1 ;;
esac
}
main "$@"