From 295310ab9ac60cc1f3d6bb747c114af7fcd42800 Mon Sep 17 00:00:00 2001 From: Donald Date: Wed, 28 Oct 2026 03:44:04 +0000 Subject: [PATCH] feat: support gateway-only deployment without client releases --- deploy/install.sh | 48 +++++++++++++++++++++---- docs/deployment.md | 6 ++++ test/gateway-deploy.test.ts | 70 +++++++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 7 deletions(-) create mode 100644 test/gateway-deploy.test.ts diff --git a/deploy/install.sh b/deploy/install.sh index da22b3e..5242c7e 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -1,6 +1,14 @@ #!/usr/bin/env bash set -Eeuo pipefail +gateway_only=false +if [[ "$#" -eq 1 && "$1" == "--gateway-only" ]]; then + gateway_only=true +elif [[ "$#" -ne 0 ]]; then + echo "用法:./deploy/install.sh [--gateway-only]" >&2 + exit 1 +fi + project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" domain="approval.justconn.top" public_base_url="https://${domain}" @@ -18,7 +26,11 @@ npm_cache="${project_root}/.npm-cache" release_private_key="${project_root}/.secrets/release-signing-private.pem" release_public_key="${project_root}/distribution/release-signing-public.pem" -for command_name in node npm curl systemctl sudo nginx certbot stat sha256sum tar find sort tail cut date git tea; do +required_commands=(node npm curl systemctl cut git) +if [[ "${gateway_only}" == false ]]; then + required_commands+=(sudo nginx certbot stat sha256sum tar find sort tail date tea) +fi +for command_name in "${required_commands[@]}"; do command -v "${command_name}" >/dev/null 2>&1 || { echo "缺少部署命令:${command_name}" >&2 exit 1 @@ -36,9 +48,36 @@ fi release_commit="$(git rev-parse HEAD)" remote_main="$(git ls-remote origin refs/heads/main | cut -f1)" if [[ -z "${remote_main}" || "${remote_main}" != "${release_commit}" ]]; then - echo "当前 main 提交尚未同步到 Gitea origin/main,拒绝创建发布标签。" >&2 + echo "当前 main 提交尚未同步到 Gitea origin/main,拒绝部署。" >&2 exit 1 fi + +if [[ ! -f "${project_root}/.runtime/dingtalk-mcp.env" ]]; then + echo "缺少 .runtime/dingtalk-mcp.env;首次部署前需先配置网关企业凭证。" >&2 + exit 1 +fi + +if [[ "${gateway_only}" == true ]]; then + echo "[1/3] 测试并构建网关" + npm --cache "${npm_cache}" test + node --env-file=.runtime/dingtalk-mcp.env --input-type=module -e ' + import { loadConfig } from "./dist/src/config.js"; + const config = loadConfig(); + if (!config.remoteAuth || !config.xftOpenApi) throw new Error("网关必须同时启用 OAuth、钉钉 OA 与薪福通"); + console.log(`网关配置检查通过;每账号会话上限 ${config.mcpSessionMaxPerUser}`); + ' + echo "[2/3] 更新并重启网关服务" + node dist/src/admin.js service start --project-root "${project_root}" + echo "[3/3] 验证网关服务与 OAuth 元数据" + systemctl --user is-active --quiet dingtalk-approval-mcp.service + curl --fail --silent --show-error --connect-timeout 5 --max-time 15 \ + --retry 10 --retry-delay 1 --retry-all-errors "${public_base_url}/healthz" >/dev/null + curl --fail --silent --show-error --connect-timeout 5 --max-time 15 \ + "${public_base_url}/.well-known/oauth-protected-resource/mcp" >/dev/null + echo "网关部署完成:${release_commit};客户端发行物未变更。" + exit 0 +fi + release_tag_commit="$(git ls-remote origin "refs/tags/${release_tag}" "refs/tags/${release_tag}^{}" | tail -n1 | cut -f1)" release_created_at_commit="${release_tag_commit:-${release_commit}}" if ! git cat-file -e "${release_created_at_commit}^{commit}" 2>/dev/null; then @@ -47,11 +86,6 @@ if ! git cat-file -e "${release_created_at_commit}^{commit}" 2>/dev/null; then fi release_created_at="$(git show -s --format=%cI "${release_created_at_commit}")" -if [[ ! -f "${project_root}/.runtime/dingtalk-mcp.env" ]]; then - echo "缺少 .runtime/dingtalk-mcp.env;首次部署前需先配置网关企业凭证。" >&2 - exit 1 -fi - if [[ ! -f "${release_private_key}" || ! -f "${release_public_key}" ]]; then echo "缺少 Ed25519 发布密钥;首次部署请先执行 npm run release:keygen 并安全备份私钥。" >&2 exit 1 diff --git a/docs/deployment.md b/docs/deployment.md index 4c6205f..202daa9 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -81,6 +81,12 @@ cd /path/to/dingMCP 脚本自动测试、构建、用 Ed25519 私钥签署 schema v3 清单、重启服务、原子发布静态文件、安装 Nginx 配置并验证公网健康检查、OAuth 元数据和安装链接。首次签证书时,临时 HTTP 配置只开放 ACME challenge,其余请求返回 403;不会在明文 HTTP 上代理 OAuth、Token 或 MCP。证书通过 Certbot webroot 模式签发,正式配置启用 HTTPS 跳转与不带 `includeSubDomains` 的 HSTS。已有证书会自动复用;签名私钥缺失、权限不是 `0600` 或与固定公钥不匹配时发行会失败。 +仅修改服务端、无需发布新客户端时,执行 `./deploy/install.sh --gateway-only`。 +该模式同样要求无未提交改动且已同步到 Gitea 的 `main`,运行全量测试和配置检查后重启 +网关,再验证公网健康和 OAuth 元数据。它保留服务器凭证、OAuth 数据库、Nginx 配置和 +现有客户端发行物,不创建 Release 或标签,也不需要读取发布私钥。服务重启会断开 +现有 MCP 连接;客户端需重新建立连接,已有 OAuth 登录凭证保留。 + 发行物先复制到独立 staging 目录,再通过符号链接原子切换;客户端不会看到新清单配旧 artifact 的中间状态。服务器保留当前版本和最近两个旧版本用于排障,较老版本自动清理。`install.md`、`compat-install.md`、清单、签名、安装器、Skill、校验文件和测试包均以 `no-cache` 提供;内容寻址的客户端 tgz 才使用 immutable 缓存。签名清单的 `createdAt` 超过 180 天时兼容客户端拒绝安装,避免旧签名版本冻结攻击。 卸载: diff --git a/test/gateway-deploy.test.ts b/test/gateway-deploy.test.ts new file mode 100644 index 0000000..2f13cbb --- /dev/null +++ b/test/gateway-deploy.test.ts @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +test("gateway-only deployment runs checks and restart without publishing clients or touching secrets", () => { + const root = mkdtempSync(join(tmpdir(), "mock-gateway-deploy-")); + try { + for (const dir of ["deploy", ".runtime", "bin", "release"]) mkdirSync(join(root, dir)); + copyFileSync("deploy/install.sh", join(root, "deploy/install.sh")); + writeFileSync(join(root, ".runtime/dingtalk-mcp.env"), "mock-config-preserved"); + writeFileSync(join(root, "release/manifest.json"), "mock-existing-client-manifest"); + const calls = join(root, "calls.log"); + const stub = `#!/bin/bash +printf '%s\\n' "$(basename "$0") $*" >> "$MOCK_DEPLOY_CALLS" +case "$(basename "$0")" in + git) + case "$1" in + branch) echo "\${MOCK_DEPLOY_BRANCH:-main}" ;; + diff) exit "\${MOCK_DEPLOY_DIRTY:-0}" ;; + rev-parse) echo mock-commit ;; + ls-remote) printf 'mock-commit\\trefs/heads/main\\n' ;; + *) exit 77 ;; + esac ;; + npm) exit "\${MOCK_DEPLOY_TEST_EXIT:-0}" ;; + node) + case "$1" in + -p) echo 1.1.0-rc.20 ;; + --env-file=*) exit "\${MOCK_DEPLOY_CONFIG_EXIT:-0}" ;; + dist/src/admin.js) exit 0 ;; + *) exit 77 ;; + esac ;; + curl|systemctl) exit 0 ;; + *) exit 77 ;; +esac +`; + for (const name of ["git", "node", "npm", "curl", "systemctl", "tea", "sudo"]) { + writeFileSync(join(root, "bin", name), stub, { mode: 0o755 }); + } + const run = (overrides: NodeJS.ProcessEnv = {}) => { + writeFileSync(calls, ""); + const result = spawnSync("bash", [join(root, "deploy/install.sh"), "--gateway-only"], { + cwd: root, encoding: "utf8", + env: { ...process.env, PATH: `${join(root, "bin")}:${process.env.PATH}`, MOCK_DEPLOY_CALLS: calls, ...overrides }, + }); + return { ...result, calls: readFileSync(calls, "utf8") }; + }; + const success = run(); + assert.equal(success.status, 0, success.stderr); + assert.match(success.calls, /npm .* test\n/); + assert.match(success.calls, /node --env-file=.*loadConfig/s); + assert.match(success.calls, /node dist\/src\/admin.js service start --project-root/); + assert.match(success.calls, /systemctl --user is-active --quiet/); + assert.match(success.calls, /\/healthz/); + assert.match(success.calls, /oauth-protected-resource\/mcp/); + assert.doesNotMatch(success.calls, /runtime:verify|release:build|release:verify|test-bundle|refs\/tags|tea |sudo /); + assert.equal(readFileSync(join(root, ".runtime/dingtalk-mcp.env"), "utf8"), "mock-config-preserved"); + assert.equal(readFileSync(join(root, "release/manifest.json"), "utf8"), "mock-existing-client-manifest"); + for (const failure of [ + { MOCK_DEPLOY_TEST_EXIT: "1" }, { MOCK_DEPLOY_CONFIG_EXIT: "1" }, + { MOCK_DEPLOY_BRANCH: "feature" }, { MOCK_DEPLOY_DIRTY: "1" }, + ]) { + const result = run(failure); + assert.notEqual(result.status, 0); + assert.doesNotMatch(result.calls, /node dist\/src\/admin.js service start/); + } + } finally { rmSync(root, { recursive: true, force: true }); } +});