#!/usr/bin/env node import { createHash, randomUUID } from 'node:crypto' import { execFileSync, spawnSync } from 'node:child_process' import { promises as fs } from 'node:fs' import os from 'node:os' import path from 'node:path' import process from 'node:process' import { fileURLToPath } from 'node:url' const SCRIPT_DIRECTORY = path.dirname(fileURLToPath(import.meta.url)) const REPOSITORY_ROOT = path.resolve(SCRIPT_DIRECTORY, '..') const POWERSHELL_DEPLOY_SCRIPT = path.join(SCRIPT_DIRECTORY, 'git-incremental-deploy.ps1') const PROXY_RECONCILER = path.join(SCRIPT_DIRECTORY, 'local-proxy-reconcile.sh') const TRUSTED_REPOSITORY = 'http://git.1080p.hk/WuYongJie/gongxiangp2.git' const TRUSTED_BRANCH = 'main' const BUILD_SERVER_IP = '192.168.40.206' const BUILD_SERVER_USER = 'root' const BUILD_DIRECTORY = '/root/youyidian-3d-git-build' const PRODUCTION_SERVER_IP = '192.168.58.20' const PRODUCTION_SERVER_USER = 'root' const PRODUCTION_KNOWN_HOSTS_DEFAULT = '/root/.ssh/gongxiangp2-production-known_hosts' const DEFAULT_UPLOADS_MOUNT = '/mnt/PoolOne/youyidian-uploads' const TRUSTED_UPLOADS_MOUNTS = new Set([ DEFAULT_UPLOADS_MOUNT, '/mnt/PoolOne/apps/youyidian-3d/uploads', ]) const USAGE = `gongxiangp2 Mac 部署入口 用法: ./scripts/git-incremental-deploy-macos.sh --dry-run ./scripts/git-incremental-deploy-macos.sh --commit <40位小写提交SHA> 选项: --commit 部署远端 main 中的精确提交;正式部署必填 --dry-run 仅执行源码、SSH、Registry 与生产连通性预检 --no-cache 不使用 Docker 构建缓存 --reuse-verified-image 复用已经校验的精确提交镜像 --skip-deploy 仅构建并推送精确提交镜像,不连接生产机或切换流量 --keep-old-images 不清理旧 Docker 镜像 --aggressive-cache-cleanup 清理超过 7 天的构建缓存 --no-rollback 禁用失败后的旧镜像自动恢复 --require-migration-audit 要求使用已批准的数据库迁移审计凭证 --help 显示帮助 本机约定: BUILD_SERVER_IDENTITY_FILE 默认 ~/.ssh/gongxiangp2_build_machine BUILD_SERVER_KNOWN_HOSTS_FILE 默认 ~/.ssh/known_hosts(只提取已信任的 ED25519 项) PROD_SERVER_KNOWN_HOSTS_FILE 默认 ${PRODUCTION_KNOWN_HOSTS_DEFAULT}(构建机上的固定文件) ` function shellQuote(value) { return `'${String(value ?? '').replaceAll("'", "'\\''")}'` } function requireExactCommit(value, label = 'commit') { if (!/^[0-9a-f]{40}$/.test(value)) { throw new Error(`${label} 必须是 40 位小写 Git SHA`) } return value } export function parseArguments(argv) { const options = { commit: '', dryRun: false, noCache: false, reuseVerifiedImage: false, skipDeploy: false, keepOldImages: false, aggressiveCleanup: false, noRollback: false, requireMigrationAudit: false, help: false, } for (let index = 0; index < argv.length; index += 1) { const argument = argv[index] switch (argument) { case '--commit': index += 1 if (index >= argv.length) throw new Error('--commit 缺少 SHA 参数') options.commit = requireExactCommit(argv[index]) break case '--dry-run': options.dryRun = true; break case '--no-cache': options.noCache = true; break case '--reuse-verified-image': options.reuseVerifiedImage = true; break case '--skip-deploy': options.skipDeploy = true; break case '--keep-old-images': options.keepOldImages = true; break case '--aggressive-cache-cleanup': options.aggressiveCleanup = true; break case '--no-rollback': options.noRollback = true; break case '--require-migration-audit': options.requireMigrationAudit = true; break case '--help': case '-h': options.help = true; break default: throw new Error(`不支持的参数:${argument}`) } } if (!options.help && !options.dryRun && !options.commit) { throw new Error('正式部署必须通过 --commit 指定 40 位小写提交 SHA') } if (options.reuseVerifiedImage && options.noCache) { throw new Error('--reuse-verified-image 不能与 --no-cache 同时使用') } if (options.reuseVerifiedImage && options.skipDeploy) { throw new Error('--reuse-verified-image 不能与 --skip-deploy 同时使用') } return options } export function extractRemoteTemplate(powerShellSource) { const match = powerShellSource.match(/\$remoteTemplate = @'\r?\n([\s\S]*?)\r?\n'@\r?\n\r?\nWrite-Step/) if (!match) throw new Error('无法从主部署脚本读取远端部署模板') return match[1].replaceAll('\r\n', '\n').replaceAll('\r', '\n') } export function renderRemoteTemplate(template, values) { const requiredTokens = [...new Set(template.match(/__[A-Z0-9_]+__/g) ?? [])] const missing = requiredTokens.filter((token) => !(token in values)) if (missing.length > 0) { throw new Error(`Mac 入口缺少主部署模板参数:${missing.join(', ')}`) } let rendered = template for (const token of requiredTokens) rendered = rendered.replaceAll(token, shellQuote(values[token])) const unresolved = [...new Set(rendered.match(/__[A-Z0-9_]+__/g) ?? [])] if (unresolved.length > 0) throw new Error(`部署模板仍有未解析参数:${unresolved.join(', ')}`) return rendered } function run(command, args, options = {}) { const result = spawnSync(command, args, { cwd: options.cwd ?? REPOSITORY_ROOT, encoding: 'utf8', input: options.input, env: options.env ?? process.env, stdio: options.stdio ?? ['ignore', 'pipe', 'pipe'], }) if (result.error) throw result.error if (result.status !== 0) { const details = [result.stderr, result.stdout].filter(Boolean).join('\n').trim() throw new Error(`${command} 执行失败(${result.status})${details ? `:\n${details}` : ''}`) } return String(result.stdout ?? '').trim() } function runGit(args, options = {}) { return run('git', [ '-c', 'http.version=HTTP/1.1', '-c', 'http.lowSpeedLimit=1', '-c', 'http.lowSpeedTime=30', ...args, ], options) } function sshArguments(identityFile, knownHostsFile) { return [ '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes', '-o', `UserKnownHostsFile=${knownHostsFile}`, '-o', 'ConnectTimeout=10', '-o', 'ServerAliveInterval=3', '-o', 'ServerAliveCountMax=10', '-o', 'TCPKeepAlive=yes', '-i', identityFile, ] } function fingerprintForPublicKeyLine(line) { const parts = line.trim().split(/\s+/) if (parts.length < 3 || parts[1] !== 'ssh-ed25519') { throw new Error('known_hosts 项不是唯一的 ED25519 主机密钥') } const digest = createHash('sha256').update(Buffer.from(parts[2], 'base64')).digest('base64') return `SHA256:${digest.replace(/=+$/, '')}` } async function createPinnedBuildKnownHosts(sourceFile, temporaryRoot) { const sourceStat = await fs.lstat(sourceFile).catch(() => null) if (!sourceStat?.isFile() || sourceStat.isSymbolicLink()) { throw new Error(`构建机 known_hosts 不是可信普通文件:${sourceFile}`) } const lookup = run('ssh-keygen', ['-F', BUILD_SERVER_IP, '-f', sourceFile]) const matches = lookup .split(/\r?\n/) .map((line) => line.trim()) .filter((line) => line && !line.startsWith('#') && /\s+ssh-ed25519\s+/.test(line)) if (matches.length !== 1) { throw new Error(`本机 known_hosts 必须包含构建机 ${BUILD_SERVER_IP} 的唯一 ED25519 密钥`) } const pinnedFile = path.join(temporaryRoot, 'build-known-hosts') await fs.writeFile(pinnedFile, `${matches[0]}\n`, { encoding: 'utf8', mode: 0o600 }) return { pinnedFile, fingerprint: fingerprintForPublicKeyLine(matches[0]) } } async function validateIdentityFile(identityFile) { const identityStat = await fs.lstat(identityFile).catch(() => null) if (!identityStat?.isFile() || identityStat.isSymbolicLink()) { throw new Error(`构建机私钥不是可信普通文件:${identityFile}`) } if ((identityStat.mode & 0o077) !== 0) { throw new Error(`构建机私钥权限必须为 0600 或更严格:${identityFile}`) } } function resolveProductionFingerprint(sshCommonArgs, productionKnownHostsFile) { if (!/^\/[A-Za-z0-9._/-]+$/.test(productionKnownHostsFile)) { throw new Error('构建机上的生产 known_hosts 路径必须是绝对安全路径') } const quotedPath = shellQuote(productionKnownHostsFile) const command = `set -eu; test -f ${quotedPath}; test ! -L ${quotedPath}; ` + `ssh-keygen -F ${shellQuote(PRODUCTION_SERVER_IP)} -f ${quotedPath} ` + `| awk '!/^#/ { print $2 " " $3 }' ` + `| ssh-keygen -lf - -E sha256 ` + `| awk '{print $2}' | sort -u` const output = run('ssh', [ ...sshCommonArgs, `${BUILD_SERVER_USER}@${BUILD_SERVER_IP}`, command, ]) const fingerprints = output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean) if (fingerprints.length !== 1 || !/^SHA256:[A-Za-z0-9+/]{20,}={0,2}$/.test(fingerprints[0])) { throw new Error('构建机的生产 known_hosts 未解析出唯一 SHA256 主机指纹') } return fingerprints[0] } async function resolveAndScanExactCommit(options) { const temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'gongxiangp2-macos-preflight-')) const bareRepository = path.join(temporaryRoot, 'target.git') const checkout = path.join(temporaryRoot, 'target-tree') try { const remoteLine = runGit(['ls-remote', '--heads', TRUSTED_REPOSITORY, `refs/heads/${TRUSTED_BRANCH}`]) const remoteParts = remoteLine.split(/\s+/) if (remoteParts.length !== 2 || remoteParts[1] !== `refs/heads/${TRUSTED_BRANCH}`) { throw new Error('远端 main 未解析为唯一分支引用') } const branchCommit = requireExactCommit(remoteParts[0], '远端 main 提交') const targetCommit = options.commit || branchCommit let useLocalObjects = false try { const localBranch = runGit(['-C', REPOSITORY_ROOT, 'rev-parse', '--verify', `${branchCommit}^{commit}`]) const localTarget = runGit(['-C', REPOSITORY_ROOT, 'rev-parse', '--verify', `${targetCommit}^{commit}`]) if (localBranch !== branchCommit || localTarget !== targetCommit) throw new Error('本机对象与远端提交不一致') runGit(['-C', REPOSITORY_ROOT, 'merge-base', '--is-ancestor', targetCommit, branchCommit]) useLocalObjects = true } catch { useLocalObjects = false } if (useLocalObjects) { runGit(['clone', '--no-checkout', '--shared', REPOSITORY_ROOT, checkout]) runGit(['-C', checkout, 'checkout', '--detach', '--force', targetCommit]) } else { runGit(['init', '--bare', bareRepository]) runGit([ `--git-dir=${bareRepository}`, 'fetch', '--no-tags', '--force', TRUSTED_REPOSITORY, `+refs/heads/${TRUSTED_BRANCH}:refs/preflight/branch`, ]) const fetchedBranch = runGit([`--git-dir=${bareRepository}`, 'rev-parse', '--verify', 'refs/preflight/branch^{commit}']) if (fetchedBranch !== branchCommit) throw new Error('预检期间远端 main 已发生变化,请重试') runGit([`--git-dir=${bareRepository}`, 'cat-file', '-e', `${targetCommit}^{commit}`]) runGit([`--git-dir=${bareRepository}`, 'merge-base', '--is-ancestor', targetCommit, branchCommit]) runGit(['clone', '--no-checkout', bareRepository, checkout]) runGit(['-C', checkout, 'checkout', '--detach', '--force', targetCommit]) } run('node', ['scripts/check-legacy-storage-boundaries.mjs'], { cwd: checkout }) return targetCommit } finally { await fs.rm(temporaryRoot, { recursive: true, force: true }) } } async function executeOnBuildServer(payload, sshCommonArgs, temporaryRoot) { const deploymentId = randomUUID().replaceAll('-', '') const localPayload = path.join(temporaryRoot, `payload-${deploymentId}.sh`) const remoteDirectory = `/tmp/gongxiangp2-deploy-${deploymentId}` const remotePayload = `${remoteDirectory}/payload.sh` const remoteTarget = `${BUILD_SERVER_USER}@${BUILD_SERVER_IP}` await fs.writeFile(localPayload, payload, { encoding: 'utf8', mode: 0o600 }) const prepareCommand = `set -eu; umask 077; test ! -e ${shellQuote(remoteDirectory)}; mkdir -m 700 ${shellQuote(remoteDirectory)}` run('ssh', [...sshCommonArgs, remoteTarget, prepareCommand], { stdio: 'inherit' }) let prepared = true try { run('scp', [...sshCommonArgs, localPayload, `${remoteTarget}:${remotePayload}`], { stdio: 'inherit' }) const runCommand = `set -eu; chmod 600 ${shellQuote(remotePayload)}; set +e; bash ${shellQuote(remotePayload)}; rc=$?; set -e; rm -f ${shellQuote(remotePayload)}; rmdir ${shellQuote(remoteDirectory)}; exit $rc` const result = spawnSync('ssh', [...sshCommonArgs, remoteTarget, runCommand], { stdio: 'inherit' }) if (result.error) throw result.error prepared = false if (result.status !== 0) throw new Error(`Git 增量部署失败,退出码 ${result.status}`) } finally { if (prepared) { spawnSync('ssh', [ ...sshCommonArgs, remoteTarget, `rm -f ${shellQuote(remotePayload)}; rmdir ${shellQuote(remoteDirectory)} >/dev/null 2>&1 || true`, ], { stdio: 'ignore' }) } } } async function main() { const options = parseArguments(process.argv.slice(2)) if (options.help) { process.stdout.write(USAGE) return } const identityFile = path.resolve(process.env.BUILD_SERVER_IDENTITY_FILE || path.join(os.homedir(), '.ssh', 'gongxiangp2_build_machine')) const knownHostsSource = path.resolve(process.env.BUILD_SERVER_KNOWN_HOSTS_FILE || path.join(os.homedir(), '.ssh', 'known_hosts')) const productionKnownHostsFile = process.env.PROD_SERVER_KNOWN_HOSTS_FILE || PRODUCTION_KNOWN_HOSTS_DEFAULT const uploadsMount = process.env.GONGXIANGP2_UPLOADS_MOUNT || DEFAULT_UPLOADS_MOUNT if (!TRUSTED_UPLOADS_MOUNTS.has(uploadsMount)) throw new Error(`不可信的生产 uploads 挂载:${uploadsMount}`) await validateIdentityFile(identityFile) const temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'gongxiangp2-macos-deploy-')) try { const { pinnedFile, fingerprint: buildFingerprint } = await createPinnedBuildKnownHosts(knownHostsSource, temporaryRoot) const sshCommonArgs = sshArguments(identityFile, pinnedFile) const productionFingerprint = process.env.PROD_SERVER_HOST_KEY || resolveProductionFingerprint(sshCommonArgs, productionKnownHostsFile) const targetCommit = await resolveAndScanExactCommit(options) const powerShellSource = await fs.readFile(POWERSHELL_DEPLOY_SCRIPT, 'utf8') const proxyReconcilerBase64 = (await fs.readFile(PROXY_RECONCILER)).toString('base64') const values = { __REPO_URL__: TRUSTED_REPOSITORY, __BRANCH__: TRUSTED_BRANCH, __COMMIT__: targetCommit, __REMOTE_BUILD_DIR__: BUILD_DIRECTORY, __TARGET__: 'production', __BUILD_SERVER_IP__: BUILD_SERVER_IP, __PROD_SERVER_IP__: PRODUCTION_SERVER_IP, __PROD_SERVER_USER__: PRODUCTION_SERVER_USER, __PROD_SERVER_HOST_KEY__: productionFingerprint, __PROD_KNOWN_HOSTS_FILE__: productionKnownHostsFile, __REGISTRY_PORT__: '5000', __IMAGE_NAME__: 'youyidian-3d-material', __UPLOADS_MOUNT__: uploadsMount, __MIGRATION_AUDIT_FILE__: process.env.GONGXIANGP2_MIGRATION_AUDIT_FILE || '', __MIGRATION_AUDIT_SHA256__: process.env.GONGXIANGP2_MIGRATION_AUDIT_SHA256 || '', __MIGRATION_AUDIT_SOURCE_COMMIT__: process.env.GONGXIANGP2_MIGRATION_AUDIT_SOURCE_COMMIT || (options.requireMigrationAudit ? targetCommit : ''), __REQUIRE_MIGRATION_AUDIT__: options.requireMigrationAudit ? '1' : '0', __CONTAINER_NAME__: 'youyidian-3d-material', __PORT__: '4320', __ENV_FILE__: '.env', __PUBLIC_APP_URL__: 'https://sc.jiansun.vip', __CORS_ALLOWED_ORIGINS__: 'https://sc.jiansun.vip,http://192.168.58.20:4320', __DINGTALK_CALLBACK_ORIGIN__: 'https://sc.jiansun.vip', __DINGTALK_INTERNAL_RETURN_ORIGINS__: 'http://192.168.58.20:4320', __S3_PUBLIC_DOMAIN__: 'https://s3.jiansun.vip/gongxiangpan', __DOWNLOAD_TOKEN_SECRET__: process.env.DOWNLOAD_TOKEN_SECRET || '', __PROXY_RECONCILE_SCRIPT_B64__: proxyReconcilerBase64, __DRY_RUN__: options.dryRun ? '1' : '0', __NO_CACHE__: options.noCache ? '1' : '0', __SKIP_BUILD__: '0', __SKIP_PUSH__: '0', __REUSE_VERIFIED_IMAGE__: options.reuseVerifiedImage ? '1' : '0', __SKIP_DEPLOY__: options.skipDeploy ? '1' : '0', __KEEP_OLD_IMAGES__: options.keepOldImages ? '1' : '0', __AGGRESSIVE_CLEANUP__: options.aggressiveCleanup ? '1' : '0', __ROLLBACK_ON_FAILURE__: options.noRollback ? '0' : '1', __DEPLOYMENT_STRATEGY__: 'direct', } const payload = renderRemoteTemplate(extractRemoteTemplate(powerShellSource), values) const syntaxCheck = spawnSync('bash', ['-n'], { input: payload, encoding: 'utf8' }) if (syntaxCheck.status !== 0) throw new Error(`生成的远端脚本语法无效:${syntaxCheck.stderr}`) process.stdout.write(`[INFO] Mac deployment entry\n[INFO] Commit: ${targetCommit}\n[INFO] Build host key: ${buildFingerprint}\n`) if (options.dryRun) process.stdout.write('[WARN] DryRun:不会修改代码、镜像或生产容器\n') await executeOnBuildServer(payload, sshCommonArgs, temporaryRoot) process.stdout.write('[OK] Git 增量部署流程完成\n') } finally { await fs.rm(temporaryRoot, { recursive: true, force: true }) } } const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : '' if (invokedPath === fileURLToPath(import.meta.url)) { main().catch((error) => { process.stderr.write(`[ERR] ${error instanceof Error ? error.message : String(error)}\n`) process.exitCode = 1 }) }