Commit 1ac7274f by luoqi

Merge deployment safeguards and operations handoff into test

parents ad8fa3f8 8010ba3c
Pipeline #3743 failed in 0 seconds
...@@ -125,7 +125,7 @@ TLS 证书自备(Let's Encrypt / 云证书);域名要和 `.env` 的 `PAC_DOMAIN` ...@@ -125,7 +125,7 @@ TLS 证书自备(Let's Encrypt / 云证书);域名要和 `.env` 的 `PAC_DOMAIN`
### 托管 DB/Redis(阿里云 RDS + Redis,可选) ### 托管 DB/Redis(阿里云 RDS + Redis,可选)
默认自带 PG/Redis(compose 内置)。要走托管:①gen-env 时传托管连接串,②部署时加 `COMPOSE_MANAGED=1`。应用代码零改动。 基础 Compose 默认自带 PG/Redis。当前 main/production 必须显式设置 `COMPOSE_MANAGED=1`,test/staging 则保持未设置或 `0`;三个发布入口共用模式检查,错误时在构建/迁移/重启前退出。托管发布完成前还需通过应用机私网端点检查。要走托管:①gen-env 时传托管连接串,②部署时加 `COMPOSE_MANAGED=1`。应用代码零改动。
```bash ```bash
# ① 生成 env 时指向托管端点(不启自带 PG/Redis) # ① 生成 env 时指向托管端点(不启自带 PG/Redis)
......
...@@ -7,6 +7,8 @@ main() { ...@@ -7,6 +7,8 @@ main() {
[[ "$target" == staging || "$target" == production ]] || { echo '用法:bash deploy/deploy-ai.sh staging|production [--check|--no-pull]'; return 1; } [[ "$target" == staging || "$target" == production ]] || { echo '用法:bash deploy/deploy-ai.sh staging|production [--check|--no-pull]'; return 1; }
[[ -z "$option" || "$option" == --check || "$option" == --no-pull ]] || { echo '未知选项'; return 1; } [[ -z "$option" || "$option" == --check || "$option" == --no-pull ]] || { echo '未知选项'; return 1; }
cd "$(dirname "${BASH_SOURCE[0]}")/.." cd "$(dirname "${BASH_SOURCE[0]}")/.."
source deploy/deploy-mode.sh
check_deploy_mode "$target"
local branch=test local branch=test
[[ "$target" == production ]] && branch=main [[ "$target" == production ]] && branch=main
[[ "$(git branch --show-current)" == "$branch" ]] || { echo "部署要求当前分支为 $branch"; return 1; } [[ "$(git branch --show-current)" == "$branch" ]] || { echo "部署要求当前分支为 $branch"; return 1; }
...@@ -53,6 +55,12 @@ main() { ...@@ -53,6 +55,12 @@ main() {
[[ "$actual" == "$expected" ]] || { echo "$service 镜像校验失败"; return 1; } [[ "$actual" == "$expected" ]] || { echo "$service 镜像校验失败"; return 1; }
done done
"${compose[@]}" exec -T --interactive=false ai-service node -e "fetch(process.env.AI_PAC_BASE_URL+'/health',{signal:AbortSignal.timeout(5000)}).then(async r=>{const v=await r.json();if(!r.ok||v.status!=='ok')process.exit(1)}).catch(()=>process.exit(1))" "${compose[@]}" exec -T --interactive=false ai-service node -e "fetch(process.env.AI_PAC_BASE_URL+'/health',{signal:AbortSignal.timeout(5000)}).then(async r=>{const v=await r.json();if(!r.ok||v.status!=='ok')process.exit(1)}).catch(()=>process.exit(1))"
if [[ "${COMPOSE_MANAGED:-0}" == 1 ]]; then
local private_ip
private_ip=$(deploy_private_ip)
verify_deploy_endpoint "http://$private_ip:3104/health/ready" ready
verify_deploy_endpoint "http://$private_ip:3103/login" web
fi
printf '%s\n' "$AI_IMAGE_TAG" > "$state/current-image-tag" printf '%s\n' "$AI_IMAGE_TAG" > "$state/current-image-tag"
echo "FridayAI 部署完成:${AI_IMAGE_TAG};Web 3103 / API 3104;PAC 未重启" echo "FridayAI 部署完成:${AI_IMAGE_TAG};Web 3103 / API 3104;PAC 未重启"
} }
......
...@@ -6,6 +6,8 @@ option=${2:-} ...@@ -6,6 +6,8 @@ option=${2:-}
[[ -z "$option" || "$option" == --check ]] || { echo '未知选项'; exit 1; } [[ -z "$option" || "$option" == --check ]] || { echo '未知选项'; exit 1; }
[[ "$target" == staging || "$target" == production ]] || { echo '用法:bash deploy/deploy-ingress.sh staging|production [--check]'; exit 1; } [[ "$target" == staging || "$target" == production ]] || { echo '用法:bash deploy/deploy-ingress.sh staging|production [--check]'; exit 1; }
cd "$(dirname "${BASH_SOURCE[0]}")/.." cd "$(dirname "${BASH_SOURCE[0]}")/.."
source deploy/deploy-mode.sh
check_deploy_mode "$target"
branch=test branch=test
[[ "$target" == production ]] && branch=main [[ "$target" == production ]] && branch=main
[[ "$(git branch --show-current)" == "$branch" ]] || { echo "要求当前分支为 $branch"; exit 1; } [[ "$(git branch --show-current)" == "$branch" ]] || { echo "要求当前分支为 $branch"; exit 1; }
...@@ -31,6 +33,10 @@ id=$("${compose[@]}" ps -q channel-ingress) ...@@ -31,6 +33,10 @@ id=$("${compose[@]}" ps -q channel-ingress)
expected=$(docker image inspect "pac-channel-ingress:$INGRESS_IMAGE_TAG" --format '{{.Id}}') expected=$(docker image inspect "pac-channel-ingress:$INGRESS_IMAGE_TAG" --format '{{.Id}}')
actual=$(docker inspect "$id" --format '{{.Image}}') actual=$(docker inspect "$id" --format '{{.Image}}')
[[ "$expected" == "$actual" ]] || { echo '中转镜像校验失败'; exit 1; } [[ "$expected" == "$actual" ]] || { echo '中转镜像校验失败'; exit 1; }
if [[ "${COMPOSE_MANAGED:-0}" == 1 ]]; then
private_ip=$(deploy_private_ip)
verify_deploy_endpoint "http://$private_ip:3106/health" health
fi
mkdir -p "$(git rev-parse --git-common-dir)/ingress-deploy" mkdir -p "$(git rev-parse --git-common-dir)/ingress-deploy"
printf '%s\n' "$INGRESS_IMAGE_TAG" > "$(git rev-parse --git-common-dir)/ingress-deploy/current-image-tag" printf '%s\n' "$INGRESS_IMAGE_TAG" > "$(git rev-parse --git-common-dir)/ingress-deploy/current-image-tag"
echo "中转部署完成:${INGRESS_IMAGE_TAG};3106;未修改 Nginx、未停止旧调试中转" echo "中转部署完成:${INGRESS_IMAGE_TAG};3106;未修改 Nginx、未停止旧调试中转"
#!/usr/bin/env bash
# 三套发布入口共用;当前生产采用 RDS + 远端网关,测试采用本机代理。
# ⛔ 不自动开启托管模式:它会开放主机监听地址,必须由部署者明确选择。
check_deploy_mode() {
local target="$1" mode="${COMPOSE_MANAGED:-0}"
case "$mode" in
0|1) ;;
*) echo 'FAIL: COMPOSE_MANAGED 只能为 0 或 1' >&2; return 1 ;;
esac
case "$target" in
main|production)
if [[ "$mode" != 1 ]]; then
echo 'FAIL: 当前生产使用远端网关,必须显式设置 COMPOSE_MANAGED=1;未执行构建、迁移或重启。' >&2
return 1
fi ;;
test|staging)
if [[ "$mode" != 0 ]]; then
echo 'FAIL: 当前测试使用本机代理,请取消 COMPOSE_MANAGED 或设为 0,避免开放应用端口。' >&2
return 1
fi ;;
esac
}
# 在应用已通过容器健康检查后,另验主机私网路径。不能代替网关/安全组验收。
deploy_private_ip() {
local addr
addr=$(ip -4 route get 1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1);exit}}') || true
if [[ -z "$addr" || "$addr" == 127.* || "$addr" == 0.0.0.0 ]]; then
echo 'FAIL: 无法确定应用机私网地址,不能验证网关上游' >&2
return 1
fi
printf '%s' "$addr"
}
# $1 URL,$2 web|health|ready;只接受 HTTP 200,健康接口必须核验 JSON 状态。
verify_deploy_endpoint() {
local url="$1" kind="$2" out code body
out=$(curl --noproxy '*' -sS --connect-timeout 3 --max-time 10 -w '\n%{http_code}' "$url") || {
echo "FAIL: 私网端点不可达:${url};请检查 managed 端口绑定" >&2
return 1
}
code=${out##*$'\n'}
body=${out%$'\n'*}
if [[ "$code" != 200 ]]; then
echo "FAIL: 私网端点 $url 返回 HTTP $code" >&2
return 1
fi
if [[ "$kind" != web ]] && ! grep -Eq '"status"[[:space:]]*:[[:space:]]*"ok"' <<<"$body"; then
echo "FAIL: 私网健康检查不通过:$url" >&2
return 1
fi
if [[ "$kind" == ready ]] && ! grep -Eq '"db"[[:space:]]*:[[:space:]]*"up"' <<<"$body"; then
echo "FAIL: 私网数据库就绪检查不通过:$url" >&2
return 1
fi
echo "私网端点通过:$url"
}
...@@ -15,9 +15,10 @@ ...@@ -15,9 +15,10 @@
# 故生产机停在 main、测试机停在 test 即可;临时切别的分支用 DEPLOY_BRANCH 覆盖。 # 故生产机停在 main、测试机停在 test 即可;临时切别的分支用 DEPLOY_BRANCH 覆盖。
# #
# 用法(服务器上,~/pac): # 用法(服务器上,~/pac):
# bash deploy/deploy-prod.sh # 部署当前分支(生产机=main / 测试机=test) # COMPOSE_MANAGED=1 bash deploy/deploy-prod.sh # 生产(main,远端网关)
# bash deploy/deploy-prod.sh # 测试(test,本机代理)
# DEPLOY_BRANCH=test bash deploy/deploy-prod.sh # 显式指定分支(首次把某机切到该分支) # DEPLOY_BRANCH=test bash deploy/deploy-prod.sh # 显式指定分支(首次把某机切到该分支)
# bash deploy/deploy-prod.sh --no-pull # 跳过 git 拉取(代码已就位时) # COMPOSE_MANAGED=1 bash deploy/deploy-prod.sh --no-pull # 生产跳过 git 拉取(代码已就位时)
set -euo pipefail set -euo pipefail
log() { printf '\n\033[1;36m== %s ==\033[0m\n' "$*"; } log() { printf '\n\033[1;36m== %s ==\033[0m\n' "$*"; }
...@@ -43,6 +44,10 @@ probe_health() { # $1=base(如 http://127.0.0.1:3101) → stdout "<code> <body ...@@ -43,6 +44,10 @@ probe_health() { # $1=base(如 http://127.0.0.1:3101) → stdout "<code> <body
main() { main() {
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
source deploy/deploy-mode.sh
local BRANCH="${DEPLOY_BRANCH:-$(git rev-parse --abbrev-ref HEAD)}"
check_deploy_mode "$BRANCH"
# 与 FridayAI 共用 checkout,发布期间禁止另一套流程修改源码或构建。 # 与 FridayAI 共用 checkout,发布期间禁止另一套流程修改源码或构建。
exec 9>"$(git rev-parse --git-common-dir)/deploy.lock" exec 9>"$(git rev-parse --git-common-dir)/deploy.lock"
flock -n 9 || die "PAC 或 FridayAI 正在部署" flock -n 9 || die "PAC 或 FridayAI 正在部署"
...@@ -84,8 +89,6 @@ main() { ...@@ -84,8 +89,6 @@ main() {
# 部署哪个分支:规约 main=生产 / test=测试。默认跟随本机当前 checkout 的分支(不写死), # 部署哪个分支:规约 main=生产 / test=测试。默认跟随本机当前 checkout 的分支(不写死),
# 故生产机(在 main)自然部署 main、测试机(在 test)自然部署 test;需临时切分支再用 DEPLOY_BRANCH 覆盖。 # 故生产机(在 main)自然部署 main、测试机(在 test)自然部署 test;需临时切分支再用 DEPLOY_BRANCH 覆盖。
local BRANCH="${DEPLOY_BRANCH:-$(git rev-parse --abbrev-ref HEAD)}"
if [[ "${1:-}" != "--no-pull" ]]; then if [[ "${1:-}" != "--no-pull" ]]; then
log "拉取分支 $BRANCH(fetch + checkout + ff-only)" log "拉取分支 $BRANCH(fetch + checkout + ff-only)"
git fetch origin "$BRANCH" git fetch origin "$BRANCH"
...@@ -182,13 +185,10 @@ main() { ...@@ -182,13 +185,10 @@ main() {
# 这里额外验私网 IP 可达,把"loopback 通、网关不通"的漂移在部署当场拦下(2026-07-18 踩过)。 # 这里额外验私网 IP 可达,把"loopback 通、网关不通"的漂移在部署当场拦下(2026-07-18 踩过)。
if [[ "${COMPOSE_MANAGED:-0}" == "1" ]]; then if [[ "${COMPOSE_MANAGED:-0}" == "1" ]]; then
log "验证 3b:私网 IP 可达(网关路径,防 loopback-only 绑定)" log "验证 3b:私网 IP 可达(网关路径,防 loopback-only 绑定)"
local priv phealth pweb local priv
priv=$(ip route get 1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1);exit}}') priv=$(deploy_private_ip)
[[ -n "$priv" ]] || die "拿不到私网 IP(ip route get 1 解析失败)" verify_deploy_endpoint "http://$priv:3101/health" health
phealth=$(probe_health "http://$priv:3101") || die "私网 $priv:3101 /health 不健康($phealth)— 应用可能绑成 loopback,网关够不到(会 502)。托管环境需 web/service 绑 0.0.0.0(见 docker-compose.managed.yml)" verify_deploy_endpoint "http://$priv:3100/plans" web
pweb=$(curl -s -m 30 -o /dev/null -w '%{http_code}' "http://$priv:3100/plans" || true)
[[ "$pweb" == "200" ]] || die "私网 $priv:3100 web=$pweb — 同上(loopback 绑定 → 网关 502)"
echo " 私网 $priv health 200 / web 200(网关路径 OK)"
fi fi
log "部署成功 ✅ $(git log --oneline -1)" log "部署成功 ✅ $(git log --oneline -1)"
......
# 在现有生产网关安装。替换 AI_DOMAIN、RELAY_DOMAIN、证书路径后 nginx -t。 # 在现有生产网关安装。域名统一为 fai.friday.tech;填写实际证书路径后 nginx -t。
# 前提:只有此网关可访问应用机 3103/3104/3106;真实来源仅一层代理。 # 前提:只有此网关可访问应用机 3103/3104/3106;真实来源仅一层代理。
server { server {
listen 80; listen 80;
server_name AI_DOMAIN RELAY_DOMAIN; server_name fai.friday.tech;
return 301 https://$host$request_uri; return 301 https://$host$request_uri;
} }
server { server {
listen 443 ssl; listen 443 ssl;
server_name AI_DOMAIN; server_name fai.friday.tech;
ssl_certificate /REQUIRED/AI/fullchain.pem; ssl_certificate /REQUIRED/AI/fullchain.pem;
ssl_certificate_key /REQUIRED/AI/privkey.pem; ssl_certificate_key /REQUIRED/AI/privkey.pem;
client_max_body_size 25m; client_max_body_size 25m;
...@@ -27,13 +27,7 @@ server { ...@@ -27,13 +27,7 @@ server {
} }
location = /health { proxy_pass http://172.16.4.239:3104; access_log off; } location = /health { proxy_pass http://172.16.4.239:3104; access_log off; }
location = /health/ready { proxy_pass http://172.16.4.239:3104; access_log off; } location = /health/ready { proxy_pass http://172.16.4.239:3104; access_log off; }
location / { proxy_pass http://172.16.4.239:3103; } # 回调直接交给独立中转,AI 停服时仍可接收。
}
server {
listen 443 ssl;
server_name RELAY_DOMAIN;
ssl_certificate /REQUIRED/RELAY/fullchain.pem;
ssl_certificate_key /REQUIRED/RELAY/privkey.pem;
location = /hook/ecom { location = /hook/ecom {
limit_except POST { deny all; } limit_except POST { deny all; }
client_max_body_size 20m; client_max_body_size 20m;
...@@ -44,5 +38,10 @@ server { ...@@ -44,5 +38,10 @@ server {
proxy_connect_timeout 2s; proxy_connect_timeout 2s;
proxy_read_timeout 8s; proxy_read_timeout 8s;
} }
location / { return 404; } # 不暴露中转的本地调试和管理入口。
location = /local { return 404; }
location ^~ /local/ { return 404; }
location = /status { return 404; }
location ^~ /hook/ { return 404; }
location / { proxy_pass http://172.16.4.239:3103; }
} }
...@@ -15,12 +15,14 @@ function runDeploy({ ...@@ -15,12 +15,14 @@ function runDeploy({
piped = false, piped = false,
managed = false, managed = false,
target = 'staging', target = 'staging',
privateFail = false,
} = {}) { } = {}) {
const root = mkdtempSync(join(tmpdir(), 'ai-deploy-flow-')); const root = mkdtempSync(join(tmpdir(), 'ai-deploy-flow-'));
const bin = join(root, 'bin'), const bin = join(root, 'bin'),
log = join(root, 'commands.log'); log = join(root, 'commands.log');
for (const dir of [bin, join(root, '.git'), join(root, 'deploy')]) mkdirSync(dir); for (const dir of [bin, join(root, '.git'), join(root, 'deploy')]) mkdirSync(dir);
copyFileSync(source, join(root, 'deploy/deploy-ai.sh')); copyFileSync(source, join(root, 'deploy/deploy-ai.sh'));
copyFileSync(resolve('deploy/deploy-mode.sh'), join(root, 'deploy/deploy-mode.sh'));
writeFileSync(log, ''); writeFileSync(log, '');
const common = 'printf "%s\\n" "$(basename "$0") $*" >> "$DEPLOY_TEST_LOG"\n'; const common = 'printf "%s\\n" "$(basename "$0") $*" >> "$DEPLOY_TEST_LOG"\n';
const stubs = { const stubs = {
...@@ -30,6 +32,9 @@ function runDeploy({ ...@@ -30,6 +32,9 @@ function runDeploy({
'rev-parse --git-common-dir') echo .git ;; 'rev-parse --git-common-dir') echo .git ;;
esac`, esac`,
flock: 'exit 0', flock: 'exit 0',
ip: 'echo "1 via 172.16.4.1 dev eth0 src 172.16.4.239"',
curl: `[[ "$PRIVATE_FAIL" != 1 ]] || exit 7
printf '{"status":"ok","db":"up"}\\n200'`,
docker: String.raw`if [[ ( "$*" == *' run '* || "$*" == *' exec '* ) && "$*" != *'--interactive=false'* ]]; then cat >/dev/null; fi docker: String.raw`if [[ ( "$*" == *' run '* || "$*" == *' exec '* ) && "$*" != *'--interactive=false'* ]]; then cat >/dev/null; fi
if [[ -n "$DEPLOY_TEST_FAIL" && "$*" == *"$DEPLOY_TEST_FAIL"* ]]; then exit 43; fi if [[ -n "$DEPLOY_TEST_FAIL" && "$*" == *"$DEPLOY_TEST_FAIL"* ]]; then exit 43; fi
case "$*" in case "$*" in
...@@ -62,7 +67,8 @@ function runDeploy({ ...@@ -62,7 +67,8 @@ function runDeploy({
...process.env, ...process.env,
PATH: `${bin}:${process.env.PATH}`, PATH: `${bin}:${process.env.PATH}`,
DEPLOY_TEST_LOG: log, DEPLOY_TEST_LOG: log,
COMPOSE_MANAGED: managed ? '1' : '0', COMPOSE_MANAGED: typeof managed === 'string' ? managed : managed ? '1' : '0',
PRIVATE_FAIL: privateFail ? '1' : '0',
DEPLOY_TEST_FAIL: fail, DEPLOY_TEST_FAIL: fail,
DEPLOY_TEST_BRANCH: branch, DEPLOY_TEST_BRANCH: branch,
DEPLOY_TEST_MISMATCH: mismatch ? '1' : '0', DEPLOY_TEST_MISMATCH: mismatch ? '1' : '0',
...@@ -141,3 +147,28 @@ test('生产托管模式每个 Compose 操作均加载生产端口覆盖', () => ...@@ -141,3 +147,28 @@ test('生产托管模式每个 Compose 操作均加载生产端口覆盖', () =>
.every((c) => c.includes('-f docker-compose.ai.managed.yml')), .every((c) => c.includes('-f docker-compose.ai.managed.yml')),
); );
}); });
for (const managed of [false, '', 'true']) {
test(`生产未明确开启托管模式时提前拒绝: ${managed}`, () => {
const { result, commands } = runDeploy({ target: 'production', branch: 'main', managed });
assert.notEqual(result.status, 0);
assert.match(result.stderr, /COMPOSE_MANAGED/);
assert.ok(!commands.some((c) => /^(docker|flock) |git fetch|git merge/.test(c)));
});
}
test('测试误开托管模式时提前拒绝', () => {
const { result, commands } = runDeploy({ managed: true });
assert.notEqual(result.status, 0);
assert.ok(!commands.some((c) => c.startsWith('docker ')));
});
test('AI 容器健康但私网不可达时不能报告部署完成', () => {
const { result } = runDeploy({
target: 'production',
branch: 'main',
managed: true,
privateFail: true,
});
assert.notEqual(result.status, 0);
assert.match(result.stderr, /私网端点不可达/);
assert.doesNotMatch(result.stdout, /FridayAI 部署完成/);
});
...@@ -4,30 +4,42 @@ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, rmSy ...@@ -4,30 +4,42 @@ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, rmSy
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path'; import { join, resolve } from 'node:path';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
function run({ check = false, fail = '' } = {}) { function run({
check = false,
fail = '',
managed = '1',
privateFail = false,
target = 'production',
} = {}) {
const root = mkdtempSync(join(tmpdir(), 'ingress-deploy-')); const root = mkdtempSync(join(tmpdir(), 'ingress-deploy-'));
try { try {
for (const d of ['deploy', 'bin', '.git']) mkdirSync(join(root, d)); for (const d of ['deploy', 'bin', '.git']) mkdirSync(join(root, d));
copyFileSync(resolve('deploy/deploy-ingress.sh'), join(root, 'deploy/deploy-ingress.sh')); copyFileSync(resolve('deploy/deploy-ingress.sh'), join(root, 'deploy/deploy-ingress.sh'));
copyFileSync(resolve('deploy/deploy-mode.sh'), join(root, 'deploy/deploy-mode.sh'));
const log = join(root, 'log'); const log = join(root, 'log');
writeFileSync(log, ''); writeFileSync(log, '');
const scripts = { const scripts = {
git: `case "$*" in 'branch --show-current') echo main;; 'rev-parse --git-common-dir') echo .git;; 'rev-parse --short HEAD') echo abc123;; esac`, git: `case "$*" in 'branch --show-current') echo "$BRANCH";; 'rev-parse --git-common-dir') echo .git;; 'rev-parse --short HEAD') echo abc123;; esac`,
flock: 'exit 0', flock: 'exit 0',
ip: 'echo "1 via 172.16.4.1 dev eth0 src 172.16.4.239"',
curl: `[[ "$PRIVATE_FAIL" != 1 ]] || exit 7
printf '{"status":"ok","db":"up"}\\n200'`,
docker: `printf '%s\\n' "$*" >> "$LOG"\n[[ -z "$FAIL" || "$*" != *"$FAIL"* ]] || exit 43\ncase "$*" in *' ps -q channel-ingress') echo container;; 'image inspect '*|'inspect '*) echo sha256:expected;; esac`, docker: `printf '%s\\n' "$*" >> "$LOG"\n[[ -z "$FAIL" || "$*" != *"$FAIL"* ]] || exit 43\ncase "$*" in *' ps -q channel-ingress') echo container;; 'image inspect '*|'inspect '*) echo sha256:expected;; esac`,
}; };
for (const [n, s] of Object.entries(scripts)) for (const [n, s] of Object.entries(scripts))
writeFileSync(join(root, 'bin', n), '#!/bin/bash\nset -eu\n' + s, { mode: 0o755 }); writeFileSync(join(root, 'bin', n), '#!/bin/bash\nset -eu\n' + s, { mode: 0o755 });
const r = spawnSync( const r = spawnSync(
'bash', 'bash',
[join(root, 'deploy/deploy-ingress.sh'), 'production', ...(check ? ['--check'] : [])], [join(root, 'deploy/deploy-ingress.sh'), target, ...(check ? ['--check'] : [])],
{ {
encoding: 'utf8', encoding: 'utf8',
timeout: 10000, timeout: 10000,
env: { env: {
...process.env, ...process.env,
PATH: join(root, 'bin') + ':' + process.env.PATH, PATH: join(root, 'bin') + ':' + process.env.PATH,
COMPOSE_MANAGED: '1', COMPOSE_MANAGED: managed,
PRIVATE_FAIL: privateFail ? '1' : '0',
BRANCH: target === 'production' ? 'main' : 'test',
LOG: log, LOG: log,
FAIL: fail, FAIL: fail,
}, },
...@@ -64,3 +76,24 @@ test('中转路由校验失败保持原容器,成功后才切换镜像', () => ...@@ -64,3 +76,24 @@ test('中转路由校验失败保持原容器,成功后才切换镜像', () =>
assert.ok(good.log.indexOf('verifyRouting') < good.log.indexOf('up -d')); assert.ok(good.log.indexOf('verifyRouting') < good.log.indexOf('up -d'));
assert.doesNotMatch(good.log, /stop ai/); assert.doesNotMatch(good.log, /stop ai/);
}); });
for (const managed of ['', '0', 'true']) {
test(`生产中转漏配或错配托管模式时不调用 Docker: ${managed}`, () => {
const { r, log } = run({ managed });
assert.notEqual(r.status, 0);
assert.match(r.stderr, /COMPOSE_MANAGED/);
assert.equal(log, '');
});
}
test('测试中转本机模式正常,误开托管模式拒绝', () => {
assert.equal(run({ target: 'staging', managed: '0', check: true }).r.status, 0);
const { r, log } = run({ target: 'staging' });
assert.notEqual(r.status, 0);
assert.equal(log, '');
});
test('中转私网不通时不能报告部署完成', () => {
const { r } = run({ privateFail: true });
assert.notEqual(r.status, 0);
assert.match(r.stderr, /私网端点不可达/);
assert.doesNotMatch(r.stdout, /中转部署完成/);
});
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, copyFileSync, rmSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
const helper = resolve('deploy/deploy-mode.sh');
function probe(body, code = '200', kind = 'ready') {
const root = mkdtempSync(join(tmpdir(), 'deploy-probe-'));
try {
writeFileSync(join(root, 'curl'), '#!/bin/sh\nprintf "%s\\n%s" "$BODY" "$CODE"\n', {
mode: 0o755,
});
return spawnSync(
'bash',
[
'-c',
'source "$1"; verify_deploy_endpoint http://172.16.4.239:3104/health/ready "$2"',
'test',
helper,
kind,
],
{
encoding: 'utf8',
timeout: 5000,
env: { ...process.env, PATH: root + ':' + process.env.PATH, BODY: body, CODE: code },
},
);
} finally {
rmSync(root, { recursive: true, force: true });
}
}
test('健康检查验证业务状态,不能把 HTTP 200 错误页视为成功', () => {
assert.equal(probe('{"status": "ok", "db": "up"}').status, 0);
for (const body of ['{"code":10004}', '{"status":"ok","db":"down"}', '<html>error</html>'])
assert.notEqual(probe(body).status, 0);
assert.notEqual(probe('{"status":"ok","db":"up"}', '502').status, 0);
assert.equal(probe('{"status":"ok"}', '200', 'health').status, 0);
assert.equal(probe('<html>login</html>', '200', 'web').status, 0);
});
test('PAC main 分支漏配托管模式,即使没有 env,也在 Docker 和发布锁之前拒绝', () => {
const root = mkdtempSync(join(tmpdir(), 'pac-mode-'));
try {
for (const d of ['deploy', 'bin']) mkdirSync(join(root, d));
for (const f of ['deploy-prod.sh', 'deploy-mode.sh'])
copyFileSync(resolve('deploy', f), join(root, 'deploy', f));
writeFileSync(join(root, 'bin/git'), '#!/bin/sh\necho main\n', { mode: 0o755 });
for (const f of ['docker', 'flock'])
writeFileSync(join(root, 'bin', f), '#!/bin/sh\necho called >> "$LOG"\nexit 99\n', {
mode: 0o755,
});
const log = join(root, 'log');
writeFileSync(log, '');
const env = { ...process.env, PATH: join(root, 'bin') + ':' + process.env.PATH, LOG: log };
delete env.COMPOSE_MANAGED;
delete env.DEPLOY_BRANCH;
const r = spawnSync('bash', [join(root, 'deploy/deploy-prod.sh'), '--no-pull'], {
env,
encoding: 'utf8',
timeout: 5000,
});
assert.notEqual(r.status, 0);
assert.match(r.stderr, /COMPOSE_MANAGED=1/);
assert.equal(readFileSync(log, 'utf8'), '');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
...@@ -8,22 +8,38 @@ import { spawnSync } from 'node:child_process'; ...@@ -8,22 +8,38 @@ import { spawnSync } from 'node:child_process';
test('PAC 迁移失败保留应用和已运行的基础设施', () => { test('PAC 迁移失败保留应用和已运行的基础设施', () => {
const root = mkdtempSync(join(tmpdir(), 'pac-migration-failure-')); const root = mkdtempSync(join(tmpdir(), 'pac-migration-failure-'));
try { try {
for (const d of ['bin', '.git', 'deploy', 'apps/pac-service']) mkdirSync(join(root, d), { recursive: true }); for (const d of ['bin', '.git', 'deploy', 'apps/pac-service'])
mkdirSync(join(root, d), { recursive: true });
copyFileSync(resolve('deploy/deploy-prod.sh'), join(root, 'deploy/deploy-prod.sh')); copyFileSync(resolve('deploy/deploy-prod.sh'), join(root, 'deploy/deploy-prod.sh'));
writeFileSync(join(root, 'apps/pac-service/.env'), 'DATABASE_URL=postgresql://dev:dev@localhost:5432/pac\n'); copyFileSync(resolve('deploy/deploy-mode.sh'), join(root, 'deploy/deploy-mode.sh'));
writeFileSync(
join(root, 'apps/pac-service/.env'),
'DATABASE_URL=postgresql://dev:dev@localhost:5432/pac\n',
);
const log = join(root, 'calls'); const log = join(root, 'calls');
writeFileSync(log, ''); writeFileSync(log, '');
for (const [name, body] of Object.entries({ for (const [name, body] of Object.entries({
git: 'case "$*" in "rev-parse --git-common-dir") echo .git;; "rev-parse --abbrev-ref HEAD") echo test;; esac', git: 'case "$*" in "rev-parse --git-common-dir") echo .git;; "rev-parse --abbrev-ref HEAD") echo test;; esac',
flock: 'exit 0', flock: 'exit 0',
docker: 'echo "$*" >> "$TEST_CALLS"\ncase "$*" in *"ps -q --status running"*) echo existing;; *"run --rm --no-deps -T --interactive=false pac-migrate"*) exit 42;; esac', docker:
})) writeFileSync(join(root, 'bin', name), '#!/bin/sh\n' + body + '\n', { mode: 0o755 }); 'echo "$*" >> "$TEST_CALLS"\ncase "$*" in *"ps -q --status running"*) echo existing;; *"run --rm --no-deps -T --interactive=false pac-migrate"*) exit 42;; esac',
}))
writeFileSync(join(root, 'bin', name), '#!/bin/sh\n' + body + '\n', { mode: 0o755 });
const r = spawnSync('bash', ['deploy/deploy-prod.sh', '--no-pull'], { const r = spawnSync('bash', ['deploy/deploy-prod.sh', '--no-pull'], {
cwd: root, encoding: 'utf8', env: { ...process.env, COMPOSE_MANAGED: '0', PATH: join(root, 'bin') + ':' + process.env.PATH, TEST_CALLS: log }, cwd: root,
encoding: 'utf8',
env: {
...process.env,
COMPOSE_MANAGED: '0',
PATH: join(root, 'bin') + ':' + process.env.PATH,
TEST_CALLS: log,
},
}); });
assert.equal(r.status, 42, r.stdout + r.stderr); assert.equal(r.status, 42, r.stdout + r.stderr);
const calls = readFileSync(log, 'utf8'); const calls = readFileSync(log, 'utf8');
assert.match(calls, /run --rm --no-deps -T --interactive=false pac-migrate/); assert.match(calls, /run --rm --no-deps -T --interactive=false pac-migrate/);
assert.doesNotMatch(calls, /\b(up|stop|restart|rm)\b.*(pac-service|pac-web|postgres|redis)/); assert.doesNotMatch(calls, /\b(up|stop|restart|rm)\b.*(pac-service|pac-web|postgres|redis)/);
} finally { rmSync(root, { recursive: true, force: true }); } } finally {
rmSync(root, { recursive: true, force: true });
}
}); });
# FridayAI 首次生产上线:运维交接单 # FridayAI 生产上线:运维协助清单
应用机 `ssh pac-friday`,源码 `/data/pac`,分支 `main`。PAC 已运行;FridayAI 和中转尚未上线。详细命令见 [生产部署步骤](friday-ai-production-deployment.md),执行证据见 [准备记录](qa/2026-10-10-production-readiness.md)。 ## 项目与环境
## 已准备 仓库:<https://code.jwsmed.com/ai-tools/pac>(PAC / FridayAI 单仓库)。
- 生产发布脚本、远端网关端口覆盖、独立中转部署和配置预检。 | 环境 | 应用服务器 | 代码目录 / 分支 | 域名 |
- 服务器健康/队列巡检、cron 与日志轮转模板;不依赖开发电脑。 | --- | --- | --- | --- |
- 生产专用 JWT 私钥、读取密钥、中转密钥、数据库应用密码及 env 草稿,位于应用机 `/data/pac-ops/readiness-20261010/config/`,尚未激活。 | 生产(本次操作) | `47.99.62.30:3335`;私网 `172.16.4.239` | `/data/pac` / `main` | 新增 `fai.friday.tech`;现有 PAC 为 `pac.friday.tech` |
- PAC 三个大表索引已于 2026-10-10 01:55 建成并验证有效,合计约 2.73 GiB;5i5ya 诊所名称字典已补齐 66 条。正式发布仍需运行七条迁移登记,已建索引会跳过重复创建。 | 测试(仅供参考) | `47.251.104.47:19822` | `/root/pac` / `test` | `fai.jarvismedical.asia`;回调 `relay.jarvismedical.asia` |
- 未使用的治疗步骤汇总功能已移除,不需要回填或全量重算患者。
## 请运维处理 生产 Nginx 在现有独立网关,**不在应用服务器上**。沿用 `pac.friday.tech` 的网关(现有入口 `lb1.friday.tech`,公网 `47.98.152.240`);请运维核对网关实例后配置。
| 项目 | 运维执行 / 提供 | 验收 | ## 1. RDS:新建库和专用账号
| --- | --- | --- |
| RDS | 备份 PAC 并登记恢复点;确认磁盘/IOPS;执行 `deploy/production/friday-ai-database.sql` 建独立库和账号,在新库安装两个扩展 | 新应用账号可迁移 `friday_ai`;UTF8、UTC;不改 PAC 时区 |
| 数据库密码 | 安全交付专用应用账号密码,或使用已生成的 `database.key` 设置 | 两份 env 中 URL 一致;不需要交出 RDS 管理账号 |
| 域名/证书 | 确认 AI 和回调的生产域名,配置 DNS、证书 | HTTPS 正常;不覆盖 PAC 域名 |
| 网关/防火墙 | 按 `deploy/production/friday-ai-gateway.conf.example` 配置;仅网关访问应用机 3103/3104/3106 | API、SSE、上传可用;回调域名只开放 POST `/hook/ecom` |
| OSS | 生产私有 bucket、region、应用 RAM 凭据 | 生产附件上传及签名下载成功 |
| ECom | 生产 API 凭据、回调出口 IP;登记独立生产回调 | 测试回调保持原样;生产无本地分流 |
| 模型 | 生产 API Key 和模型访问额度 | 测试调用成功 |
| 告警 | 采集巡检日志;连续三次异常或五分钟缺日志告警;外部拨测;登记接收人 | 受控演练可收到告警和恢复通知 |
业务还需提供:集团正式名称、首位管理员姓名/手机号、平台管理员资料、首批诊所/员工范围、批准的席位数量/期限、专用验收企微号。先接 5i5ya:`partner=jvs-dw`、`partnerTenant=ruier-grp`;无需提供其 PAC appSecret,不创建 Friday SaaS 生产租户。 实例:`fridaypac.pg.rds.aliyuncs.com:5432`(现有 PAC 使用的 PostgreSQL 实例)。
## 实施顺序 用 RDS 管理账号连接 `postgres` 库,执行以下 SQL;`CREATE DATABASE` 不放在事务中:
1. 核对准备记录和索引状态;确认 RDS 备份、网关限制、所有生产配置已齐。 ```sql
2. 备份现有配置和镜像记录;`main` 快进到本次经过测试的 SHA,保护现有未跟踪文件。 CREATE ROLE friday_ai LOGIN;
3. 补全 env 草稿、安装配置和私钥;PAC/AI 使用同一服务读取密钥。 CREATE DATABASE friday_ai OWNER friday_ai TEMPLATE template0
4. `COMPOSE_MANAGED=1 bash deploy/deploy-prod.sh --no-pull` 升级 PAC,核验七条迁移及真实读取;不首灌、不回填、不全量重算。 ENCODING 'UTF8' LC_COLLATE 'C' LC_CTYPE 'en_US.utf8';
5. `COMPOSE_MANAGED=1 bash deploy/deploy-ai.sh production --no-pull`。 ALTER DATABASE friday_ai SET timezone TO 'UTC';
6. `COMPOSE_MANAGED=1 bash deploy/deploy-ingress.sh production`;检查健康,启用网关和巡检。 ```
7. CLI 开户,配置组织/员工范围及席位;验收 5i5ya 患者数据和诊所隔离。
8. 先开 worker、后按业务确认开 scheduler;专用号验收收发/图片/文件及 AI 停服补投,再开放正式业务。
**生产发布仍有短暂停服;本次没有承诺零停机。** 中转保护 AI 停服期间已持久接收的回调,不覆盖中转、主机或数据库自身故障。首次上线不能照搬测试账号、设备或密钥。 随后**切换连接到 `friday_ai` 库**,执行:
```sql
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE EXTENSION IF NOT EXISTS btree_gist;
```
- 用 RDS 控制台/DMS 设置 `friday_ai` 密码,或在 psql 中执行 `\password friday_ai`;通过安全渠道交付密码。
- 账号需拥有该库及 `public` schema 建表、索引、约束和读写权限;仅使用 `friday_ai`,不复用 `pac` 账号。
- 确认应用机 `172.16.4.239` 可连接该实例。
- 不改现有 `pac` 库、账号及实例全局时区。若库或账号已存在,核对后补齐权限,不删除重建。
- 发布前确认 PAC 可恢复备份和恢复点、RDS 磁盘/IOPS 余量;新库纳入自动备份。
SQL 文件:[friday-ai-database.sql](../deploy/production/friday-ai-database.sql)(扩展语句须切换库后执行)。
## 2. 域名、证书与网关
1. 新增 `fai.friday.tech` 的 DNS,指向现有生产网关;申请 HTTPS 证书并配置自动续期。
2. 在网关新增独立站点,使用 [friday-ai-gateway.conf.example](../deploy/production/friday-ai-gateway.conf.example),替换两处证书路径。
3. 配置网关到应用机的网络放行:仅允许网关访问 `172.16.4.239` 的 TCP `3103 / 3104 / 3106`,不开放公网直连。
4. 执行 `nginx -t`,通过后 reload;保留现有 PAC 和测试站点配置。
模板已包含以下转发规则,请完整保留:
| 路径 | 上游 |
| --- | --- |
| `/` | `http://172.16.4.239:3103` |
| `/api/`、`/health`、`/health/ready` | `http://172.16.4.239:3104` |
| 仅 POST `/hook/ecom` | `http://172.16.4.239:3106` |
模板已配置 SSE 禁用缓冲、长连接超时和上传限制,并禁止访问中转调试/管理路径。若网关前还有其他代理,请告知代理层数和真实来源 IP 的传递方式。
应用部署完成后共同验收:`https://fai.friday.tech/login` 可打开;`/health/ready` 返回 HTTP 200 且包含 `status=ok`、`db=up`。应用未启动前上游暂不可用属预期,正式切换回调由我们协调。
## 3. 接入现有监控告警(应用上线后启用)
- 采集应用机 `/var/log/friday-ai-health.log`:连续三条 `degraded`,或五分钟无新日志时通知负责人。
- 外部拨测 `https://fai.friday.tech/health/ready`:每分钟一次,连续三次失败告警,检查 HTTP 状态和上述 JSON 字段。
- 纳入主机内存/磁盘、RDS 存储/IOPS/连接数、证书到期监控,配置异常及恢复通知。
## 完成后反馈
- 新库/账号已就绪、密码已安全交付、备份恢复点。
- DNS、证书、网关配置完成,网络规则已放行;有额外代理时说明。
- 告警接收人及通知渠道。
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
- 服务器:`ssh -p 19822 root@47.251.104.47`。 - 服务器:`ssh -p 19822 root@47.251.104.47`。
- 源码:`/root/pac`,分支 `test`;feature 合入 test,验证通过后再由 feature 合入 main。 - 源码:`/root/pac`,分支 `test`;feature 合入 test,验证通过后再由 feature 合入 main。
- 域名:`https://fai.jarvismedical.asia`;Web 3103、API 3104,仅发布到主机 loopback。 - 域名:`https://fai.jarvismedical.asia`;Web 3103、API 3104,仅发布到主机 loopback。
- 测试发布不设置 `COMPOSE_MANAGED`(或设为 `0`);误设为 `1` 会在构建、迁移和重启前被拒绝。
- 使用 Docker Compose;Node、pnpm 随镜像提供,不安装主机运行时,不使用 systemd。 - 使用 Docker Compose;Node、pnpm 随镜像提供,不安装主机运行时,不使用 systemd。
- PAC 已有 `pac_default` 网络及 PostgreSQL;AI 使用独立 `friday_ai` 库、独立 `pac-ai` Compose 项目。 - PAC 已有 `pac_default` 网络及 PostgreSQL;AI 使用独立 `friday_ai` 库、独立 `pac-ai` Compose 项目。
......
...@@ -55,6 +55,6 @@ ...@@ -55,6 +55,6 @@
## 运维交付 ## 运维交付
按 [生产部署](../friday-ai-production-deployment.md) 实施。需要运维执行的 SQL、网关、cron、日志轮转模板在 `deploy/production/`。待提供:生产域名/证书,专用数据库账号,ECom/OSS/模型生产配置,告警接收人,首个集团/管理员/诊所/席位信息。无需交付 RDS 管理账号。 按 [生产部署](../friday-ai-production-deployment.md) 实施。需要运维执行的 SQL、网关、cron、日志轮转模板在 `deploy/production/`。后续已确定 AI 与回调共用 `fai.friday.tech`,OSS 和模型凭据复用测试配置(OSS 生产前缀 `friday-ai-prod`)。待提供/落实:该域名 DNS/证书,专用数据库账号,ECom 生产配置及共用资源权限/额度核验,告警接收人,首个集团/管理员/诊所/席位信息。无需交付 RDS 管理账号。
生产准备工具已验证后合入 main `ad25aed`、test `938dff5`;本次收尾仅更新完成记录及运维步骤,不更改应用代码。临时索引/字典任务已退出,收尾自动化在文档同步完成后停用。 生产准备工具已验证后合入 main `ad25aed`、test `938dff5`;本次收尾仅更新完成记录及运维步骤,不更改应用代码。临时索引/字典任务已退出,收尾自动化在文档同步完成后停用。
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment