394 lines
18 KiB
JavaScript
Executable File
394 lines
18 KiB
JavaScript
Executable File
#!/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
|
||
})
|
||
}
|