hypertwist/website/scripts/run-vps-same-origin-live-deploy-lib.mjs
2026-06-22 08:07:39 +00:00

299 lines
9 KiB
JavaScript

import fs from 'node:fs'
import path from 'node:path'
import posixPath from 'node:path/posix'
import { buildSshArgs, sanitizeStagingProofManifest } from './run-vps-same-origin-staging-proof-lib.mjs'
function normalizeTrimmed(value) {
return String(value || '').trim()
}
function normalizeBoolean(value, fallback = false) {
if (typeof value === 'boolean') {
return value
}
const normalized = normalizeTrimmed(value).toLowerCase()
if (!normalized) {
return fallback
}
return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on'
}
function requireNonEmpty(value, label) {
const normalized = normalizeTrimmed(value)
if (!normalized) {
throw new Error(`${label} is required.`)
}
return normalized
}
function requirePositiveInteger(value, label) {
const parsed = Number.parseInt(String(value || ''), 10)
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`${label} must be a positive integer.`)
}
return parsed
}
function normalizeArchiveSource(value, fallback = 'git-head') {
const normalized = normalizeTrimmed(value).toLowerCase()
if (normalized === 'worktree') {
return 'worktree'
}
if (normalized === 'git' || normalized === 'head' || normalized === 'git-head') {
return 'git-head'
}
return fallback
}
export function parseLiveDeployArgs(argv) {
const options = {
manifest: '',
identityFile: '',
vpsHost: '212.227.13.220',
vpsUser: 'root',
archiveSource: 'git-head',
bundleDir: '',
healthUrl: '',
waitSeconds: '30',
keepBundleDir: false,
skipLiveValidation: false,
json: false,
dryRun: false,
}
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index]
const next = argv[index + 1] || ''
switch (argument) {
case '--manifest':
options.manifest = next
index += 1
break
case '--identity-file':
options.identityFile = next
index += 1
break
case '--vps-host':
options.vpsHost = next
index += 1
break
case '--vps-user':
options.vpsUser = next
index += 1
break
case '--archive-source':
options.archiveSource = next
index += 1
break
case '--bundle-dir':
options.bundleDir = next
index += 1
break
case '--health-url':
options.healthUrl = next
index += 1
break
case '--wait-seconds':
options.waitSeconds = next
index += 1
break
case '--keep-bundle-dir':
options.keepBundleDir = true
break
case '--skip-live-validation':
options.skipLiveValidation = true
break
case '--json':
options.json = true
break
case '--dry-run':
options.dryRun = true
break
default:
break
}
}
return options
}
export function loadLiveDeployManifest(filePath) {
const absolutePath = path.resolve(filePath)
const raw = fs.readFileSync(absolutePath, 'utf8')
return {
path: absolutePath,
manifest: JSON.parse(raw),
}
}
export function buildDefaultLiveDeployBundleDir(gitRef = 'head') {
const normalized = normalizeTrimmed(gitRef).replace(/[^a-zA-Z0-9._-]/g, '-')
return `/tmp/hypertwist-live-deploy-${normalized || 'head'}`
}
export function resolveLiveDeployOptions({
manifestPath,
identityFile,
vpsHost,
vpsUser,
archiveSource,
bundleDir,
healthUrl,
waitSeconds,
keepBundleDir,
skipLiveValidation,
json,
dryRun,
manifest,
gitRef,
}) {
const checkoutRoot = requireNonEmpty(manifest?.checkoutRoot || '/srv/hypertwist/current', 'manifest.checkoutRoot')
const publicOrigin = requireNonEmpty(manifest?.publicOrigin || '', 'manifest.publicOrigin')
return {
manifestPath: path.resolve(requireNonEmpty(manifestPath, 'manifestPath')),
identityFile: path.resolve(requireNonEmpty(identityFile, 'identityFile')),
vpsHost: requireNonEmpty(vpsHost || '212.227.13.220', 'vpsHost'),
vpsUser: requireNonEmpty(vpsUser || 'root', 'vpsUser'),
archiveSource: normalizeArchiveSource(archiveSource, 'git-head'),
bundleDir: normalizeTrimmed(bundleDir) || buildDefaultLiveDeployBundleDir(gitRef),
checkoutRoot,
healthUrl: normalizeTrimmed(healthUrl) || publicOrigin,
waitSeconds: requirePositiveInteger(waitSeconds || '30', 'waitSeconds'),
keepBundleDir: normalizeBoolean(keepBundleDir, false),
skipLiveValidation: normalizeBoolean(skipLiveValidation, false),
json: normalizeBoolean(json, false),
dryRun: normalizeBoolean(dryRun, false),
}
}
export function buildStageCheckoutRemoteCommand(checkoutRoot) {
const parentDir = posixPath.dirname(checkoutRoot)
return `rm -rf ${JSON.stringify(checkoutRoot)} && mkdir -p ${JSON.stringify(parentDir)} ${JSON.stringify(checkoutRoot)} && tar -xf - -C ${JSON.stringify(checkoutRoot)}`
}
export function buildStageBundleRemoteCommand(bundleDir) {
return `rm -rf ${JSON.stringify(bundleDir)} && mkdir -p ${JSON.stringify(bundleDir)} && tar -xf - -C ${JSON.stringify(bundleDir)}`
}
export function sanitizeLiveDeployManifest(manifest) {
return sanitizeStagingProofManifest(manifest)
}
export function buildRemoteLiveDeployScript({
checkoutRoot,
bundleDir,
keepBundleDir,
manifest,
}) {
const serviceUser = normalizeTrimmed(manifest?.serviceUser || 'hypertwist')
const serviceGroup = normalizeTrimmed(manifest?.serviceGroup || serviceUser)
const billingStatePath = normalizeTrimmed(manifest?.server?.billingStatePath || '/var/lib/hypertwist/auth/hypertwist-billing-state.json')
const billingStateDir = posixPath.dirname(billingStatePath)
return `set -euo pipefail
CHECKOUT_ROOT=${JSON.stringify(checkoutRoot)}
BUNDLE_DIR=${JSON.stringify(bundleDir)}
SERVICE_USER=${JSON.stringify(serviceUser)}
SERVICE_GROUP=${JSON.stringify(serviceGroup)}
BILLING_STATE_DIR=${JSON.stringify(billingStateDir)}
KEEP_BUNDLE_DIR=${keepBundleDir ? '1' : '0'}
SERVICE_NAME="hypertwist-website-auth-server.service"
NGINX_CONF_NAME="hypertwist-app.conf"
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
CHECKOUT_PARENT=$(dirname "$CHECKOUT_ROOT")
WEBSITE_DIR="$CHECKOUT_ROOT/website"
SERVER_DIR="$WEBSITE_DIR/server"
NGINX_BACKUP=""
PLACEHOLDER_BACKUP=""
cleanup() {
if [ "$KEEP_BUNDLE_DIR" = "1" ]; then
return
fi
rm -rf "$BUNDLE_DIR"
}
trap cleanup EXIT
if ! getent group "$SERVICE_GROUP" >/dev/null 2>&1; then
groupadd --system "$SERVICE_GROUP"
fi
if ! id "$SERVICE_USER" >/dev/null 2>&1; then
useradd --system --create-home --home-dir "$CHECKOUT_PARENT" --shell /usr/sbin/nologin --gid "$SERVICE_GROUP" "$SERVICE_USER"
fi
install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$CHECKOUT_PARENT" "$CHECKOUT_ROOT" "$(dirname "$BILLING_STATE_DIR")" "$BILLING_STATE_DIR"
cp "$BUNDLE_DIR/website.env" "$WEBSITE_DIR/.env"
cp "$BUNDLE_DIR/server.env" "$SERVER_DIR/.env"
chown -R "$SERVICE_USER:$SERVICE_GROUP" "$CHECKOUT_ROOT" "$(dirname "$BILLING_STATE_DIR")"
runuser -u "$SERVICE_USER" -- bash -lc "cd $(printf '%q' "$WEBSITE_DIR") && npm ci && npm --prefix server ci && npm run build"
systemd-analyze verify "$BUNDLE_DIR/hypertwist-website-auth-server.service"
if [ -f "/etc/nginx/sites-available/$NGINX_CONF_NAME" ]; then
NGINX_BACKUP="/etc/nginx/sites-available/$NGINX_CONF_NAME.bak-$STAMP"
cp "/etc/nginx/sites-available/$NGINX_CONF_NAME" "$NGINX_BACKUP"
fi
if [ -f "/var/www/hypertwist/index.html" ]; then
PLACEHOLDER_BACKUP="/var/www/hypertwist/index.html.pre-same-origin-$STAMP"
cp "/var/www/hypertwist/index.html" "$PLACEHOLDER_BACKUP"
fi
cp "$BUNDLE_DIR/hypertwist-website-auth-server.service" "/etc/systemd/system/$SERVICE_NAME"
cp "$BUNDLE_DIR/hypertwist.app.conf" "/etc/nginx/sites-available/$NGINX_CONF_NAME"
ln -sf "/etc/nginx/sites-available/$NGINX_CONF_NAME" "/etc/nginx/sites-enabled/$NGINX_CONF_NAME"
systemctl daemon-reload
systemctl enable "$SERVICE_NAME"
systemctl restart "$SERVICE_NAME"
nginx -t
systemctl reload nginx
SERVICE_STATUS=$(systemctl is-active "$SERVICE_NAME")
NGINX_STATUS=$(systemctl is-active nginx)
node - "$CHECKOUT_ROOT" "$BUNDLE_DIR" "$SERVICE_STATUS" "$NGINX_STATUS" "$NGINX_BACKUP" "$PLACEHOLDER_BACKUP" <<'__HYPERTWIST_LIVE_DEPLOY_SUMMARY__'
const checkoutRoot = process.argv[2]
const bundleDir = process.argv[3]
const serviceStatus = process.argv[4]
const nginxStatus = process.argv[5]
const nginxBackup = process.argv[6]
const placeholderBackup = process.argv[7]
const summary = {
ok: serviceStatus === 'active' && nginxStatus === 'active',
checkoutRoot,
bundleDir,
serviceStatus,
nginxStatus,
nginxBackup: nginxBackup || null,
placeholderBackup: placeholderBackup || null,
}
process.stdout.write(\`__HYPERTWIST_LIVE_DEPLOY_SUMMARY__\${JSON.stringify(summary)}\\n\`)
if (!summary.ok) {
process.exit(1)
}
__HYPERTWIST_LIVE_DEPLOY_SUMMARY__
`
}
export function extractLiveDeploySummary(output) {
const marker = '__HYPERTWIST_LIVE_DEPLOY_SUMMARY__'
const lines = String(output || '').split('\n')
const summaryLine = [...lines].reverse().find((line) => line.startsWith(marker))
if (!summaryLine) {
return null
}
return JSON.parse(summaryLine.slice(marker.length))
}
export { buildSshArgs }