gongxiangp2/scripts/git-incremental-deploy-macos.mjs
2026-09-09 18:22:02 +08:00

394 lines
18 KiB
JavaScript
Executable File
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 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 <sha> 部署远端 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
})
}