mirror of
https://github.com/nocobase/nocobase.git
synced 2026-10-13 07:47:55 +08:00
450 lines
14 KiB
JavaScript
450 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
|
// Installs, checks, upgrades and rolls back an application with app-installer from a deployment archive built by
|
|
// `pnpm build --tar`. CI runs it with the installer from the checkout;
|
|
// `pnpm unreleased:installer-smoke` runs it against the unreleased snapshot. Keeping one script keeps the two from
|
|
// drifting apart.
|
|
|
|
import { spawnSync } from 'node:child_process';
|
|
import fs from 'node:fs';
|
|
import { createRequire } from 'node:module';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
const usage = `Usage: node scripts/smoke-app-installer.mjs --root DIR --archive FILE
|
|
[--port 13000] [-- INSTALLER...]
|
|
|
|
Runs install, status, upgrade and rollback against a new installation at DIR from the
|
|
deployment archive FILE, built by \`pnpm build --tar\` for this machine. INSTALLER is the
|
|
command that runs app-installer, "node packages/tools/app-installer/bin/run.js" by default. pm2 must be
|
|
on PATH; set PM2_HOME to keep the test away from your own pm2 processes.`;
|
|
|
|
export function parseArgs(argv) {
|
|
const separator = argv.indexOf('--');
|
|
const own = separator === -1 ? argv : argv.slice(0, separator);
|
|
const installer =
|
|
separator === -1
|
|
? ['node', 'packages/tools/app-installer/bin/run.js']
|
|
: argv.slice(separator + 1);
|
|
const options = { port: 13000, installer };
|
|
for (let i = 0; i < own.length; i++) {
|
|
const key = own[i];
|
|
const value = own[i + 1];
|
|
if (
|
|
!['--root', '--port', '--archive'].includes(key) ||
|
|
!value ||
|
|
value.startsWith('--')
|
|
)
|
|
throw new Error(`Invalid option: ${key}\n\n${usage}`);
|
|
options[key.slice(2)] = value;
|
|
i++;
|
|
}
|
|
if (!options.root) throw new Error(`--root is required.\n\n${usage}`);
|
|
if (!options.archive) throw new Error(`--archive is required.\n\n${usage}`);
|
|
options.root = path.resolve(options.root);
|
|
options.archive = path.resolve(options.archive);
|
|
options.port = Number(options.port);
|
|
if (
|
|
!Number.isInteger(options.port) ||
|
|
options.port < 1 ||
|
|
options.port > 65535
|
|
)
|
|
throw new Error('Invalid --port.');
|
|
if (installer.length === 0)
|
|
throw new Error('The installer command is empty.');
|
|
return options;
|
|
}
|
|
|
|
/** A release id as app-installer forms it: the version and the UTC build time. */
|
|
export function releaseIdOf(version, builtAt) {
|
|
const compact = new Date(builtAt)
|
|
.toISOString()
|
|
.replace(/\.\d+Z$/u, 'Z')
|
|
.replaceAll('-', '')
|
|
.replaceAll(':', '');
|
|
return `${version}_${compact}`;
|
|
}
|
|
|
|
const releasePath = (root, id) => path.join(root, 'releases', id, 'app');
|
|
|
|
function readState(root) {
|
|
return JSON.parse(fs.readFileSync(path.join(root, 'installer.json'), 'utf8'));
|
|
}
|
|
|
|
function writeState(root, state) {
|
|
fs.writeFileSync(
|
|
path.join(root, 'installer.json'),
|
|
`${JSON.stringify(state, null, 2)}\n`,
|
|
);
|
|
}
|
|
|
|
function breakServerEntry(deploymentRoot) {
|
|
const entry = path.join(deploymentRoot, 'dist', 'server', 'standalone.js');
|
|
fs.writeFileSync(
|
|
entry,
|
|
`throw new Error('smoke test: this release fails to start');\n${fs.readFileSync(entry, 'utf8')}`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Writes a copy of a deployment archive as a later build: its manifest gets `builtAt`, and with `broken` its server
|
|
* entry throws on start. The system `tar` unpacks and packs it, which keeps symbolic links and modes as they were.
|
|
*/
|
|
export function repackArchive(source, target, { builtAt, broken = false }) {
|
|
const tree = fs.mkdtempSync(path.join(os.tmpdir(), 'app-installer-smoke-'));
|
|
try {
|
|
const untar = spawnSync('tar', ['-xzf', source, '-C', tree], {
|
|
stdio: 'inherit',
|
|
});
|
|
if (untar.status !== 0) fail(`Could not unpack ${source}.`);
|
|
const manifestFile = path.join(tree, 'dist', 'package.json');
|
|
const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
|
|
manifest.nocobase = { ...manifest.nocobase, builtAt };
|
|
fs.writeFileSync(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
if (broken) breakServerEntry(tree);
|
|
const pack = spawnSync(
|
|
'tar',
|
|
['-czf', target, '-C', tree, 'config.example.yml', 'dist'],
|
|
{ stdio: 'inherit' },
|
|
);
|
|
if (pack.status !== 0) fail(`Could not pack ${target}.`);
|
|
return releaseIdOf(manifest.version, builtAt);
|
|
} finally {
|
|
fs.rmSync(tree, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Records the last upgrade as having migrated, so the rollback after it restores the database. Upgrading to a copy of
|
|
* the same release applies nothing, and without this the restore path would never run.
|
|
*/
|
|
export function markLastUpgradeMigrated(root) {
|
|
const state = readState(root);
|
|
const last = state.history.findLast((entry) => entry.action === 'upgrade');
|
|
if (!last) throw new Error('installer.json records no upgrade.');
|
|
last.migrations = 1;
|
|
writeState(root, state);
|
|
}
|
|
|
|
/** The application's SQLite database: the one file a fresh application has. */
|
|
export function databaseFile(root) {
|
|
const found = fs
|
|
.readdirSync(path.join(root, 'storage'), {
|
|
recursive: true,
|
|
withFileTypes: true,
|
|
})
|
|
.filter((entry) => entry.isFile() && entry.name.endsWith('.sqlite'));
|
|
if (found.length !== 1)
|
|
fail(
|
|
`Expected one SQLite database under ${path.join(root, 'storage')}, found ${found.length}.`,
|
|
);
|
|
return path.join(found[0].parentPath, found[0].name);
|
|
}
|
|
|
|
/** Opens the application's SQLite database with the better-sqlite3 a release ships. */
|
|
function openDatabase(root, releaseId, options = {}) {
|
|
const require = createRequire(
|
|
path.join(releasePath(root, releaseId), 'dist', 'package.json'),
|
|
);
|
|
const Database = require('better-sqlite3');
|
|
const database = new Database(databaseFile(root), options);
|
|
database.pragma('busy_timeout = 5000');
|
|
return database;
|
|
}
|
|
|
|
const MARKER_TABLE = 'app_installer_smoke_marker';
|
|
|
|
export function writeMarker(root, releaseId) {
|
|
const database = openDatabase(root, releaseId);
|
|
try {
|
|
database.exec(`CREATE TABLE IF NOT EXISTS ${MARKER_TABLE} (id INTEGER)`);
|
|
} finally {
|
|
database.close();
|
|
}
|
|
}
|
|
|
|
export function hasMarker(root, releaseId) {
|
|
const database = openDatabase(root, releaseId, { readonly: true });
|
|
try {
|
|
return Boolean(
|
|
database
|
|
.prepare(
|
|
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
|
|
)
|
|
.get(MARKER_TABLE),
|
|
);
|
|
} finally {
|
|
database.close();
|
|
}
|
|
}
|
|
|
|
/** A running process's command line: from /proc on Linux, from ps elsewhere. */
|
|
export function processCommandLine(pid) {
|
|
try {
|
|
return fs
|
|
.readFileSync(`/proc/${pid}/cmdline`, 'utf8')
|
|
.split('\0')
|
|
.filter(Boolean)
|
|
.join(' ');
|
|
} catch {
|
|
return spawnSync('ps', ['-o', 'args=', '-p', String(pid)], {
|
|
encoding: 'utf8',
|
|
}).stdout.trim();
|
|
}
|
|
}
|
|
|
|
function fail(message) {
|
|
throw new Error(message);
|
|
}
|
|
|
|
function assert(condition, message) {
|
|
if (!condition) fail(message);
|
|
}
|
|
|
|
/** Runs the installer, reads its envelope, and checks what is running. */
|
|
function createHarness({ root, installer, env }) {
|
|
const run = (args) => {
|
|
const result = spawnSync(
|
|
installer[0],
|
|
[...installer.slice(1), ...args, '--json'],
|
|
{
|
|
env,
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'inherit'],
|
|
maxBuffer: 16 * 1024 * 1024,
|
|
},
|
|
);
|
|
if (result.error)
|
|
fail(`app-installer ${args[0]} did not run: ${result.error.message}`);
|
|
let envelope;
|
|
try {
|
|
envelope = JSON.parse(result.stdout);
|
|
} catch {
|
|
fail(
|
|
`app-installer ${args[0]} printed no JSON result (exit ${result.status}).`,
|
|
);
|
|
}
|
|
return { exitCode: result.status, envelope };
|
|
};
|
|
const installerOk = (args) => {
|
|
const { envelope } = run(args);
|
|
if (!envelope.ok)
|
|
fail(
|
|
`app-installer ${args[0]} failed with ${envelope.error.code}: ${envelope.error.message}`,
|
|
);
|
|
return envelope.result;
|
|
};
|
|
const pm2 = (args) => {
|
|
const result = spawnSync('pm2', args, { env, stdio: 'ignore' });
|
|
if (result.error || result.status !== 0)
|
|
fail(`pm2 ${args.join(' ')} failed.`);
|
|
};
|
|
const checkStatus = (expectedCurrent) => {
|
|
const status = installerOk(['status', '--dir', root]);
|
|
assert(
|
|
status.health.ok,
|
|
`The application does not answer ${status.health.url}.`,
|
|
);
|
|
assert(
|
|
status.process?.status === 'online',
|
|
'The pm2 process is not online.',
|
|
);
|
|
// What installer.json says is not proof: the process pm2 watches must be running that release's server.
|
|
const entry = path.join(
|
|
'releases',
|
|
expectedCurrent,
|
|
'app',
|
|
'dist',
|
|
'server',
|
|
'standalone.js',
|
|
);
|
|
const commandLine = processCommandLine(status.process.pid);
|
|
assert(
|
|
commandLine.includes(entry),
|
|
`The pm2 process runs "${commandLine}", not ${entry}.`,
|
|
);
|
|
assert(
|
|
status.node.matches,
|
|
'The release was built for another Node major.',
|
|
);
|
|
assert(!status.pending, 'An operation is still recorded as pending.');
|
|
assert(
|
|
status.current === expectedCurrent,
|
|
`The installation runs ${status.current}, expected ${expectedCurrent}.`,
|
|
);
|
|
return status;
|
|
};
|
|
const waitUntilHealthy = (timeoutMs = 180_000) => {
|
|
const deadline = Date.now() + timeoutMs;
|
|
for (;;) {
|
|
const status = installerOk(['status', '--dir', root]);
|
|
if (status.health.ok) return;
|
|
if (Date.now() > deadline)
|
|
fail(
|
|
`The application did not answer ${status.health.url} after restarting.`,
|
|
);
|
|
spawnSync('sleep', ['2']);
|
|
}
|
|
};
|
|
const checkStorage = (releaseId) => {
|
|
assert(
|
|
!fs.existsSync(path.join(releasePath(root, releaseId), 'storage')),
|
|
'The release directory holds a storage/ directory; APP_STORAGE_DIR was not applied.',
|
|
);
|
|
databaseFile(root);
|
|
};
|
|
const expectRolledBack = (failed) =>
|
|
assert(
|
|
failed.exitCode === 3 &&
|
|
failed.envelope.error?.code === 'UPGRADE_ROLLED_BACK',
|
|
`a failed start should roll back with exit 3 and UPGRADE_ROLLED_BACK, got exit ${failed.exitCode} and ${failed.envelope.error?.code ?? 'no error'}.`,
|
|
);
|
|
return {
|
|
run,
|
|
installer: installerOk,
|
|
pm2,
|
|
checkStatus,
|
|
waitUntilHealthy,
|
|
checkStorage,
|
|
expectRolledBack,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Runs the whole check for a deployment archive. `archive` must be built for this machine; later builds are made from
|
|
* it by rewriting its build time, so every upgrade here is a new build of the same version. Throws on the first failed
|
|
* expectation, leaving the installation in place for inspection.
|
|
*/
|
|
export function runArchiveSmoke({
|
|
root,
|
|
port,
|
|
archive,
|
|
installer,
|
|
env = process.env,
|
|
log = console.error,
|
|
}) {
|
|
const smoke = createHarness({ root, installer, env });
|
|
const archives = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'app-installer-archives-'),
|
|
);
|
|
|
|
try {
|
|
log('== Install from the archive');
|
|
const installed = smoke.installer([
|
|
'install',
|
|
root,
|
|
'--archive',
|
|
archive,
|
|
'--port',
|
|
String(port),
|
|
'--origin',
|
|
`http://127.0.0.1:${port}`,
|
|
]);
|
|
assert(installed.started, 'install did not start the application.');
|
|
const first = installed.releaseId;
|
|
|
|
log('== Check the installed application');
|
|
const status = smoke.checkStatus(first);
|
|
assert(
|
|
status.source?.kind === 'archive',
|
|
'status does not report an archive installation.',
|
|
);
|
|
smoke.checkStorage(first);
|
|
|
|
// Later than the installed build, and than now, so the ids sort after it whatever the archive's own time was.
|
|
const base = Math.max(Date.now(), Date.parse(installed.builtAt));
|
|
const buildAt = (minutes) =>
|
|
new Date(base + minutes * 60_000).toISOString();
|
|
const nextArchive = path.join(archives, 'next.tar.gz');
|
|
const next = repackArchive(archive, nextArchive, { builtAt: buildAt(1) });
|
|
|
|
log('== Upgrade to a later build of the same version');
|
|
const upgraded = smoke.installer([
|
|
'upgrade',
|
|
'--dir',
|
|
root,
|
|
'--archive',
|
|
nextArchive,
|
|
'--yes',
|
|
]);
|
|
assert(
|
|
upgraded.upgraded && !upgraded.reused && upgraded.to === next,
|
|
`upgrade did not switch to ${next}.`,
|
|
);
|
|
assert(upgraded.from === first, `upgrade started from ${upgraded.from}.`);
|
|
assert(
|
|
fs.existsSync(path.join(root, upgraded.backup, 'backup.json')),
|
|
'upgrade took no database backup.',
|
|
);
|
|
smoke.checkStatus(next);
|
|
smoke.checkStorage(next);
|
|
|
|
log('== Roll back');
|
|
const rolledBack = smoke.installer(['rollback', '--dir', root, '--yes']);
|
|
assert(rolledBack.to === first, `rollback returned to ${rolledBack.to}.`);
|
|
smoke.checkStatus(first);
|
|
|
|
log('== Roll back an upgrade that migrated, restoring the database');
|
|
const again = smoke.installer([
|
|
'upgrade',
|
|
'--dir',
|
|
root,
|
|
'--archive',
|
|
nextArchive,
|
|
'--yes',
|
|
]);
|
|
assert(
|
|
again.upgraded && again.reused,
|
|
'the second upgrade did not reuse the release on disk.',
|
|
);
|
|
writeMarker(root, next);
|
|
markLastUpgradeMigrated(root);
|
|
const restored = smoke.installer(['rollback', '--dir', root, '--yes']);
|
|
assert(
|
|
restored.databaseRestored === true,
|
|
'rollback did not restore the database after an upgrade that migrated.',
|
|
);
|
|
assert(
|
|
!hasMarker(root, first),
|
|
'a table written after the upgrade survived the restore.',
|
|
);
|
|
smoke.checkStatus(first);
|
|
|
|
log('== Upgrade to a build that fails to start');
|
|
const brokenArchive = path.join(archives, 'broken.tar.gz');
|
|
repackArchive(archive, brokenArchive, {
|
|
builtAt: buildAt(2),
|
|
broken: true,
|
|
});
|
|
smoke.expectRolledBack(
|
|
smoke.run([
|
|
'upgrade',
|
|
'--dir',
|
|
root,
|
|
'--archive',
|
|
brokenArchive,
|
|
'--yes',
|
|
'--health-timeout',
|
|
'60',
|
|
]),
|
|
);
|
|
smoke.checkStatus(first);
|
|
|
|
return { root, version: installed.version };
|
|
} finally {
|
|
fs.rmSync(archives, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
try {
|
|
const options = parseArgs(process.argv.slice(2));
|
|
const result = runArchiveSmoke(options);
|
|
console.log(
|
|
`app-installer smoke test passed with ${result.version} at ${result.root}.`,
|
|
);
|
|
} catch (error) {
|
|
console.error(error.message);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|