mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-07 01:27:39 +08:00
783 lines
26 KiB
JavaScript
783 lines
26 KiB
JavaScript
// Run service tests only inside a disposable Linux container or CI job.
|
|
// Unlike source-tree tests, these load the published entrypoints and dependencies.
|
|
const fs = require('node:fs/promises');
|
|
const sync = require('node:fs');
|
|
const path = require('node:path');
|
|
const os = require('node:os');
|
|
const http = require('node:http');
|
|
const net = require('node:net');
|
|
const assert = require('node:assert/strict');
|
|
const { createHash, randomUUID } = require('node:crypto');
|
|
const { createRequire } = require('node:module');
|
|
const { execFile } = require('node:child_process');
|
|
const { promisify } = require('node:util');
|
|
const { setTimeout: delay } = require('node:timers/promises');
|
|
const execute = promisify(execFile);
|
|
const repository = path.resolve(__dirname, '..');
|
|
const hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
const read = async (file) => JSON.parse(await fs.readFile(file, 'utf8'));
|
|
const run = (program, args, options = {}) =>
|
|
execute(program, args, {
|
|
encoding: 'utf8',
|
|
maxBuffer: 8 * 1024 * 1024,
|
|
timeout: 120000,
|
|
...options,
|
|
});
|
|
const requiredFiles = [
|
|
'.env.example',
|
|
'back/protos/api.proto',
|
|
'ecosystem.config.js',
|
|
'version.yaml',
|
|
'shell/upgrade.cjs',
|
|
'shell/install-bundle.cjs',
|
|
'shell/install-package.cjs',
|
|
'shell/upgrade-lock.yaml',
|
|
'shell/runtime-guard.cjs',
|
|
'shell/node-runtime-probe.cjs',
|
|
'shell/start.sh',
|
|
'shell/start-simplify.sh',
|
|
'shell/task.sh',
|
|
'shell/preload/client.js',
|
|
'shell/preload/client.py',
|
|
'shell/preload/sitecustomize.js',
|
|
'shell/preload/sitecustomize.py',
|
|
'shell/preload/esm-loader.mjs',
|
|
'shell/preload/notificationAudit.js',
|
|
'sample/config.sample.sh',
|
|
'sample/auth.sample.json',
|
|
'static/build-info.json',
|
|
'static/build/app.js',
|
|
'static/build/token.js',
|
|
'static/build/bootstrap/upgradeProbe.js',
|
|
...[
|
|
'databaseClient',
|
|
'databaseErrors',
|
|
'databaseProcess',
|
|
'databaseWorker',
|
|
'repository',
|
|
'schema',
|
|
'queries',
|
|
'cacheStore',
|
|
'statistics',
|
|
].map((name) => `static/build/data/${name}.js`),
|
|
'static/dist/index.html',
|
|
...[
|
|
'upgradeArtifacts',
|
|
'platformBundle',
|
|
'upgradePrestart',
|
|
'panelUpgrade',
|
|
'nodeRuntime',
|
|
].map((name) => `cli/src/internal/maintenance/${name}.cjs`),
|
|
];
|
|
|
|
function checkPackedFiles(files) {
|
|
const names = new Set(files.map((file) => file.path));
|
|
for (const name of requiredFiles)
|
|
assert.ok(names.has(name), `Missing npm runtime: ${name}`);
|
|
for (const name of names) {
|
|
assert.ok(
|
|
/^(?:package\.json$|LICENSE$|README[^/]*\.md$|\.env\.example$|ecosystem\.config\.js$|version\.yaml$|back\/protos\/api\.proto$|cli\/(?:README[^/]*\.md$|src\/internal\/maintenance\/[^/]+\.cjs$)|sample\/|shell\/|static\/)/.test(
|
|
name,
|
|
),
|
|
`Unexpected npm file: ${name}`,
|
|
);
|
|
assert.ok(
|
|
!/^shell\/(?:pub\.sh$|preload\/(?:env\.|notify\.|__ql_notify__\.|lang_env\.sh|.*-notify\.json))/.test(
|
|
name,
|
|
),
|
|
`Generated or release-only runtime file: ${name}`,
|
|
);
|
|
assert.ok(
|
|
!/(?:^|\/)__pycache__\/|\.pyc$|(?:^|\/)\.DS_Store$/.test(name),
|
|
name,
|
|
);
|
|
assert.notEqual(name, 'sample/tool.ts');
|
|
}
|
|
}
|
|
|
|
async function packPanel(output) {
|
|
const git = (...args) => run('git', args, { cwd: repository });
|
|
assert.equal(
|
|
(await git('status', '--porcelain', '--untracked-files=all')).stdout.trim(),
|
|
'',
|
|
'Pack only from a clean source checkout',
|
|
);
|
|
await run(
|
|
process.execPath,
|
|
[path.join(repository, 'docker/verify-build.cjs')],
|
|
{ cwd: repository },
|
|
);
|
|
await fs.mkdir(output, { recursive: true });
|
|
const cache = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-npm-pack-cache-'));
|
|
try {
|
|
const result = await run(
|
|
'npm',
|
|
['pack', '--json', '--cache', cache, '--pack-destination', output],
|
|
{ cwd: repository },
|
|
);
|
|
const [manifest] = JSON.parse(result.stdout);
|
|
checkPackedFiles(manifest.files);
|
|
assert.equal(manifest.name, '@whyour/qinglong');
|
|
const build = await read(path.join(repository, 'static/build-info.json'));
|
|
const names = new Set(manifest.files.map((file) => file.path));
|
|
for (const name of Object.keys(build.files))
|
|
assert.ok(
|
|
names.has(`static/${name}`),
|
|
`npm filtered a verified build artifact: ${name}`,
|
|
);
|
|
const receipt = {
|
|
filename: manifest.filename,
|
|
sha256: hash(await fs.readFile(path.join(output, manifest.filename))),
|
|
sourceCommit: build.sourceCommit,
|
|
size: manifest.size,
|
|
fileCount: manifest.files.length,
|
|
};
|
|
await fs.writeFile(
|
|
path.join(output, 'package-receipt.json'),
|
|
JSON.stringify(receipt, null, 2),
|
|
);
|
|
console.log(JSON.stringify(receipt));
|
|
return receipt;
|
|
} finally {
|
|
await fs.rm(cache, { recursive: true, force: true });
|
|
// Also recover an interrupted npm pack whose postpack hook did not execute.
|
|
await fs.rm(path.join(repository, 'shell/upgrade-lock.yaml'), {
|
|
force: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function unusedPort() {
|
|
const server = net.createServer();
|
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
const port = server.address().port;
|
|
await new Promise((resolve) => server.close(resolve));
|
|
return port;
|
|
}
|
|
|
|
// Consume the unmodified output of Dockerfile.upgrade-bundle / buildBundle.
|
|
async function platformFixture(root, directory, expectedCommit) {
|
|
assert.ok(
|
|
directory,
|
|
'Set QL_PANEL_TEST_BUNDLE_DIR to the official platform bundle output',
|
|
);
|
|
const names = (await fs.readdir(directory)).filter((name) =>
|
|
name.endsWith('.tar.gz'),
|
|
);
|
|
assert.equal(names.length, 1, 'Expected one official platform package');
|
|
const archive = path.resolve(directory, names[0]);
|
|
const record = await read(archive + '.json');
|
|
const bytes = await fs.readFile(archive);
|
|
assert.equal(record.format, 2);
|
|
assert.equal(record.sourceCommit, expectedCommit);
|
|
assert.equal(record.filename, names[0]);
|
|
assert.equal(record.sha256, hash(bytes));
|
|
assert.equal(record.size, bytes.length);
|
|
assert.match(record.baseImage, /^ghcr\.io\/[^@]+@sha256:[a-f0-9]{64}$/);
|
|
const local = createRequire(path.join(root, 'package.json'));
|
|
const bundle = local('./cli/src/internal/maintenance/platformBundle.cjs');
|
|
assert.equal(record.target, bundle.targetKey(await bundle.runtimeTarget()));
|
|
return { ...record, archive };
|
|
}
|
|
|
|
async function legacyArchive(temp, env) {
|
|
const directory = path.join(temp, 'legacy');
|
|
await fs.mkdir(directory);
|
|
const result = await run(
|
|
'npm',
|
|
[
|
|
'pack',
|
|
'@whyour/qinglong@2.23.0',
|
|
'--ignore-scripts',
|
|
'--json',
|
|
'--registry=https://registry.npmjs.org',
|
|
'--pack-destination',
|
|
directory,
|
|
'--cache',
|
|
path.join(temp, 'cache'),
|
|
],
|
|
{ cwd: temp, env, timeout: 300000 },
|
|
);
|
|
const [record] = JSON.parse(result.stdout);
|
|
assert.equal(record.name, '@whyour/qinglong');
|
|
assert.equal(record.version, '2.23.0');
|
|
assert.equal(record.filename, 'whyour-qinglong-2.23.0.tgz');
|
|
const archive = path.join(directory, record.filename);
|
|
const integrity =
|
|
'sha512-' +
|
|
createHash('sha512')
|
|
.update(await fs.readFile(archive))
|
|
.digest('base64');
|
|
assert.equal(
|
|
integrity,
|
|
'sha512-x6B9s1/SRKkHe7jRQtb3LzMluJ3O3PAAssrr/5jaFMMl7DZvABSEiqkkZMu0r1BNtiCF72uj8OqXN9pCnF2WVw==',
|
|
'Published legacy package integrity changed',
|
|
);
|
|
return { archive, integrity, version: record.version };
|
|
}
|
|
|
|
async function verifyPanel(input, output) {
|
|
assert.equal(process.platform, 'linux', 'Service tests require Linux');
|
|
assert.equal(
|
|
process.env.QL_PANEL_PACKAGE_TEST_ISOLATED,
|
|
'1',
|
|
'Run only inside a disposable Linux container: QL_PANEL_PACKAGE_TEST_ISOLATED=1',
|
|
);
|
|
const stat = await fs.stat(input);
|
|
if (stat.isDirectory()) {
|
|
const names = (await fs.readdir(input)).filter((name) =>
|
|
name.endsWith('.tgz'),
|
|
);
|
|
assert.equal(names.length, 1, 'Expected one exact npm archive');
|
|
input = path.join(input, names[0]);
|
|
}
|
|
const expected = await read(
|
|
path.join(path.dirname(input), 'package-receipt.json'),
|
|
);
|
|
assert.equal(path.basename(input), expected.filename);
|
|
assert.equal(
|
|
hash(await fs.readFile(input)),
|
|
expected.sha256,
|
|
'npm archive differs from its build receipt',
|
|
);
|
|
assert.equal((await fs.stat(input)).size, expected.size);
|
|
assert.match(expected.sourceCommit, /^[a-f0-9]{40}$/);
|
|
if (process.env.GITHUB_SHA)
|
|
assert.equal(expected.sourceCommit, process.env.GITHUB_SHA);
|
|
const nodeSha = hash(await fs.readFile(process.execPath));
|
|
const pm2 = process.env.QL_PANEL_TEST_PM2;
|
|
assert.ok(
|
|
pm2 && path.isAbsolute(pm2),
|
|
'Set QL_PANEL_TEST_PM2 to an isolated PM2 executable',
|
|
);
|
|
const temp = await fs.realpath(
|
|
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-npm-install-')),
|
|
);
|
|
const home = path.join(temp, 'home'),
|
|
prefix = path.join(temp, 'prefix'),
|
|
data = path.join(temp, 'data');
|
|
await fs.mkdir(home);
|
|
await fs.mkdir(data);
|
|
const port = await unusedPort();
|
|
let grpcPort = await unusedPort();
|
|
while (grpcPort === port) grpcPort = await unusedPort();
|
|
const env = {
|
|
...process.env,
|
|
HOME: home,
|
|
PM2_HOME: path.join(temp, '.pm2'),
|
|
QL_DATA_DIR: data,
|
|
QL_BRANCH: 'develop',
|
|
NODE_ENV: 'production',
|
|
QL_CONTAINER: 'false',
|
|
BACK_PORT: String(port),
|
|
GRPC_PORT: String(grpcPort),
|
|
QlPort: String(port),
|
|
QlGrpcPort: String(grpcPort),
|
|
BIND_HOST: '127.0.0.1',
|
|
BIND_HOST_GRPC: '127.0.0.1',
|
|
NODE_PATH: '',
|
|
NODE_OPTIONS: '',
|
|
PATH: [path.join(prefix, 'bin'), path.dirname(pm2), process.env.PATH].join(
|
|
path.delimiter,
|
|
),
|
|
};
|
|
for (const key of [
|
|
'QL_CLI_ROOT',
|
|
'QL_DIR',
|
|
'QL_UPGRADE_FILE',
|
|
'QL_UPGRADE_REPAIR_DEPS',
|
|
'QL_UPGRADE_LAUNCHER_PID',
|
|
'QL_UPGRADE_INDEX_URL',
|
|
])
|
|
delete env[key];
|
|
let root, fixtureServer, token;
|
|
const api = async (route, method = 'GET', body, anonymous = false) => {
|
|
const response = await fetch(`http://127.0.0.1:${port}/api/${route}`, {
|
|
method,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...(!anonymous && token ? { Authorization: `Bearer ${token}` } : {}),
|
|
},
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
signal: AbortSignal.timeout(5000),
|
|
});
|
|
const result = await response.json();
|
|
try {
|
|
assert.equal(result.code, 200, `${method} ${route}: ${result.message}`);
|
|
} catch (error) {
|
|
error.status = response.status;
|
|
throw error;
|
|
}
|
|
return result.data;
|
|
};
|
|
const healthy = async () => {
|
|
const deadline = Date.now() + 120000;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const value = await api('health', 'GET', undefined, true);
|
|
if (value.status === 'ok') return value;
|
|
} catch {}
|
|
await delay(300);
|
|
}
|
|
throw new Error('Installed npm backend did not become healthy');
|
|
};
|
|
const job = async (id, status) => {
|
|
const deadline = Date.now() + 240000;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const value = await api('system/update-status');
|
|
assert.equal(value.id, id);
|
|
if (value.status === status) return value;
|
|
if (value.status === 'failed' && status !== 'failed')
|
|
throw new Error(value.error);
|
|
} catch (error) {
|
|
if (
|
|
error.status !== 503 &&
|
|
!/fetch failed|ECONNRESET|ECONNREFUSED|aborted|timeout/i.test(
|
|
error.message,
|
|
)
|
|
)
|
|
throw error;
|
|
}
|
|
await delay(250);
|
|
}
|
|
throw new Error(`Installed npm update never reached ${status}`);
|
|
};
|
|
try {
|
|
const install = (archive) =>
|
|
run(
|
|
'npm',
|
|
[
|
|
'install',
|
|
'--global',
|
|
'--prefix',
|
|
prefix,
|
|
'--force',
|
|
'--omit=dev',
|
|
'--no-audit',
|
|
'--no-fund',
|
|
'--cache',
|
|
path.join(temp, 'cache'),
|
|
archive,
|
|
],
|
|
{ cwd: temp, env, timeout: 600000 },
|
|
);
|
|
const start = () =>
|
|
run(path.join(prefix, 'bin/qinglong'), ['reload'], {
|
|
cwd: temp,
|
|
env,
|
|
timeout: 180000,
|
|
});
|
|
const stop = () =>
|
|
run(pm2, ['delete', 'ecosystem.config.js'], { cwd: root, env });
|
|
console.log(
|
|
`Installing actual npm archive with production lifecycle scripts on ${process.version}`,
|
|
);
|
|
await install(input);
|
|
const globalRoot = (
|
|
await run('npm', ['root', '--global', '--prefix', prefix], {
|
|
cwd: temp,
|
|
env,
|
|
})
|
|
).stdout.trim();
|
|
root = path.join(globalRoot, '@whyour/qinglong');
|
|
env.QL_DIR = root;
|
|
const runtimeProbes = [];
|
|
const checkRuntime = async () => {
|
|
const result = await run(
|
|
process.execPath,
|
|
[path.join(root, 'shell/node-runtime-probe.cjs')],
|
|
{ cwd: temp, env },
|
|
);
|
|
runtimeProbes.push(JSON.parse(result.stdout.trim()));
|
|
};
|
|
await checkRuntime();
|
|
// Use a subprocess: cached candidate modules must not mask missing files
|
|
// after old-package installation and the real npm overwrite below.
|
|
await run(
|
|
process.execPath,
|
|
[
|
|
'-e',
|
|
`const root=process.argv[1],p=require('node:path');const m=require(root+'/package.json');for(const target of Object.values(m.bin))require('node:fs').accessSync(p.join(root,target));for(const name of ['platformBundle','upgradePrestart','panelUpgrade'])require(root+'/cli/src/internal/maintenance/'+name+'.cjs');require(root+'/shell/upgrade.cjs').verifyUpgrade(root,p.join(root,'static')).catch(e=>{console.error(e);process.exitCode=1});`,
|
|
root,
|
|
],
|
|
{ env, cwd: temp },
|
|
);
|
|
env.QL_DATA_DIR = path.join(temp, 'fresh/data');
|
|
await fs.mkdir(env.QL_DATA_DIR, { recursive: true });
|
|
console.log('Starting the freshly installed npm package');
|
|
await start();
|
|
const fresh = await healthy();
|
|
assert.equal(fresh.build.sourceCommit, expected.sourceCommit);
|
|
assert.equal(fresh.services.grpc, true);
|
|
assert.match(
|
|
await fetch(`http://127.0.0.1:${port}/`).then((r) => r.text()),
|
|
/<html/i,
|
|
);
|
|
await stop();
|
|
await fs.rm(path.join(temp, 'fresh'), { recursive: true, force: true });
|
|
env.QL_DATA_DIR = data;
|
|
console.log('Installing the integrity-pinned published legacy npm package');
|
|
const legacy = await legacyArchive(temp, env);
|
|
await install(legacy.archive);
|
|
assert.equal(
|
|
(await read(path.join(root, 'package.json'))).version,
|
|
legacy.version,
|
|
);
|
|
await assert.rejects(
|
|
fs.access(
|
|
path.join(root, 'cli/src/internal/maintenance/platformBundle.cjs'),
|
|
),
|
|
{ code: 'ENOENT' },
|
|
);
|
|
const legacyBackendSha = hash(
|
|
await fs.readFile(path.join(root, 'static/build/app.js')),
|
|
);
|
|
await start();
|
|
await healthy();
|
|
const credentials = {
|
|
username: 'npm_package_verification',
|
|
password: `Test_${randomUUID()}!`,
|
|
};
|
|
await api('user/init', 'PUT', credentials, true);
|
|
token = (await api('user/login', 'POST', credentials, true)).token;
|
|
const nonce = randomUUID();
|
|
await api('envs', 'POST', [{ name: 'NPM_PACKAGE_PRESERVE', value: nonce }]);
|
|
const script = path.join(data, 'scripts/npm-package-check.js');
|
|
await fs.writeFile(script, `console.log(${JSON.stringify(nonce)});\n`);
|
|
const cron = await api('crons', 'POST', {
|
|
name: 'npm package retained task',
|
|
command: `node ${script}`,
|
|
schedule: '0 0 1 1 *',
|
|
});
|
|
const userConfig = path.join(data, 'config/config.sh');
|
|
await fs.appendFile(
|
|
userConfig,
|
|
`\n# retained npm package setting ${nonce}\n`,
|
|
);
|
|
const userConfigSha = hash(await fs.readFile(userConfig));
|
|
const preservedData = async () => {
|
|
assert.equal((await api('user')).username, credentials.username);
|
|
assert.ok(
|
|
(await api('envs')).some(
|
|
(x) => x.name === 'NPM_PACKAGE_PRESERVE' && x.value === nonce,
|
|
),
|
|
);
|
|
assert.equal((await api(`crons/${cron.id}`)).command, cron.command);
|
|
assert.equal(hash(await fs.readFile(userConfig)), userConfigSha);
|
|
assert.equal(
|
|
await fs.readFile(script, 'utf8'),
|
|
`console.log(${JSON.stringify(nonce)});\n`,
|
|
);
|
|
};
|
|
await preservedData();
|
|
console.log(
|
|
'Stopping legacy service and verifying npm automatically preserves its .env during overwrite',
|
|
);
|
|
await stop();
|
|
await fs.appendFile(
|
|
path.join(root, '.env'),
|
|
`\n# retained custom npm environment ${nonce}\n`,
|
|
);
|
|
const savedEnv = await fs.readFile(path.join(root, '.env'));
|
|
await install(input);
|
|
assert.deepEqual(await fs.readFile(path.join(root, '.env')), savedEnv);
|
|
assert.equal((await fs.stat(path.join(root, '.env'))).mode & 0o777, 0o600);
|
|
const configSha = hash(savedEnv);
|
|
const local = createRequire(path.join(root, 'package.json'));
|
|
const artifacts = local('./shell/upgrade.cjs');
|
|
const build = await artifacts.verifyUpgrade(
|
|
root,
|
|
path.join(root, 'static'),
|
|
);
|
|
assert.equal(build.sourceCommit, expected.sourceCommit);
|
|
assert.notEqual(
|
|
hash(await fs.readFile(path.join(root, 'static/build/app.js'))),
|
|
legacyBackendSha,
|
|
);
|
|
assert.equal(
|
|
hash(await fs.readFile(path.join(root, 'pnpm-lock.yaml'))),
|
|
build.lockfileSha256,
|
|
);
|
|
const fixture = await platformFixture(
|
|
root,
|
|
process.env.QL_PANEL_TEST_BUNDLE_DIR,
|
|
expected.sourceCommit,
|
|
);
|
|
let badChecksum = true,
|
|
requests = 0;
|
|
fixtureServer = http.createServer((request, response) => {
|
|
requests++;
|
|
if (request.url === '/latest.json') {
|
|
response.setHeader('Content-Type', 'application/json');
|
|
response.end(
|
|
JSON.stringify({
|
|
format: 2,
|
|
sourceCommit: fixture.sourceCommit,
|
|
packages: {
|
|
[fixture.target]: {
|
|
size: fixture.size,
|
|
sha256: badChecksum ? '0'.repeat(64) : fixture.sha256,
|
|
url: `http://127.0.0.1:${
|
|
fixtureServer.address().port
|
|
}/bundle.tar.gz`,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
} else if (request.url === '/bundle.tar.gz') {
|
|
response.setHeader('Content-Length', fixture.size);
|
|
sync.createReadStream(fixture.archive).pipe(response);
|
|
} else {
|
|
response.statusCode = 404;
|
|
response.end();
|
|
}
|
|
});
|
|
await new Promise((resolve) =>
|
|
fixtureServer.listen(0, '127.0.0.1', resolve),
|
|
);
|
|
env.QL_UPGRADE_INDEX_URL = `http://127.0.0.1:${
|
|
fixtureServer.address().port
|
|
}/latest.json`;
|
|
console.log(
|
|
'Starting the overwritten npm package against legacy user data',
|
|
);
|
|
await start();
|
|
const original = await healthy();
|
|
assert.equal(original.build.sourceCommit, build.sourceCommit);
|
|
assert.equal(original.services.grpc, true);
|
|
token = (await api('user/login', 'POST', credentials, true)).token;
|
|
const preserved = async () => {
|
|
await preservedData();
|
|
assert.equal(hash(await fs.readFile(path.join(root, '.env'))), configSha);
|
|
};
|
|
await preserved();
|
|
console.log('Testing a rejected download through the installed panel API');
|
|
const rejected = await api('system/update', 'PUT');
|
|
assert.match((await job(rejected.id, 'failed')).error, /checksum|校验/i);
|
|
assert.equal((await healthy()).build.instanceId, original.build.instanceId);
|
|
await preserved();
|
|
badChecksum = false;
|
|
console.log(
|
|
'Testing installed panel download, prestart and explicit application',
|
|
);
|
|
const accepted = await api('system/update', 'PUT');
|
|
const ready = await job(accepted.id, 'ready');
|
|
assert.equal((await healthy()).build.instanceId, original.build.instanceId);
|
|
const pointer = await read(
|
|
path.join(root, '.tmp/upgrade-ready-develop.json'),
|
|
);
|
|
const receipt = await read(
|
|
path.join(root, '.tmp', pointer.directory, 'prestart.json'),
|
|
);
|
|
assert.ok(
|
|
receipt.completedAt <= ready.updatedAt,
|
|
'Readiness was recorded before backend prestart',
|
|
);
|
|
const applying = await api('update/system', 'PUT');
|
|
await job(applying.id, 'completed');
|
|
const afterOnline = await healthy();
|
|
await checkRuntime();
|
|
assert.notEqual(afterOnline.build.instanceId, original.build.instanceId);
|
|
assert.equal(afterOnline.build.sourceCommit, fixture.sourceCommit);
|
|
await fs.access(path.join(root, 'node_modules/.pnpm'));
|
|
assert.match(
|
|
await fs.realpath(path.join(root, 'node_modules/@mikro-orm/sql')),
|
|
/[/\\]node_modules[/\\]\.pnpm[/\\]/,
|
|
);
|
|
await assert.rejects(fs.access(path.join(root, 'node_modules/sqlite3')), {
|
|
code: 'ENOENT',
|
|
});
|
|
await assert.rejects(fs.access(path.join(root, 'node_modules/sequelize')), {
|
|
code: 'ENOENT',
|
|
});
|
|
await fs.access(path.join(root, 'scripts/build-upgrade-bundle.cjs'));
|
|
await artifacts.verifyUpgrade(root, path.join(root, 'static'));
|
|
assert.match(
|
|
await fetch(`http://127.0.0.1:${port}/`).then((r) => r.text()),
|
|
/<html/i,
|
|
);
|
|
await preserved();
|
|
console.log(
|
|
'Testing offline installer and Bash reload from the installed npm package',
|
|
);
|
|
const requestsBeforeOffline = requests;
|
|
await run(
|
|
process.execPath,
|
|
[
|
|
path.join(root, 'shell/install-bundle.cjs'),
|
|
'--root',
|
|
root,
|
|
'--file',
|
|
fixture.archive,
|
|
'--download-only',
|
|
],
|
|
{ cwd: temp, env, timeout: 240000 },
|
|
);
|
|
assert.equal(
|
|
requests,
|
|
requestsBeforeOffline,
|
|
'Offline preparation contacted the index',
|
|
);
|
|
assert.equal(
|
|
(await healthy()).build.instanceId,
|
|
afterOnline.build.instanceId,
|
|
);
|
|
await run(path.join(prefix, 'bin/ql'), ['reload', 'system'], {
|
|
cwd: temp,
|
|
env,
|
|
timeout: 240000,
|
|
});
|
|
const afterOffline = await healthy();
|
|
await checkRuntime();
|
|
assert.notEqual(
|
|
afterOffline.build.instanceId,
|
|
afterOnline.build.instanceId,
|
|
);
|
|
assert.equal(afterOffline.build.sourceCommit, fixture.sourceCommit);
|
|
await fs.access(path.join(root, 'node_modules/.pnpm'));
|
|
assert.equal(hash(await fs.readFile(process.execPath)), nodeSha);
|
|
assert.equal((await api('system/update-status')).status, 'completed');
|
|
await preserved();
|
|
await api('crons/run', 'PUT', [cron.id]);
|
|
let taskExecuted = false;
|
|
for (let i = 0; i < 100; i++) {
|
|
const log = String(await api(`crons/${cron.id}/log`));
|
|
if (
|
|
log.includes(nonce) &&
|
|
/完成|执行结束|finished|completed/i.test(log)
|
|
) {
|
|
taskExecuted = true;
|
|
break;
|
|
}
|
|
await delay(300);
|
|
}
|
|
assert.ok(
|
|
taskExecuted,
|
|
'Retained task did not execute after npm package upgrades',
|
|
);
|
|
const result = {
|
|
node: process.version,
|
|
platform: fixture.target,
|
|
archiveSha256: hash(await fs.readFile(input)),
|
|
sourceCommit: build.sourceCommit,
|
|
installedProductionDependencies: true,
|
|
restoredLockVerified: true,
|
|
legacyNpmVersion: legacy.version,
|
|
legacyNpmIntegrity: legacy.integrity,
|
|
legacyNpmOverwriteInstalled: true,
|
|
legacyExternalDataPreserved: true,
|
|
customRootEnvironmentPreservedAutomatically: true,
|
|
officialPlatformBundle: {
|
|
baseImage: fixture.baseImage,
|
|
sourceCommit: fixture.sourceCommit,
|
|
sha256: fixture.sha256,
|
|
},
|
|
migratedFromNpmToOfficialPnpmLayout: true,
|
|
existingNodePreserved: true,
|
|
nativeUrlRuntimeProbes: runtimeProbes,
|
|
frontendAndBackendStarted: true,
|
|
checksumFailureKeptOldService: true,
|
|
prestartBeforeReady: true,
|
|
onlineActualRestart: true,
|
|
offlinePreparationDidNotContactIndex: true,
|
|
offlineBashActualRestart: true,
|
|
accountEnvironmentConfigurationAndScriptsPreserved: true,
|
|
retainedTaskExecuted: true,
|
|
initialBuild: original.build,
|
|
afterOnline: afterOnline.build,
|
|
afterOffline: afterOffline.build,
|
|
};
|
|
if (output) {
|
|
await fs.mkdir(output, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(output, `verification-${process.version}.json`),
|
|
JSON.stringify(result, null, 2),
|
|
);
|
|
}
|
|
console.log(JSON.stringify(result, null, 2));
|
|
return result;
|
|
} catch (error) {
|
|
if (root) {
|
|
for (const file of ['qinglong-error.log', 'qinglong-out.log']) {
|
|
const text = await fs
|
|
.readFile(path.join(env.PM2_HOME, 'logs', file), 'utf8')
|
|
.catch(() => '');
|
|
if (text) console.error(text.slice(-6000));
|
|
}
|
|
}
|
|
throw error;
|
|
} finally {
|
|
if (fixtureServer) {
|
|
fixtureServer.closeAllConnections();
|
|
await new Promise((resolve) => fixtureServer.close(resolve));
|
|
}
|
|
// A failed assertion must not remove a prefix that its detached updater is
|
|
// still extracting into. Identify only this private installation's job.
|
|
if (root) {
|
|
const state = await read(
|
|
path.join(root, '.tmp/panel-upgrade.json'),
|
|
).catch(() => undefined);
|
|
if (
|
|
Number.isSafeInteger(state?.pid) &&
|
|
state.pid > 1 &&
|
|
typeof state.id === 'string'
|
|
) {
|
|
const command = (
|
|
await fs
|
|
.readFile(`/proc/${state.pid}/cmdline`, 'utf8')
|
|
.catch(() => '')
|
|
).split('\0');
|
|
if (
|
|
command.includes(
|
|
path.join(root, 'cli/src/internal/maintenance/panelUpgrade.cjs'),
|
|
) &&
|
|
command.includes('worker') &&
|
|
command.includes(state.id)
|
|
) {
|
|
const signal = (name) => {
|
|
try {
|
|
process.kill(-state.pid, name);
|
|
return true;
|
|
} catch (error) {
|
|
if (error.code !== 'ESRCH') throw error;
|
|
return false;
|
|
}
|
|
};
|
|
signal('SIGTERM');
|
|
const deadline = Date.now() + 30000;
|
|
while (signal(0) && Date.now() < deadline) await delay(100);
|
|
if (signal(0)) signal('SIGKILL');
|
|
}
|
|
}
|
|
}
|
|
await run(pm2, ['kill'], { env, cwd: temp }).catch(() => {});
|
|
await run('nginx', ['-s', 'quit'], { env, cwd: temp }).catch(() => {});
|
|
await fs.rm(temp, {
|
|
recursive: true,
|
|
force: true,
|
|
maxRetries: 3,
|
|
retryDelay: 100,
|
|
});
|
|
}
|
|
}
|
|
|
|
module.exports = { checkPackedFiles, packPanel, verifyPanel };
|
|
if (require.main === module) {
|
|
const [action, input, output] = process.argv.slice(2);
|
|
const operation =
|
|
action === 'pack' && input
|
|
? packPanel(path.resolve(input))
|
|
: action === 'verify' && input
|
|
? verifyPanel(path.resolve(input), output && path.resolve(output))
|
|
: Promise.reject(
|
|
new Error(
|
|
'Usage: node scripts/verify-panel-package.cjs pack OUTPUT | verify ARCHIVE_OR_DIRECTORY [RESULT_DIRECTORY]',
|
|
),
|
|
);
|
|
operation.catch((error) => {
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|