From afc8cbc03cc942b72c1331428179507d8a9b1ca2 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 19 Oct 2026 13:55:57 +0800 Subject: [PATCH] fix: verify configured Goose targets during Portal release --- .../2026-10-18-news-morning-missing-images.md | 8 +++ release-portal-targets.test.mjs | 57 +++++++++++++++++++ scripts/release-portal-runtime-prod.sh | 31 +++++++++- 3 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 release-portal-targets.test.mjs diff --git a/docs/incidents/2026-10-18-news-morning-missing-images.md b/docs/incidents/2026-10-18-news-morning-missing-images.md index 415d7ea..c90aef4 100644 --- a/docs/incidents/2026-10-18-news-morning-missing-images.md +++ b/docs/incidents/2026-10-18-news-morning-missing-images.md @@ -63,3 +63,11 @@ 发布授权已经收到;剩余阻断是工作区整理与 Git 写权限,并非再次索要发布批准。解除后须按现有规则继续提交、同步主线、CI、整包与生产 Gate,而不是单文件上传线上源码。当前不能保证次日自动生成已经使用修复后的提取器。 后续用户明确允许 Git 写入,正常 `git add` 已成功。图片代码与测试将单独提交;其余未提交内容需按 portal-release 流程先取得临时 stash 的单独同意,发布后恢复。发布前基础数据库、能力、provider 与微信测试已通过。 + +## 2026-10-19 发布回滚与配置修复 + +图片修复已通过 CI 并进入 main `07e9244`。快速发布 `20261019-134634-07e9244` 切换后自动回滚,当前 live manifest 仍为 `20261009-102835-05b440c`,失败 runtime 保留在 `archives/Memind-failed-after-20261019-134634-07e9244`。 + +发现发布脚本 native remount 和健康检查仍写死 18006–18014 九个实例,而生产 `TKMIND_API_TARGETS` 仅配置 18006、18007,两者 HTTP 200,18008 已停用。脚本改为从 live `.env` 读取配置,按实际目标重启和检查;空配置、非法地址和端口仍阻断,不跳过不可用的已配置目标。Docker 路径的健康探测同样使用实际目标。未通过增加已停用实例或跳过健康检查解决。 + +新增六项离线回归,覆盖双实例、引号与 localhost、空配置、非法地址、非法端口和已配置实例不健康的阻断。修复后需重新通过 CI 和完整 main runtime 发布,再核对 live manifest。 diff --git a/release-portal-targets.test.mjs b/release-portal-targets.test.mjs new file mode 100644 index 0000000..71d612d --- /dev/null +++ b/release-portal-targets.test.mjs @@ -0,0 +1,57 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const release = readFileSync(new URL('./scripts/release-portal-runtime-prod.sh', import.meta.url), 'utf8'); +const start = release.indexOf('remount_goosed_after_live_swap() {'); +const end = release.indexOf('\n mkdir -p "${APP_DIR}"', release.indexOf('\n return 0\n fi', start)); +const nativeFunction = release.slice(start, end) + .replace('if [[ ! -x "${docker_bin}" || ! -f "${compose_file}" ]]; then', 'if true; then') + '\n}'; +function run(targets, unavailable = '') { + const root = mkdtempSync(join(tmpdir(), 'portal-targets-')); + try { + writeFileSync(join(root, '.env'), `TKMIND_API_TARGETS=${targets}\n`); + writeFileSync(join(root, 'mindspace-sandbox-mcp.mjs'), ''); + const result = spawnSync('/bin/bash', ['-c', ` +set -euo pipefail +APP_DIR="$1"; RELEASE_ID=test; unavailable="$2" +say() { :; } +launchctl() { printf 'restart=%s\\n' "$*" >> "$APP_DIR/restarts"; } +sleep() { :; } +curl() { case "$*" in *"$unavailable"*) return 1;; esac; printf ok; } +# An empty unavailable marker must not match every request. +if [[ -z "$2" ]]; then curl() { printf ok; }; fi +${nativeFunction} +remount_goosed_after_live_swap +`, 'test', root, unavailable], { encoding: 'utf8' }); + try { result.stdout += readFileSync(join(root, 'restarts'), 'utf8'); } catch {} + return result; + } finally { rmSync(root, { recursive: true, force: true }); } +} + +test('native remount restarts only the two configured targets', () => { + const result = run('https://127.0.0.1:18006,https://127.0.0.1:18007'); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /native-18006/); + assert.match(result.stdout, /native-18007/); + assert.doesNotMatch(result.stdout, /native-18008/); +}); +test('quoted targets and localhost are supported', () => { + assert.equal(run('"https://127.0.0.1:18006,https://localhost:18007"').status, 0); +}); +for (const targets of ['', 'https://example.com:18006', 'https://127.0.0.1:99999']) { + test(`invalid configured targets fail closed: ${targets || '(empty)'}`, () => { + const result = run(targets); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /goosed remount failed/); + assert.doesNotMatch(result.stdout, /restart=/); + }); +} +test('an unavailable configured target still blocks remount', () => { + const result = run('https://127.0.0.1:18006,https://127.0.0.1:18007', '18007'); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /did not become healthy/); +}); diff --git a/scripts/release-portal-runtime-prod.sh b/scripts/release-portal-runtime-prod.sh index b268200..6d7c00e 100755 --- a/scripts/release-portal-runtime-prod.sh +++ b/scripts/release-portal-runtime-prod.sh @@ -497,6 +497,31 @@ remount_goosed_after_live_swap() { local marker_host="${APP_DIR}/${marker_rel}" local marker_value="${RELEASE_ID}-$(date +%s)" local ready=0 + local targets_line target port + local target_urls=() native_ports=() + targets_line="$(sed -n 's/^TKMIND_API_TARGETS=//p' "${APP_DIR}/.env" | tail -1 | tr -d "\"'\r")" + if [[ -z "${targets_line}" ]]; then + echo "goosed remount failed: TKMIND_API_TARGETS is empty" >&2 + return 1 + fi + IFS=',' read -r -a target_urls <<< "${targets_line}" + for target in "${target_urls[@]}"; do + target="$(printf '%s' "${target}" | tr -d '[:space:]')" + if [[ ! "${target}" =~ ^https://(127\.0\.0\.1|localhost):([0-9]+)/*$ ]]; then + echo "goosed remount failed: invalid or unsupported configured target: ${target}" >&2 + return 1 + fi + port="${BASH_REMATCH[2]}" + if (( 10#${port} < 1 || 10#${port} > 65535 )); then + echo "goosed remount failed: configured target port out of range" >&2 + return 1 + fi + native_ports+=("${port}") + done + if [[ "${#native_ports[@]}" -eq 0 ]]; then + echo "goosed remount failed: TKMIND_API_TARGETS is empty" >&2 + return 1 + fi if [[ ! -x "${docker_bin}" || ! -f "${compose_file}" ]]; then say "重启 native goosed 以刷新 Portal/MindSpace bind mount" @@ -505,7 +530,7 @@ remount_goosed_after_live_swap() { local gui="gui/$(id -u)" local port - for port in $(seq 18006 18014); do + for port in "${native_ports[@]}"; do launchctl kickstart -k "${gui}/cn.tkmind.goosed-native-${port}" >/dev/null 2>&1 || true done @@ -517,7 +542,7 @@ remount_goosed_after_live_swap() { if [[ ! -f "${APP_DIR}/mindspace-sandbox-mcp.mjs" ]]; then healthy=0 fi - for port in $(seq 18006 18014); do + for port in "${native_ports[@]}"; do if [[ "$(curl -skS -m 5 "https://127.0.0.1:${port}/status" 2>/dev/null || true)" != "ok" ]]; then healthy=0 fi @@ -590,7 +615,7 @@ remount_goosed_after_live_swap() { done local port - for port in $(seq 18006 18014); do + for port in "${native_ports[@]}"; do if [[ "$(curl -skS -m 5 "https://127.0.0.1:${port}/status" 2>/dev/null || true)" != "ok" ]]; then healthy=0 fi