293 lines
8.8 KiB
JavaScript
293 lines
8.8 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import fs from 'node:fs'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
import { execFileSync, spawnSync } from 'node:child_process'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
import {
|
|
buildRuntimeReadinessReport,
|
|
fetchLiveAuthHealth,
|
|
fetchLiveReleaseManifest,
|
|
fetchLiveWebsiteShell,
|
|
formatRuntimeReadinessReport,
|
|
parseEnvFile,
|
|
} from './runtime-readiness-lib.mjs'
|
|
import { writeSameOriginBundle } from './render-same-origin-bundle-lib.mjs'
|
|
import {
|
|
buildDefaultLiveDeployBundleDir,
|
|
buildRemoteLiveDeployScript,
|
|
buildSshArgs,
|
|
buildStageBundleRemoteCommand,
|
|
buildStageCheckoutRemoteCommand,
|
|
extractLiveDeploySummary,
|
|
loadLiveDeployManifest,
|
|
parseLiveDeployArgs,
|
|
resolveLiveDeployOptions,
|
|
sanitizeLiveDeployManifest,
|
|
} from './run-vps-same-origin-live-deploy-lib.mjs'
|
|
|
|
const currentFile = fileURLToPath(import.meta.url)
|
|
const websiteRoot = path.resolve(path.dirname(currentFile), '..')
|
|
const repoRoot = path.resolve(websiteRoot, '..')
|
|
|
|
function runCommand(command, args, options = {}) {
|
|
const result = spawnSync(command, args, {
|
|
encoding: 'utf8',
|
|
maxBuffer: 64 * 1024 * 1024,
|
|
...options,
|
|
})
|
|
|
|
if (result.error) {
|
|
throw result.error
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
function buildArchiveBuffer(repoRootPath, archiveSource) {
|
|
if (archiveSource === 'worktree') {
|
|
return execFileSync('tar', [
|
|
'--exclude=website/node_modules',
|
|
'--exclude=website/dist',
|
|
'--exclude=website/coverage',
|
|
'--exclude=website/server/node_modules',
|
|
'--exclude=website/server/dist',
|
|
'-cf',
|
|
'-',
|
|
'website',
|
|
], {
|
|
cwd: repoRootPath,
|
|
encoding: null,
|
|
maxBuffer: 64 * 1024 * 1024,
|
|
})
|
|
}
|
|
|
|
return execFileSync('git', ['archive', '--format=tar', 'HEAD', 'website'], {
|
|
cwd: repoRootPath,
|
|
encoding: null,
|
|
maxBuffer: 64 * 1024 * 1024,
|
|
})
|
|
}
|
|
|
|
function buildRenderedBundleArchive(renderedDir) {
|
|
return execFileSync('tar', ['-cf', '-', '-C', renderedDir, '.'], {
|
|
encoding: null,
|
|
maxBuffer: 64 * 1024 * 1024,
|
|
})
|
|
}
|
|
|
|
function sleep(ms) {
|
|
return new Promise((resolve) => {
|
|
setTimeout(resolve, ms)
|
|
})
|
|
}
|
|
|
|
async function waitForLiveAuthHealth(baseUrl, waitSeconds) {
|
|
let lastError = null
|
|
|
|
for (let attempt = 1; attempt <= waitSeconds; attempt += 1) {
|
|
try {
|
|
return await fetchLiveAuthHealth(baseUrl)
|
|
} catch (error) {
|
|
lastError = error
|
|
if (attempt < waitSeconds) {
|
|
await sleep(1000)
|
|
}
|
|
}
|
|
}
|
|
|
|
throw lastError || new Error(`Live auth health did not become ready within ${waitSeconds} seconds.`)
|
|
}
|
|
|
|
async function main() {
|
|
const rawArgs = parseLiveDeployArgs(process.argv.slice(2))
|
|
const gitRef = execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
|
|
cwd: repoRoot,
|
|
encoding: 'utf8',
|
|
}).trim()
|
|
|
|
const { path: manifestPath, manifest } = loadLiveDeployManifest(rawArgs.manifest)
|
|
const options = resolveLiveDeployOptions({
|
|
manifestPath,
|
|
identityFile: rawArgs.identityFile,
|
|
vpsHost: rawArgs.vpsHost,
|
|
vpsUser: rawArgs.vpsUser,
|
|
archiveSource: rawArgs.archiveSource,
|
|
bundleDir: rawArgs.bundleDir,
|
|
healthUrl: rawArgs.healthUrl,
|
|
waitSeconds: rawArgs.waitSeconds,
|
|
keepBundleDir: rawArgs.keepBundleDir,
|
|
skipLiveValidation: rawArgs.skipLiveValidation,
|
|
json: rawArgs.json,
|
|
dryRun: rawArgs.dryRun,
|
|
manifest,
|
|
gitRef,
|
|
})
|
|
|
|
const deployScript = buildRemoteLiveDeployScript({
|
|
checkoutRoot: options.checkoutRoot,
|
|
bundleDir: options.bundleDir,
|
|
keepBundleDir: options.keepBundleDir,
|
|
manifest,
|
|
})
|
|
const checkoutStageCommand = buildStageCheckoutRemoteCommand(options.checkoutRoot)
|
|
const bundleStageCommand = buildStageBundleRemoteCommand(options.bundleDir)
|
|
const sanitizedManifest = sanitizeLiveDeployManifest(manifest)
|
|
|
|
if (options.dryRun) {
|
|
const payload = {
|
|
repoRoot,
|
|
websiteRoot,
|
|
manifestPath,
|
|
vpsHost: options.vpsHost,
|
|
vpsUser: options.vpsUser,
|
|
archiveSource: options.archiveSource,
|
|
checkoutRoot: options.checkoutRoot,
|
|
bundleDir: options.bundleDir,
|
|
healthUrl: options.healthUrl,
|
|
waitSeconds: options.waitSeconds,
|
|
keepBundleDir: options.keepBundleDir,
|
|
skipLiveValidation: options.skipLiveValidation,
|
|
checkoutStageCommand,
|
|
bundleStageCommand,
|
|
manifest: sanitizedManifest,
|
|
deployScript,
|
|
defaultBundleDirForGitRef: buildDefaultLiveDeployBundleDir(gitRef),
|
|
}
|
|
|
|
if (options.json) {
|
|
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`)
|
|
} else {
|
|
process.stdout.write(`[live-deploy] manifest: ${manifestPath}\n`)
|
|
process.stdout.write(`[live-deploy] vps: ${options.vpsUser}@${options.vpsHost}\n`)
|
|
process.stdout.write(`[live-deploy] archive source: ${options.archiveSource}\n`)
|
|
process.stdout.write(`[live-deploy] checkout root: ${options.checkoutRoot}\n`)
|
|
process.stdout.write(`[live-deploy] bundle dir: ${options.bundleDir}\n`)
|
|
process.stdout.write(`[live-deploy] health url: ${options.healthUrl}\n`)
|
|
}
|
|
return
|
|
}
|
|
|
|
const localBundleDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hypertwist-live-deploy-'))
|
|
|
|
try {
|
|
const rendered = writeSameOriginBundle({
|
|
manifest,
|
|
outDir: localBundleDir,
|
|
})
|
|
const frontendEnv = parseEnvFile(rendered.frontendEnvContent)
|
|
const serverEnv = parseEnvFile(rendered.serverEnvContent)
|
|
const checkoutArchiveBuffer = buildArchiveBuffer(repoRoot, options.archiveSource)
|
|
const bundleArchiveBuffer = buildRenderedBundleArchive(localBundleDir)
|
|
|
|
const checkoutStageResult = runCommand('ssh', buildSshArgs({
|
|
identityFile: options.identityFile,
|
|
vpsUser: options.vpsUser,
|
|
vpsHost: options.vpsHost,
|
|
remoteCommand: checkoutStageCommand,
|
|
}), {
|
|
input: checkoutArchiveBuffer,
|
|
encoding: 'utf8',
|
|
})
|
|
|
|
if (checkoutStageResult.status !== 0) {
|
|
process.stderr.write(checkoutStageResult.stderr || '')
|
|
process.exit(checkoutStageResult.status || 1)
|
|
}
|
|
|
|
const bundleStageResult = runCommand('ssh', buildSshArgs({
|
|
identityFile: options.identityFile,
|
|
vpsUser: options.vpsUser,
|
|
vpsHost: options.vpsHost,
|
|
remoteCommand: bundleStageCommand,
|
|
}), {
|
|
input: bundleArchiveBuffer,
|
|
encoding: 'utf8',
|
|
})
|
|
|
|
if (bundleStageResult.status !== 0) {
|
|
process.stderr.write(bundleStageResult.stderr || '')
|
|
process.exit(bundleStageResult.status || 1)
|
|
}
|
|
|
|
const deployResult = runCommand('ssh', [
|
|
...buildSshArgs({
|
|
identityFile: options.identityFile,
|
|
vpsUser: options.vpsUser,
|
|
vpsHost: options.vpsHost,
|
|
}),
|
|
'bash',
|
|
'-s',
|
|
], {
|
|
input: deployScript,
|
|
})
|
|
|
|
process.stderr.write(deployResult.stderr || '')
|
|
const deploySummary = extractLiveDeploySummary(deployResult.stdout || '')
|
|
|
|
let liveHealth = null
|
|
let liveReleaseManifest = null
|
|
let liveWebsiteShell = null
|
|
const attemptedLiveValidation = !options.skipLiveValidation
|
|
|
|
if (!options.skipLiveValidation) {
|
|
liveHealth = await waitForLiveAuthHealth(options.healthUrl, options.waitSeconds)
|
|
liveReleaseManifest = await fetchLiveReleaseManifest(options.healthUrl)
|
|
liveWebsiteShell = await fetchLiveWebsiteShell(options.healthUrl)
|
|
}
|
|
|
|
const readinessReport = buildRuntimeReadinessReport({
|
|
frontendEnv,
|
|
serverEnv,
|
|
liveHealth,
|
|
liveReleaseManifest,
|
|
liveWebsiteShell,
|
|
liveHealthAttempted: attemptedLiveValidation,
|
|
liveReleaseManifestAttempted: attemptedLiveValidation,
|
|
liveWebsiteShellAttempted: attemptedLiveValidation,
|
|
frontendEnvPath: path.join(localBundleDir, 'website.env'),
|
|
serverEnvPath: path.join(localBundleDir, 'server.env'),
|
|
healthBaseUrl: options.healthUrl,
|
|
deploymentTier: manifest.deploymentTier || manifest?.server?.deploymentTier || '',
|
|
})
|
|
|
|
const payload = {
|
|
ok: deployResult.status === 0 && readinessReport.ok,
|
|
manifestPath,
|
|
vpsHost: options.vpsHost,
|
|
vpsUser: options.vpsUser,
|
|
archiveSource: options.archiveSource,
|
|
checkoutRoot: options.checkoutRoot,
|
|
bundleDir: options.bundleDir,
|
|
healthUrl: options.healthUrl,
|
|
deploySummary,
|
|
readinessReport,
|
|
liveHealth,
|
|
liveReleaseManifest,
|
|
liveWebsiteShell,
|
|
}
|
|
|
|
if (options.json) {
|
|
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`)
|
|
} else {
|
|
if (deploySummary) {
|
|
process.stdout.write(`[live-deploy] service status: ${deploySummary.serviceStatus}\n`)
|
|
process.stdout.write(`[live-deploy] nginx status: ${deploySummary.nginxStatus}\n`)
|
|
}
|
|
process.stdout.write(`${formatRuntimeReadinessReport(readinessReport)}\n`)
|
|
}
|
|
|
|
if (deployResult.status !== 0 || !readinessReport.ok) {
|
|
process.exit(deployResult.status || 1)
|
|
}
|
|
} finally {
|
|
fs.rmSync(localBundleDir, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
process.stderr.write(`${error.stack || error.message}\n`)
|
|
process.exit(1)
|
|
})
|