Files
memind/scripts/release-mindspace-service-prod.sh
john 4e6db07123
Memind CI / Test, build, and release guards (pull_request) Successful in 2m26s
fix: verify MindSpace service replacement
2026-07-21 18:55:23 +08:00

432 lines
13 KiB
Bash
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
HOST="${STUDIO_HOST:-58.38.22.103}"
REMOTE_USER_ROOT="${STUDIO_USER_ROOT:-/Users/john}"
SHARED_MEMIND_ROOT="${STUDIO_SHARED_MEMIND_ROOT:-/Users/john/Project/Memind}"
APP_DIR="${REMOTE_USER_ROOT}/MindSpace"
INCOMING_DIR="/Users/john/Project/incoming/mindspace-service-runtime"
BACKUP_DIR="/Users/john/Project/backups/mindspace-service"
ARCHIVE_DIR="/Users/john/Project/archives/mindspace-service"
HEALTH_URL="${MINDSPACE_SERVICE_HEALTH_URL:-http://127.0.0.1:8082/health}"
CONTRACT_URL="${MINDSPACE_SERVICE_CONTRACT_URL:-http://127.0.0.1:8082/mindspace/v1/contract}"
PORTAL_HEALTH_URL="${STUDIO_HEALTH_URL:-http://127.0.0.1:8081/api/status}"
SERVICE_LABEL="cn.tkmind.mindspace-service"
PORTAL_LABEL="cn.tkmind.memind-portal"
LAUNCHD_GUI="gui/$(id -u)"
RELEASE_TS="$(date +%Y%m%d-%H%M%S)"
SHORT_SHA="$(git -C "${ROOT}" rev-parse --short HEAD 2>/dev/null || echo no-git)"
RELEASE_ID="${RELEASE_TS}-${SHORT_SHA}"
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mindspace-service-release.XXXXXX")"
RUNTIME_ROOT="${ROOT}/.runtime/mindspace-service"
BUNDLE_PATH="${TMP_DIR}/mindspace-service-${RELEASE_ID}.tar.gz"
MANIFEST_PATH="${TMP_DIR}/mindspace-service-${RELEASE_ID}.manifest.txt"
SHA_PATH="${TMP_DIR}/mindspace-service-${RELEASE_ID}.sha256"
DRY_RUN=0
SKIP_TESTS=0
SKIP_BUILD=0
AUTO_YES=0
cleanup() {
rm -rf "${TMP_DIR}"
}
trap cleanup EXIT
usage() {
cat <<'EOF'
用法:
bash scripts/release-mindspace-service-prod.sh [--dry-run] [--skip-tests] [--skip-build] [--yes]
说明:
1. 本地构建 MindSpace service runtime artifact
2. 上传到 103
3. 103 备份当前 /Users/john/MindSpace(如存在)
4. 103 备份 /Users/john/Project/Memind/.env
5. 启动 /Users/john/MindSpace 独立服务(127.0.0.1:8082
6. 将 Memind 切到 remote adapter 并重启 Portal
7. 失败时恢复旧 MindSpace 服务目录与 Memind .env
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=1 ;;
--skip-tests) SKIP_TESTS=1 ;;
--skip-build) SKIP_BUILD=1 ;;
--yes|-y) AUTO_YES=1 ;;
-h|--help)
usage
exit 0
;;
*)
echo "未知参数: $1" >&2
usage >&2
exit 1
;;
esac
shift
done
say() {
printf '\n[%s] %s\n' "$(date +%H:%M:%S)" "$*"
}
need_cmd() {
command -v "$1" >/dev/null 2>&1 || {
echo "缺少命令: $1" >&2
exit 1
}
}
need_cmd ssh
need_cmd scp
need_cmd tar
need_cmd shasum
if [[ -x "${ROOT}/scripts/check-release-ready.sh" ]]; then
bash "${ROOT}/scripts/check-release-ready.sh"
else
echo "缺少发布前闸门: ${ROOT}/scripts/check-release-ready.sh" >&2
exit 1
fi
say "本地预检查"
ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" "echo mindspace-service-ssh-ok" >/dev/null
ssh -o BatchMode=yes "${HOST}" "test -d '${SHARED_MEMIND_ROOT}' && test -f '${SHARED_MEMIND_ROOT}/.env'" >/dev/null
if [[ "${SKIP_TESTS}" -ne 1 ]]; then
say "运行最小验证"
(
cd "${ROOT}"
node --test mindspace-service/mindspace-rpc-server.test.mjs >/dev/null
)
fi
if [[ "${SKIP_BUILD}" -ne 1 ]]; then
say "构建 MindSpace service runtime"
(
cd "${ROOT}"
node scripts/build-mindspace-service-runtime.mjs
)
fi
[[ -d "${RUNTIME_ROOT}" ]] || {
echo "缺少 runtime 目录: ${RUNTIME_ROOT}" >&2
exit 1
}
verify_runtime_artifact() {
local missing=0
for required in README.md package.json server.mjs mindspace-rpc-server.mjs mindspace-service-bootstrap.mjs scripts/run-mindspace-prod.sh memind-source RUNBOOK.txt; do
if [[ ! -e "${RUNTIME_ROOT}/${required}" ]]; then
echo "runtime 产物缺失: ${RUNTIME_ROOT}/${required}" >&2
missing=1
fi
done
if [[ "${missing}" -ne 0 ]]; then
exit 1
fi
}
verify_runtime_artifact
say "生成发布清单"
cat > "${MANIFEST_PATH}" <<EOF
release_id=${RELEASE_ID}
created_at=$(date '+%Y-%m-%d %H:%M:%S %z')
host=$(hostname)
git_head=$(git -C "${ROOT}" rev-parse HEAD)
git_branch=$(git -C "${ROOT}" branch --show-current)
artifact=.runtime/mindspace-service
service_root=${APP_DIR}
shared_memind_root=${SHARED_MEMIND_ROOT}
service_port=8082
EOF
say "打包 MindSpace service runtime artifact"
COPYFILE_DISABLE=1 tar -czf "${BUNDLE_PATH}" -C "${RUNTIME_ROOT}" .
shasum -a 256 "${BUNDLE_PATH}" > "${SHA_PATH}"
if [[ "${DRY_RUN}" -eq 1 ]]; then
say "dry-run 完成"
ls -lh "${BUNDLE_PATH}" "${MANIFEST_PATH}" "${SHA_PATH}"
exit 0
fi
if [[ "${AUTO_YES}" -ne 1 ]]; then
echo ""
echo "即将部署 MindSpace service 到 ${HOST}:${APP_DIR}"
echo "并把 Memind 切换到 remote adapter -> http://127.0.0.1:8082"
read -r -p "确认继续?[y/N] " reply
if [[ ! "${reply}" =~ ^[Yy]$ ]]; then
echo "已取消"
exit 1
fi
fi
say "上传 MindSpace service runtime 到 103"
ssh "${HOST}" "mkdir -p '${INCOMING_DIR}' '${BACKUP_DIR}' '${ARCHIVE_DIR}'"
scp -q "${BUNDLE_PATH}" "${MANIFEST_PATH}" "${HOST}:${INCOMING_DIR}/"
say "在 103 切换 MindSpace service 并更新 Memind remote adapter"
ssh -o BatchMode=yes "${HOST}" 'bash -s' -- "${RELEASE_ID}" <<'REMOTE'
set -euo pipefail
RELEASE_ID="$1"
USER_ROOT="/Users/john"
APP_DIR="${USER_ROOT}/MindSpace"
NEW_DIR="${USER_ROOT}/MindSpace.release-${RELEASE_ID}"
INCOMING_DIR="/Users/john/Project/incoming/mindspace-service-runtime"
BACKUP_DIR="/Users/john/Project/backups/mindspace-service"
ARCHIVE_DIR="/Users/john/Project/archives/mindspace-service"
SHARED_MEMIND_ROOT="/Users/john/Project/Memind"
BUNDLE="${INCOMING_DIR}/mindspace-service-${RELEASE_ID}.tar.gz"
MANIFEST="${INCOMING_DIR}/mindspace-service-${RELEASE_ID}.manifest.txt"
ENV_FILE="${SHARED_MEMIND_ROOT}/.env"
ENV_BACKUP="${BACKUP_DIR}/memind-env-${RELEASE_ID}.before"
APP_BACKUP="${BACKUP_DIR}/mindspace-service-${RELEASE_ID}.before.tar.gz"
OLD_APP_ARCHIVE="${ARCHIVE_DIR}/MindSpace-before-${RELEASE_ID}"
SERVICE_LABEL="cn.tkmind.mindspace-service"
PORTAL_LABEL="cn.tkmind.memind-portal"
LAUNCHD_GUI="gui/$(id -u)"
SERVICE_PLIST="${HOME}/Library/LaunchAgents/${SERVICE_LABEL}.plist"
SERVICE_PLIST_BAK="${BACKUP_DIR}/${SERVICE_LABEL}.${RELEASE_ID}.plist.bak"
SERVICE_STARTED=0
PORTAL_TOUCHED=0
APP_MOVED=0
HAD_OLD_APP=0
PERSISTED_DATA_MOVED=0
PERSISTED_USERS_MOVED=0
say() {
printf '\n[remote %s] %s\n' "$(date +%H:%M:%S)" "$*"
}
service_is_running() {
launchctl print "${LAUNCHD_GUI}/${SERVICE_LABEL}" 2>/dev/null | grep -q 'state = running'
}
rollback() {
set +e
say "rollback: restoring previous state"
if [[ "${PORTAL_TOUCHED}" == "1" && -f "${ENV_BACKUP}" ]]; then
cp "${ENV_BACKUP}" "${ENV_FILE}"
launchctl kickstart -k "${LAUNCHD_GUI}/${PORTAL_LABEL}" >/dev/null 2>&1 || true
fi
if [[ "${SERVICE_STARTED}" == "1" ]]; then
launchctl bootout "${LAUNCHD_GUI}/${SERVICE_LABEL}" >/dev/null 2>&1 || true
fi
if [[ -f "${SERVICE_PLIST_BAK}" ]]; then
cp "${SERVICE_PLIST_BAK}" "${SERVICE_PLIST}"
else
rm -f "${SERVICE_PLIST}"
fi
if [[ "${APP_MOVED}" == "1" ]]; then
if [[ "${HAD_OLD_APP}" == "1" ]]; then
if [[ "${PERSISTED_DATA_MOVED}" == "1" && ( -e "${APP_DIR}/data" || -L "${APP_DIR}/data" ) ]]; then
rm -rf "${OLD_APP_ARCHIVE}/data"
mv "${APP_DIR}/data" "${OLD_APP_ARCHIVE}/data"
fi
if [[ "${PERSISTED_USERS_MOVED}" == "1" && ( -e "${APP_DIR}/users" || -L "${APP_DIR}/users" ) ]]; then
rm -rf "${OLD_APP_ARCHIVE}/users"
mv "${APP_DIR}/users" "${OLD_APP_ARCHIVE}/users"
fi
fi
rm -rf "${APP_DIR}"
if [[ "${HAD_OLD_APP}" == "1" && -d "${OLD_APP_ARCHIVE}" ]]; then
mv "${OLD_APP_ARCHIVE}" "${APP_DIR}"
fi
fi
if [[ "${HAD_OLD_APP}" == "1" && -f "${SERVICE_PLIST}" ]]; then
launchctl bootstrap "${LAUNCHD_GUI}" "${SERVICE_PLIST}" >/dev/null 2>&1 || true
launchctl kickstart -k "${LAUNCHD_GUI}/${SERVICE_LABEL}" >/dev/null 2>&1 || true
fi
exit 1
}
trap rollback ERR
[[ -f "${BUNDLE}" && -f "${MANIFEST}" ]]
say "校验产物完整性"
cd "${INCOMING_DIR}"
tar -tzf "${BUNDLE}" >/dev/null
say "备份 Memind .env"
cp "${ENV_FILE}" "${ENV_BACKUP}"
if [[ -d "${APP_DIR}" ]]; then
HAD_OLD_APP=1
say "备份旧 MindSpace service 目录"
COPYFILE_DISABLE=1 tar -czf "${APP_BACKUP}" -C "$(dirname "${APP_DIR}")" "$(basename "${APP_DIR}")"
fi
say "准备新的 runtime 目录"
rm -rf "${NEW_DIR}"
mkdir -p "${NEW_DIR}"
tar -xzf "${BUNDLE}" -C "${NEW_DIR}"
cp "${MANIFEST}" "${NEW_DIR}/.release-manifest.txt"
say "更新 MindSpace service LaunchAgent"
if [[ -f "${SERVICE_PLIST}" ]]; then
cp "${SERVICE_PLIST}" "${SERVICE_PLIST_BAK}"
fi
cat > "${SERVICE_PLIST}" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${SERVICE_LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>${APP_DIR}/scripts/run-mindspace-prod.sh</string>
</array>
<key>WorkingDirectory</key>
<string>${APP_DIR}</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/opt/homebrew/opt/node@24/bin:/usr/local/bin:/usr/bin:/bin</string>
<key>MEMIND_SHARED_ROOT</key>
<string>${SHARED_MEMIND_ROOT}</string>
<key>MINDSPACE_SERVICE_PORT</key>
<string>8082</string>
<key>MINDSPACE_SERVICE_HOST</key>
<string>127.0.0.1</string>
<key>MINDSPACE_STORAGE_ROOT</key>
<string>${APP_DIR}/data/mindspace</string>
<key>H5_USERS_ROOT</key>
<string>${APP_DIR}/users</string>
<key>MINDSPACE_SERVICE_AGENT_WORKER_ENABLED</key>
<string>true</string>
<key>MINDSPACE_SERVICE_WORKSPACE_MAINTENANCE</key>
<string>true</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>${HOME}/Library/Logs/mindspace-service.log</string>
<key>StandardErrorPath</key>
<string>${HOME}/Library/Logs/mindspace-service.log</string>
</dict>
</plist>
EOF
say "切换 MindSpace service live 目录"
launchctl bootout "${LAUNCHD_GUI}/${SERVICE_LABEL}" >/dev/null 2>&1 || true
for _ in $(seq 1 60); do
if ! lsof -nP -iTCP:8082 -sTCP:LISTEN >/dev/null 2>&1; then
break
fi
sleep 1
done
if lsof -nP -iTCP:8082 -sTCP:LISTEN >/dev/null 2>&1; then
echo "旧 MindSpace service 未在超时内退出,拒绝切换 runtime" >&2
exit 1
fi
rm -rf "${OLD_APP_ARCHIVE}"
if [[ -d "${APP_DIR}" ]]; then
mv "${APP_DIR}" "${OLD_APP_ARCHIVE}"
fi
mv "${NEW_DIR}" "${APP_DIR}"
APP_MOVED=1
say "继承 MindSpace 持久目录"
if [[ "${HAD_OLD_APP}" == "1" && ( -e "${OLD_APP_ARCHIVE}/data" || -L "${OLD_APP_ARCHIVE}/data" ) ]]; then
rm -rf "${APP_DIR}/data"
mv "${OLD_APP_ARCHIVE}/data" "${APP_DIR}/data"
PERSISTED_DATA_MOVED=1
fi
if [[ "${HAD_OLD_APP}" == "1" && ( -e "${OLD_APP_ARCHIVE}/users" || -L "${OLD_APP_ARCHIVE}/users" ) ]]; then
rm -rf "${APP_DIR}/users"
mv "${OLD_APP_ARCHIVE}/users" "${APP_DIR}/users"
PERSISTED_USERS_MOVED=1
fi
say "启动 MindSpace service"
mkdir -p "${APP_DIR}/data/mindspace" "${APP_DIR}/users"
launchctl bootstrap "${LAUNCHD_GUI}" "${SERVICE_PLIST}" >/dev/null
SERVICE_STARTED=1
launchctl enable "${LAUNCHD_GUI}/${SERVICE_LABEL}" >/dev/null
launchctl kickstart -k "${LAUNCHD_GUI}/${SERVICE_LABEL}" >/dev/null
for _ in $(seq 1 30); do
code="$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8082/health || true)"
if [[ "${code}" == "200" ]] && service_is_running; then
break
fi
sleep 1
done
[[ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8082/health || true)" == "200" ]]
service_is_running
[[ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8082/mindspace/v1/contract || true)" == "200" ]]
set_env() {
local key="$1"
local value="$2"
local file="$3"
local tmp
tmp="$(mktemp "${TMPDIR:-/tmp}/env.XXXXXX")"
awk -v k="${key}" -v v="${value}" '
BEGIN { done = 0 }
index($0, k "=") == 1 {
if (!done) {
print k "=" v
done = 1
}
next
}
{ print }
END {
if (!done) print k "=" v
}
' "${file}" > "${tmp}"
mv "${tmp}" "${file}"
}
TOKEN="$(grep -E '^TKMIND_SERVER__SECRET_KEY=' "${ENV_FILE}" | tail -1 | cut -d= -f2- || true)"
if [[ -z "${TOKEN}" ]]; then
echo "Memind .env 缺少 TKMIND_SERVER__SECRET_KEY,无法写入 remote adapter token" >&2
exit 1
fi
say "切换 Memind 到 remote adapter"
set_env MINDSPACE_SERVER_ADAPTER remote "${ENV_FILE}"
set_env MINDSPACE_REMOTE_BASE_URL http://127.0.0.1:8082 "${ENV_FILE}"
set_env MINDSPACE_REMOTE_AUTH_TOKEN "${TOKEN}" "${ENV_FILE}"
set_env MEMIND_WORKSPACE_MAINTENANCE 0 "${ENV_FILE}"
PORTAL_TOUCHED=1
launchctl kickstart -k "${LAUNCHD_GUI}/${PORTAL_LABEL}" >/dev/null 2>&1 || true
for _ in $(seq 1 30); do
code="$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8081/api/status || true)"
if [[ "${code}" == "200" ]]; then
break
fi
sleep 1
done
[[ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8081/api/status || true)" == "200" ]]
sleep 3
[[ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8082/health || true)" == "200" ]]
service_is_running
trap - ERR
say "切换完成"
printf 'release_id=%s\n' "${RELEASE_ID}"
printf 'service_root=%s\n' "${APP_DIR}"
printf 'service_backup=%s\n' "${APP_BACKUP}"
printf 'env_backup=%s\n' "${ENV_BACKUP}"
printf 'service_health=%s\n' "$(curl -fsS http://127.0.0.1:8082/health)"
REMOTE
say "发布完成"
printf 'release_id=%s\n' "${RELEASE_ID}"
printf 'bundle=%s\n' "${BUNDLE_PATH}"
printf 'manifest=%s\n' "${MANIFEST_PATH}"
printf 'sha=%s\n' "${SHA_PATH}"