deploy.js 5.16 KB
#!/usr/bin/env node
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'

import { setGodaddyARecord } from './godaddy-dns.js'
import { renderVisualGameHttpConfig, renderVisualGameHttpsConfig } from './visual-game-nginx.js'

const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')

const config = readDeploymentConfig(process.argv)
const pat = readGodaddyPat(config.dns.credential_file)

for (const domain of config.domains) {
  const result = await setGodaddyARecord({
    pat,
    zone: config.dns.zone,
    name: domain.record_name,
    address: config.public_ip,
    ttl: config.dns.ttl,
  })
  console.log(`DNS ${result.action}: ${result.hostname} -> ${result.address}`)
}

prepareRemoteDirectories(config)
installNginxConfig(config, renderVisualGameHttpConfig(config))
issueCertificate(config)
installNginxConfig(config, renderVisualGameHttpsConfig(config))
await verifyDeployment(config)

console.log('域名部署完成')
for (const domain of config.domains) console.log(`${domain.role}: https://${domain.hostname}`)

function readDeploymentConfig(argv) {
  const configPath = path.resolve(repositoryRoot, argv[2])
  return JSON.parse(fs.readFileSync(configPath, 'utf8'))
}

function readGodaddyPat(credentialFile) {
  const filePath = path.resolve(repositoryRoot, credentialFile)
  const content = fs.readFileSync(filePath, 'utf8').trim()
  return content.slice('GODADDY_PAT='.length)
}

function prepareRemoteDirectories(deployment) {
  runRemote(deployment, 'sudo', ['mkdir', '-p', deployment.nginx.acme_webroot])
  runRemote(deployment, 'sudo', ['mkdir', '-p', deployment.nginx.certificate_directory])
  if (deployment.nginx.layout === 'sites') {
    runRemote(deployment, 'sudo', ['mkdir', '-p', '/etc/nginx/sites-available'])
    runRemote(deployment, 'sudo', ['mkdir', '-p', '/etc/nginx/sites-enabled'])
  }
}

function installNginxConfig(deployment, content) {
  const upload = `/tmp/${deployment.nginx.site_name}`
  runRemote(deployment, 'tee', [upload], content)
  installNginxSite(deployment, upload)
  runRemote(deployment, 'sudo', ['nginx', '-t'])
  runRemote(deployment, 'sudo', ['systemctl', 'reload', 'nginx'])
  runRemote(deployment, 'rm', [upload])
}

function installNginxSite(deployment, upload) {
  switch (deployment.nginx.layout) {
    case 'sites': {
      const available = `/etc/nginx/sites-available/${deployment.nginx.site_name}`
      const enabled = `/etc/nginx/sites-enabled/${deployment.nginx.site_name}`
      runRemote(deployment, 'sudo', ['install', '-m', '644', upload, available])
      runRemote(deployment, 'sudo', ['ln', '-sfn', available, enabled])
      return
    }
    case 'conf.d':
      runRemote(deployment, 'sudo', [
        'install',
        '-m',
        '644',
        upload,
        `/etc/nginx/conf.d/${deployment.nginx.site_name}.conf`,
      ])
      return
    default:
      throw new Error(`不支持的 nginx layout:${deployment.nginx.layout}`)
  }
}

function issueCertificate(deployment) {
  const domains = []
  for (const domain of deployment.domains) domains.push('-d', domain.hostname)
  runRemote(deployment, 'sudo', [
    '/root/.acme.sh/acme.sh',
    '--issue',
    '--server',
    'letsencrypt',
    '--webroot',
    deployment.nginx.acme_webroot,
    ...domains,
  ])
  runRemote(deployment, 'sudo', [
    '/root/.acme.sh/acme.sh',
    '--install-cert',
    '-d',
    deployment.domains[0].hostname,
    '--key-file',
    `${deployment.nginx.certificate_directory}/key.pem`,
    '--fullchain-file',
    `${deployment.nginx.certificate_directory}/fullchain.pem`,
    '--reloadcmd',
    'systemctl reload nginx',
  ])
}

async function verifyDeployment(deployment) {
  for (const domain of deployment.domains) {
    const response = await fetch(`https://${domain.hostname}/`, { redirect: 'manual' })
    if (response.status !== 200) throw new Error(`${domain.hostname} 首页返回 ${response.status}`)
    console.log(`HTTPS 200: ${domain.hostname}`)
  }
  const health = await fetch(`https://${deployment.domains[0].hostname}/healthz`)
  if (health.status !== 200) throw new Error(`${deployment.domains[0].hostname}/healthz 返回 ${health.status}`)
  console.log(`API 200: ${deployment.domains[0].hostname}/healthz`)
}

function runRemote(deployment, command, args, input) {
  const identityFile = expandHome(deployment.ssh.identity_file)
  const destination = `${deployment.ssh.user}@${deployment.ssh.host}`
  const remoteCommand = [command, ...args].map(quoteRemoteArgument).join(' ')
  const result = spawnSync('ssh', [
    '-i', identityFile,
    '-o', 'IdentitiesOnly=yes',
    '-o', 'StrictHostKeyChecking=yes',
    destination,
    remoteCommand,
  ], {
    cwd: repositoryRoot,
    encoding: 'utf8',
    input,
    maxBuffer: 10 * 1024 * 1024,
  })
  if (result.status !== 0) {
    throw new Error(`远程命令失败: ${command} ${args.join(' ')}\n${result.stdout}\n${result.stderr}`)
  }
  return result.stdout.trim()
}

function quoteRemoteArgument(value) {
  return `'${value.replaceAll("'", "'\\''")}'`
}

function expandHome(filePath) {
  if (!filePath.startsWith('~/')) return filePath
  return path.join(os.homedir(), filePath.slice(2))
}