299 lines
9 KiB
JavaScript
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 }
|