mirror of
https://github.com/whyour/qinglong.git
synced 2026-10-16 22:35:32 +08:00
fix: 修复页面升级、ql update 与静态资源版本同步 (#3086)
* fix: validate panel upgrades and synchronize versioned static builds * fix: add static-only mirror backfill workflow * fix: avoid lifecycle self-kill and stop cancelled upgrade groups * fix: build images when the shared upgrade engine changes * fix: dispatch upgrade lifecycle through a fixed script * fix: reclaim upgrade locks safely and normalize health probes * fix: match backend base URL normalization exactly
This commit is contained in:
@@ -13,6 +13,10 @@ execFileSync(
|
||||
],
|
||||
{ stdio: 'inherit' },
|
||||
);
|
||||
fs.copyFileSync(
|
||||
path.join(root, 'src/internal/maintenance/upgradeArtifacts.cjs'),
|
||||
path.join(root, 'dist/internal/maintenance/upgradeArtifacts.cjs'),
|
||||
);
|
||||
require('esbuild').buildSync({
|
||||
entryPoints: [path.join(root, 'src/shared/cli/commander.ts')],
|
||||
outfile: path.join(root, 'dist/shared/cli/commander.js'),
|
||||
@@ -45,7 +49,9 @@ const remote = require('esbuild').buildSync({
|
||||
metafile: true,
|
||||
});
|
||||
for (const input of Object.keys(remote.metafile.inputs)) {
|
||||
if (/(?:^|[\\/])src[\\/](?:internal|compatibility|developer)[\\/]/.test(input))
|
||||
if (
|
||||
/(?:^|[\\/])src[\\/](?:internal|compatibility|developer)[\\/]/.test(input)
|
||||
)
|
||||
throw new Error(
|
||||
'Local implementation leaked into remote npm bundle: ' + input,
|
||||
);
|
||||
|
||||
@@ -8,12 +8,33 @@ import { checkedProcess } from '../runtime/process';
|
||||
import { operationSignal, withoutCancellation } from '../runtime/cancellation';
|
||||
import { installPanelDependencies, startPanel, stopPanel } from './operator';
|
||||
|
||||
// The Bash image updater and local CLI must enforce the same build provenance.
|
||||
const artifacts = require('./upgradeArtifacts.cjs') as {
|
||||
releaseBranch(env: NodeJS.ProcessEnv): string;
|
||||
verifyUpgrade(source: string, staticRoot: string): Promise<unknown>;
|
||||
waitForHealth(
|
||||
env: NodeJS.ProcessEnv,
|
||||
timeout?: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void>;
|
||||
withUpgradeLock<T>(tmp: string, operation: () => Promise<T>): Promise<T>;
|
||||
stageUpgrade(options: {
|
||||
root: string;
|
||||
tmp: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
mirror: 'github' | 'gitee';
|
||||
signal?: AbortSignal;
|
||||
run(program: string, args: string[], capture?: boolean): Promise<unknown>;
|
||||
install(source: string): Promise<void>;
|
||||
}): Promise<{ source: string; static: string }>;
|
||||
selectedUpgrade(
|
||||
tmp: string,
|
||||
branch: string,
|
||||
): Promise<{ source: string; static: string }>;
|
||||
};
|
||||
|
||||
export function releaseBranch(context: LocalContext): string {
|
||||
return ['develop', 'debian', 'debian-dev'].includes(
|
||||
context.env.QL_BRANCH ?? '',
|
||||
)
|
||||
? context.env.QL_BRANCH!
|
||||
: 'master';
|
||||
return artifacts.releaseBranch(context.env);
|
||||
}
|
||||
|
||||
async function validateTree(
|
||||
@@ -48,134 +69,27 @@ export async function stageUpgrade(
|
||||
context: LocalContext,
|
||||
mirror: 'github' | 'gitee',
|
||||
): Promise<{ source: string; static: string }> {
|
||||
await fs.mkdir(context.paths.dir_tmp!, { recursive: true });
|
||||
const workspace = await fs.mkdtemp(
|
||||
path.join(context.paths.dir_tmp!, 'upgrade-'),
|
||||
return artifacts.withUpgradeLock(context.paths.dir_tmp!, () =>
|
||||
artifacts.stageUpgrade({
|
||||
root: context.root,
|
||||
tmp: context.paths.dir_tmp!,
|
||||
env: context.env,
|
||||
mirror,
|
||||
signal: operationSignal(),
|
||||
run: (program, args, capture) =>
|
||||
checkedProcess(program, args, { env: context.env, capture }),
|
||||
install: (source) => installPanelDependencies(context, source),
|
||||
}),
|
||||
);
|
||||
const branch = releaseBranch(context);
|
||||
try {
|
||||
for (const repository of ['qinglong', 'qinglong-static']) {
|
||||
const url =
|
||||
mirror === 'github'
|
||||
? `https://github.com/whyour/${repository}/archive/refs/heads/${branch}.zip`
|
||||
: `https://gitee.com/whyour/${repository}/repository/archive/${branch}.zip`;
|
||||
const archive = path.join(workspace, `${repository}.zip`);
|
||||
await checkedProcess(
|
||||
'curl',
|
||||
[
|
||||
'--fail',
|
||||
'--location',
|
||||
'--proto',
|
||||
'=https',
|
||||
'--proto-redir',
|
||||
'=https',
|
||||
'--output',
|
||||
archive,
|
||||
url,
|
||||
],
|
||||
{ env: context.env },
|
||||
);
|
||||
const listing = await checkedProcess('unzip', ['-Z1', archive], {
|
||||
env: context.env,
|
||||
capture: true,
|
||||
});
|
||||
const expected = `${repository}-${branch}`;
|
||||
for (const name of listing.stdout.split('\n').filter(Boolean)) {
|
||||
if (
|
||||
name.includes('\\') ||
|
||||
name.includes('\r') ||
|
||||
name.startsWith('/') ||
|
||||
name.split('/').includes('..') ||
|
||||
name.split('/')[0] !== expected
|
||||
)
|
||||
throw new Error(translate(context.env, '升级归档包含无效路径。'));
|
||||
}
|
||||
// Reject Unix symlinks before extraction, including links that could redirect
|
||||
// a later archive entry outside the staging directory.
|
||||
const modes = await checkedProcess('unzip', ['-Z', '-l', archive], {
|
||||
env: context.env,
|
||||
capture: true,
|
||||
});
|
||||
if (modes.stdout.split('\n').some((line) => /^l[rwx-]{9}\s/.test(line)))
|
||||
throw new Error(translate(context.env, '升级归档包含符号链接。'));
|
||||
await checkedProcess('unzip', ['-oq', archive, '-d', workspace], {
|
||||
env: context.env,
|
||||
});
|
||||
await validateTree(path.join(workspace, expected), context.env);
|
||||
}
|
||||
const source = path.join(workspace, `qinglong-${branch}`);
|
||||
const staticRoot = path.join(workspace, `qinglong-static-${branch}`);
|
||||
await fs.access(path.join(staticRoot, 'build/app.js'));
|
||||
const manifest = await fs.readFile(path.join(source, 'package.json'));
|
||||
const existing = await fs.readFile(path.join(context.root, 'package.json'));
|
||||
if (!manifest.equals(existing))
|
||||
await installPanelDependencies(context, source);
|
||||
// Publish a marker only after both archives and dependency installation succeed.
|
||||
await fs.writeFile(
|
||||
path.join(workspace, 'ready.json'),
|
||||
JSON.stringify({ source, static: staticRoot }),
|
||||
{ flag: 'wx', mode: 0o600 },
|
||||
);
|
||||
const pointer = path.join(workspace, 'pointer.json');
|
||||
await fs.writeFile(
|
||||
pointer,
|
||||
JSON.stringify({ directory: path.basename(workspace) }),
|
||||
{
|
||||
flag: 'wx',
|
||||
mode: 0o600,
|
||||
},
|
||||
);
|
||||
await fs.rename(
|
||||
pointer,
|
||||
path.join(context.paths.dir_tmp!, `upgrade-ready-${branch}.json`),
|
||||
);
|
||||
return { source, static: staticRoot };
|
||||
} catch (error) {
|
||||
await fs.rm(workspace, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectedUpgrade(
|
||||
context: LocalContext,
|
||||
): Promise<{ source: string; static: string }> {
|
||||
const branch = releaseBranch(context);
|
||||
const fallback = {
|
||||
source: path.join(context.paths.dir_tmp!, `qinglong-${branch}`),
|
||||
static: path.join(context.paths.dir_tmp!, `qinglong-static-${branch}`),
|
||||
};
|
||||
const pointer = await fs
|
||||
.readFile(
|
||||
path.join(context.paths.dir_tmp!, `upgrade-ready-${branch}.json`),
|
||||
'utf8',
|
||||
)
|
||||
.catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'ENOENT') return undefined;
|
||||
throw error;
|
||||
});
|
||||
if (pointer === undefined) return fallback;
|
||||
const directory: unknown = JSON.parse(pointer)?.directory;
|
||||
if (
|
||||
typeof directory !== 'string' ||
|
||||
!/^upgrade-[A-Za-z0-9_-]+$/.test(directory)
|
||||
)
|
||||
throw new Error(translate(context.env, '暂存升级指针无效。'));
|
||||
const workspace = path.join(context.paths.dir_tmp!, directory);
|
||||
const selected = {
|
||||
source: path.join(workspace, `qinglong-${branch}`),
|
||||
static: path.join(workspace, `qinglong-static-${branch}`),
|
||||
};
|
||||
for (const location of [workspace, selected.source, selected.static]) {
|
||||
const stat = await fs.lstat(location);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink())
|
||||
throw new Error(translate(context.env, '暂存升级目录无效。'));
|
||||
}
|
||||
const ready = JSON.parse(
|
||||
await fs.readFile(path.join(workspace, 'ready.json'), 'utf8'),
|
||||
return artifacts.selectedUpgrade(
|
||||
context.paths.dir_tmp!,
|
||||
releaseBranch(context),
|
||||
);
|
||||
if (ready?.source !== selected.source || ready?.static !== selected.static)
|
||||
throw new Error(translate(context.env, '暂存升级就绪记录与目录不匹配。'));
|
||||
return selected;
|
||||
}
|
||||
|
||||
export interface ReloadLifecycle {
|
||||
@@ -345,8 +259,7 @@ export async function reloadPanel(
|
||||
const staticRoot = selected.static;
|
||||
await validateTree(source, context.env);
|
||||
await validateTree(staticRoot, context.env);
|
||||
await fs.access(path.join(source, 'package.json'));
|
||||
await fs.access(path.join(staticRoot, 'build/app.js'));
|
||||
await artifacts.verifyUpgrade(source, staticRoot);
|
||||
const entries = await fs.readdir(source);
|
||||
const reserved = new Set(['data', '.tmp', 'static', '.git', '.env']);
|
||||
const relativeData = path.relative(context.root, context.data);
|
||||
@@ -371,7 +284,16 @@ export async function reloadPanel(
|
||||
if (selection)
|
||||
replacements.push({ source: selection.source, target: selection.target });
|
||||
try {
|
||||
return await replaceAndReload(context, replacements);
|
||||
return await artifacts.withUpgradeLock(context.paths.dir_tmp!, () =>
|
||||
replaceAndReload(context, replacements, {
|
||||
stop: stopPanel,
|
||||
start: async (ctx) => {
|
||||
const service = await startPanel(ctx);
|
||||
await artifacts.waitForHealth(ctx.env, 60000, operationSignal());
|
||||
return service;
|
||||
},
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
if (selection)
|
||||
await fs.rm(selection.directory, { recursive: true, force: true });
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
// Shared by the image's Bash updater and the optional local CLI. Use only
|
||||
// installed code to validate downloads; never execute a downloaded verifier.
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const { createHash, randomUUID } = require('node:crypto');
|
||||
const { spawn } = require('node:child_process');
|
||||
const http = require('node:http');
|
||||
const { setTimeout: delay } = require('node:timers/promises');
|
||||
|
||||
const hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
||||
const releaseBranch = (env) =>
|
||||
['develop', 'debian-dev'].includes(env.QL_BRANCH) ? 'develop' : 'master';
|
||||
|
||||
async function manifestAt(staticRoot) {
|
||||
const manifest = JSON.parse(
|
||||
await fs.readFile(path.join(staticRoot, 'build-info.json'), 'utf8'),
|
||||
);
|
||||
if (
|
||||
manifest.version !== 1 ||
|
||||
manifest.dirty !== false ||
|
||||
!/^[a-f0-9]{40}$/.test(manifest.sourceCommit) ||
|
||||
!/^[a-f0-9]{64}$/.test(manifest.lockfileSha256) ||
|
||||
!manifest.files ||
|
||||
typeof manifest.files !== 'object' ||
|
||||
Array.isArray(manifest.files)
|
||||
)
|
||||
throw new Error('Invalid or dirty build manifest / 构建清单无效。');
|
||||
for (const required of ['build/app.js', 'dist/index.html'])
|
||||
if (!Object.hasOwn(manifest.files, required))
|
||||
throw new Error(`Missing build output / 缺少构建产物: ${required}`);
|
||||
return manifest;
|
||||
}
|
||||
|
||||
async function verifyUpgrade(source, staticRoot) {
|
||||
const manifest = await manifestAt(staticRoot);
|
||||
if (
|
||||
hash(await fs.readFile(path.join(source, 'pnpm-lock.yaml'))) !==
|
||||
manifest.lockfileSha256
|
||||
)
|
||||
throw new Error(
|
||||
'Source and build lockfiles differ / 源码与构建依赖不一致。',
|
||||
);
|
||||
await fs.access(path.join(source, 'package.json'));
|
||||
await fs.access(path.join(source, 'sample/config.sample.sh'));
|
||||
const files = {};
|
||||
async function visit(directory, prefix = '') {
|
||||
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
|
||||
const name = prefix + entry.name;
|
||||
if (name === 'build-info.json') continue;
|
||||
const filename = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) await visit(filename, `${name}/`);
|
||||
else if (entry.isFile()) files[name] = hash(await fs.readFile(filename));
|
||||
else
|
||||
throw new Error(`Unsupported build entry / 构建包含非法文件: ${name}`);
|
||||
}
|
||||
}
|
||||
await visit(staticRoot);
|
||||
const actual = Object.keys(files).sort(),
|
||||
expected = Object.keys(manifest.files).sort();
|
||||
if (
|
||||
JSON.stringify(actual) !== JSON.stringify(expected) ||
|
||||
actual.some((name) => files[name] !== manifest.files[name])
|
||||
)
|
||||
throw new Error(
|
||||
'Build checksum mismatch / 构建文件缺失、损坏或混入旧文件。',
|
||||
);
|
||||
return manifest;
|
||||
}
|
||||
|
||||
async function withUpgradeLock(tmp, operation) {
|
||||
await fs.mkdir(tmp, { recursive: true });
|
||||
const lock = path.join(tmp, 'upgrade.lock');
|
||||
const token = randomUUID();
|
||||
const owner = `owner-${process.pid}-${token}`;
|
||||
const candidate = path.join(tmp, `upgrade-lock-${token}`);
|
||||
let acquired = false;
|
||||
await fs.mkdir(candidate, { mode: 0o700 });
|
||||
try {
|
||||
// Publish a nonempty directory atomically: a live lock can never be
|
||||
// replaced, and a crash before publication cannot leave an ownerless lock.
|
||||
await fs.writeFile(path.join(candidate, owner), '', {
|
||||
flag: 'wx',
|
||||
mode: 0o600,
|
||||
});
|
||||
for (;;) {
|
||||
try {
|
||||
await fs.rename(candidate, lock);
|
||||
acquired = true;
|
||||
break;
|
||||
} catch (error) {
|
||||
if (!['ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error;
|
||||
}
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(lock);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') continue;
|
||||
throw error;
|
||||
}
|
||||
if (!entries.length) continue;
|
||||
const heldOwner = entries[0];
|
||||
const match = /^owner-([1-9]\d*)-[a-f0-9-]{36}$/.exec(heldOwner);
|
||||
let pid = match ? Number(match[1]) : 0;
|
||||
if (entries.length === 1 && heldOwner === 'pid') {
|
||||
try {
|
||||
pid = Number(await fs.readFile(path.join(lock, heldOwner), 'utf8'));
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') continue;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
let stale = false;
|
||||
if (
|
||||
entries.length === 1 &&
|
||||
Number.isSafeInteger(pid) &&
|
||||
pid > 0 &&
|
||||
pid <= 0x7fffffff
|
||||
) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
} catch (error) {
|
||||
if (error.code === 'ESRCH') stale = true;
|
||||
}
|
||||
}
|
||||
if (!stale)
|
||||
throw new Error(
|
||||
'Another upgrade is running / 已有更新或重载正在执行。',
|
||||
);
|
||||
// Removing the observed unique marker is a compare-and-delete. A
|
||||
// contender cannot unlink the marker belonging to a replacement lock.
|
||||
// Legacy pid files are supported; new owners never reuse that filename.
|
||||
try {
|
||||
await fs.unlink(path.join(lock, heldOwner));
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
}
|
||||
return await operation();
|
||||
} finally {
|
||||
if (acquired) {
|
||||
await fs.unlink(path.join(lock, owner)).catch((error) => {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
});
|
||||
await fs.rmdir(lock).catch((error) => {
|
||||
if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code))
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
await fs.rm(candidate, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function stageUpgrade({ root, tmp, env, mirror, run, install, signal }) {
|
||||
const workspace = await fs.mkdtemp(path.join(tmp, 'upgrade-'));
|
||||
const branch = releaseBranch(env);
|
||||
try {
|
||||
async function extract(repository, ref) {
|
||||
const archive = path.join(workspace, `${repository}.zip`);
|
||||
const url =
|
||||
mirror === 'github'
|
||||
? `https://github.com/whyour/${repository}/archive/${ref}.zip`
|
||||
: `https://gitee.com/whyour/${repository}/repository/archive/${ref}.zip`;
|
||||
await run('curl', [
|
||||
'--fail',
|
||||
'--location',
|
||||
'--proto',
|
||||
'=https',
|
||||
'--proto-redir',
|
||||
'=https',
|
||||
'--connect-timeout',
|
||||
'30',
|
||||
'--max-time',
|
||||
'600',
|
||||
'--output',
|
||||
archive,
|
||||
url,
|
||||
]);
|
||||
const listing = await run('unzip', ['-Z1', archive], true);
|
||||
const expected = `${repository}-${ref}`;
|
||||
const names = listing.stdout.split('\n').filter(Boolean);
|
||||
if (
|
||||
!names.length ||
|
||||
names.some(
|
||||
(name) =>
|
||||
name.includes('\\') ||
|
||||
name.includes('\r') ||
|
||||
name.startsWith('/') ||
|
||||
name.split('/').includes('..') ||
|
||||
name.split('/')[0] !== expected,
|
||||
)
|
||||
)
|
||||
throw new Error('Invalid archive paths / 升级归档路径无效。');
|
||||
const modes = await run('unzip', ['-Z', '-l', archive], true);
|
||||
if (modes.stdout.split('\n').some((line) => /^l[rwx-]{9}\s/.test(line)))
|
||||
throw new Error('Archive contains symlinks / 升级归档包含符号链接。');
|
||||
await run('unzip', ['-oq', archive, '-d', workspace]);
|
||||
return path.join(workspace, expected);
|
||||
}
|
||||
// Static branches are mutable snapshots. Their manifest pins the exact
|
||||
// source revision, avoiding a race between two independent branch reads.
|
||||
const staticRoot = await extract('qinglong-static', branch);
|
||||
const manifest = await manifestAt(staticRoot);
|
||||
const downloadedSource = await extract('qinglong', manifest.sourceCommit);
|
||||
const source = path.join(workspace, `qinglong-${branch}`);
|
||||
await fs.rename(downloadedSource, source);
|
||||
await verifyUpgrade(source, staticRoot);
|
||||
let dependenciesChanged = false;
|
||||
for (const name of ['package.json', 'pnpm-lock.yaml']) {
|
||||
const current = await fs
|
||||
.readFile(path.join(root, name))
|
||||
.catch((error) => {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
return Buffer.alloc(0);
|
||||
});
|
||||
if (!(await fs.readFile(path.join(source, name))).equals(current))
|
||||
dependenciesChanged = true;
|
||||
}
|
||||
if (
|
||||
!(await fs.stat(path.join(root, 'node_modules')).catch(() => undefined))
|
||||
)
|
||||
dependenciesChanged = true;
|
||||
if (dependenciesChanged) {
|
||||
await install(source);
|
||||
await fs.access(path.join(source, 'node_modules'));
|
||||
await verifyUpgrade(source, staticRoot);
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const selected = { source, static: staticRoot };
|
||||
await fs.writeFile(
|
||||
path.join(workspace, 'ready.json'),
|
||||
JSON.stringify({
|
||||
...selected,
|
||||
sourceCommit: manifest.sourceCommit,
|
||||
dependenciesChanged,
|
||||
}),
|
||||
{ flag: 'wx', mode: 0o600 },
|
||||
);
|
||||
const pointer = path.join(workspace, 'pointer.json');
|
||||
await fs.writeFile(
|
||||
pointer,
|
||||
JSON.stringify({ directory: path.basename(workspace) }),
|
||||
{ flag: 'wx', mode: 0o600 },
|
||||
);
|
||||
const readyPointer = path.join(tmp, `upgrade-ready-${branch}.json`);
|
||||
const previous = await fs
|
||||
.readFile(readyPointer, 'utf8')
|
||||
.catch(() => undefined);
|
||||
signal?.throwIfAborted();
|
||||
await fs.rename(pointer, readyPointer);
|
||||
// Retire only our previous successful stage, after publishing its replacement.
|
||||
try {
|
||||
const directory = previous && JSON.parse(previous).directory;
|
||||
if (
|
||||
typeof directory === 'string' &&
|
||||
/^upgrade-[A-Za-z0-9_-]+$/.test(directory) &&
|
||||
directory !== path.basename(workspace)
|
||||
)
|
||||
await fs.rm(path.join(tmp, directory), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
} catch {
|
||||
/* Cleanup cannot invalidate the newly published stage. */
|
||||
}
|
||||
return selected;
|
||||
} catch (error) {
|
||||
await fs.rm(workspace, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectedUpgrade(tmp, branch) {
|
||||
const pointer = JSON.parse(
|
||||
await fs.readFile(path.join(tmp, `upgrade-ready-${branch}.json`), 'utf8'),
|
||||
);
|
||||
if (
|
||||
typeof pointer.directory !== 'string' ||
|
||||
!/^upgrade-[A-Za-z0-9_-]+$/.test(pointer.directory)
|
||||
)
|
||||
throw new Error('Invalid staged upgrade pointer / 暂存升级指针无效。');
|
||||
const workspace = path.join(tmp, pointer.directory);
|
||||
const selected = {
|
||||
source: path.join(workspace, `qinglong-${branch}`),
|
||||
static: path.join(workspace, `qinglong-static-${branch}`),
|
||||
};
|
||||
for (const directory of [workspace, selected.source, selected.static]) {
|
||||
const stat = await fs.lstat(directory);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink())
|
||||
throw new Error('Invalid staging directory / 暂存目录无效。');
|
||||
}
|
||||
const ready = JSON.parse(
|
||||
await fs.readFile(path.join(workspace, 'ready.json'), 'utf8'),
|
||||
);
|
||||
if (ready.source !== selected.source || ready.static !== selected.static)
|
||||
throw new Error('Invalid upgrade readiness / 暂存升级就绪记录不匹配。');
|
||||
const manifest = await verifyUpgrade(selected.source, selected.static);
|
||||
if (ready.sourceCommit !== manifest.sourceCommit)
|
||||
throw new Error('Invalid upgrade readiness / 暂存升级就绪记录不匹配。');
|
||||
if (ready.dependenciesChanged)
|
||||
await fs.access(path.join(selected.source, 'node_modules'));
|
||||
return selected;
|
||||
}
|
||||
|
||||
async function replaceAndReload(replacements, lifecycle) {
|
||||
const changed = [];
|
||||
let attemptedStart = false;
|
||||
lifecycle.signal?.throwIfAborted();
|
||||
try {
|
||||
await lifecycle.stop();
|
||||
for (const { source, target } of replacements) {
|
||||
lifecycle.signal?.throwIfAborted();
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
const backup = `${target}.ql-backup-${randomUUID()}`;
|
||||
const existed = !!(await fs.lstat(target).catch((error) => {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
return undefined;
|
||||
}));
|
||||
if (existed) {
|
||||
try {
|
||||
await fs.rename(target, backup);
|
||||
} catch (error) {
|
||||
if (error.code !== 'EXDEV') throw error;
|
||||
try {
|
||||
await fs.cp(target, backup, {
|
||||
recursive: true,
|
||||
verbatimSymlinks: true,
|
||||
});
|
||||
} catch (copyError) {
|
||||
await fs.rm(backup, { recursive: true, force: true });
|
||||
throw copyError;
|
||||
}
|
||||
changed.push({ target, backup, existed });
|
||||
await fs.rm(target, { recursive: true });
|
||||
}
|
||||
}
|
||||
if (changed.at(-1)?.target !== target)
|
||||
changed.push({ target, backup, existed });
|
||||
await fs.cp(source, target, {
|
||||
recursive: true,
|
||||
verbatimSymlinks: true,
|
||||
force: false,
|
||||
errorOnExist: true,
|
||||
});
|
||||
}
|
||||
lifecycle.signal?.throwIfAborted();
|
||||
attemptedStart = true;
|
||||
await lifecycle.start();
|
||||
lifecycle.signal?.throwIfAborted();
|
||||
} catch (error) {
|
||||
// Do not modify files under a still-running failed startup. Keep backups
|
||||
// for recovery if stopping the new process itself fails.
|
||||
if (attemptedStart) await lifecycle.stop();
|
||||
for (const item of changed.reverse()) {
|
||||
await fs.rm(item.target, { recursive: true, force: true });
|
||||
if (item.existed) {
|
||||
try {
|
||||
await fs.rename(item.backup, item.target);
|
||||
} catch (restoreError) {
|
||||
if (restoreError.code !== 'EXDEV') throw restoreError;
|
||||
await fs.cp(item.backup, item.target, {
|
||||
recursive: true,
|
||||
verbatimSymlinks: true,
|
||||
});
|
||||
await fs.rm(item.backup, { recursive: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
await lifecycle.start(true);
|
||||
throw error;
|
||||
}
|
||||
for (const item of changed)
|
||||
if (item.existed)
|
||||
await fs
|
||||
.rm(item.backup, { recursive: true, force: true })
|
||||
.catch(() =>
|
||||
console.error(`Retained backup / 保留备份: ${item.backup}`),
|
||||
);
|
||||
}
|
||||
|
||||
async function reloadSystem({
|
||||
root,
|
||||
data,
|
||||
tmp,
|
||||
staticRoot,
|
||||
config,
|
||||
env,
|
||||
lifecycle,
|
||||
}) {
|
||||
const selected = await selectedUpgrade(tmp, releaseBranch(env));
|
||||
const reserved = new Set(['data', '.tmp', 'static', '.git', '.env']);
|
||||
const relative = path.relative(root, data);
|
||||
if (relative && !relative.startsWith('..') && !path.isAbsolute(relative))
|
||||
reserved.add(relative.split(path.sep)[0]);
|
||||
const replacements = (await fs.readdir(selected.source))
|
||||
.filter((name) => !reserved.has(name))
|
||||
.map((name) => ({
|
||||
source: path.join(selected.source, name),
|
||||
target: path.join(root, name),
|
||||
}));
|
||||
replacements.push(
|
||||
{ source: selected.static, target: staticRoot },
|
||||
{
|
||||
source: path.join(selected.source, 'sample/config.sample.sh'),
|
||||
target: path.join(config, 'config.sample.sh'),
|
||||
},
|
||||
);
|
||||
await replaceAndReload(replacements, lifecycle);
|
||||
// Cleanup after a committed installation must not report a failed update.
|
||||
await fs
|
||||
.rm(path.join(tmp, `upgrade-ready-${releaseBranch(env)}.json`), {
|
||||
force: true,
|
||||
})
|
||||
.catch(() => {});
|
||||
await fs
|
||||
.rm(path.dirname(selected.source), { recursive: true, force: true })
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
async function waitForHealth(env, timeout = 60000, signal) {
|
||||
const port = Number(env.QlPort || 5700);
|
||||
let base = env.QlBaseUrl || '';
|
||||
if (base && !base.startsWith('/')) base = `/${base}`;
|
||||
if (base.endsWith('/')) base = base.slice(0, -1);
|
||||
const deadline = Date.now() + timeout;
|
||||
while (Date.now() < deadline) {
|
||||
signal?.throwIfAborted();
|
||||
const healthy = await new Promise((resolve) => {
|
||||
let timer;
|
||||
const finish = (healthy) => {
|
||||
clearTimeout(timer);
|
||||
resolve(healthy);
|
||||
};
|
||||
const req = http.get(
|
||||
{ hostname: '127.0.0.1', port, path: `${base}/api/health`, signal },
|
||||
(res) => {
|
||||
let body = '';
|
||||
res.on('data', (chunk) => {
|
||||
body += chunk;
|
||||
if (body.length > 65536) req.destroy();
|
||||
});
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const result = JSON.parse(body);
|
||||
finish(
|
||||
res.statusCode === 200 &&
|
||||
result.code === 200 &&
|
||||
result.data?.status === 'ok',
|
||||
);
|
||||
} catch {
|
||||
finish(false);
|
||||
}
|
||||
});
|
||||
res.on('error', () => finish(false));
|
||||
},
|
||||
);
|
||||
// Socket inactivity timeouts do not bound a response that keeps streaming.
|
||||
timer = setTimeout(
|
||||
() => req.destroy(new Error('Health probe timed out')),
|
||||
Math.min(1000, Math.max(1, deadline - Date.now())),
|
||||
);
|
||||
req.on('error', () => finish(false));
|
||||
});
|
||||
signal?.throwIfAborted();
|
||||
if (healthy) return;
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining > 0) {
|
||||
try {
|
||||
await delay(Math.min(500, remaining), undefined, { signal });
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error('Backend failed health check / 后端启动检查失败。');
|
||||
}
|
||||
|
||||
function runProcess(env, program, args, capture = false, signal) {
|
||||
if (signal?.aborted) return Promise.reject(signal.reason);
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(program, args, {
|
||||
env,
|
||||
detached: process.platform !== 'win32',
|
||||
// Pipes keep close pending until children have released their output.
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let aborted = false;
|
||||
let failure;
|
||||
let escalation;
|
||||
const kill = (name) => {
|
||||
if (!child.pid) return;
|
||||
try {
|
||||
process.kill(
|
||||
process.platform === 'win32' ? child.pid : -child.pid,
|
||||
name,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error.code === 'EPERM') {
|
||||
child.kill(name);
|
||||
return;
|
||||
}
|
||||
if (error.code !== 'ESRCH') throw error;
|
||||
}
|
||||
};
|
||||
const abort = () => {
|
||||
if (aborted) return;
|
||||
aborted = true;
|
||||
kill('SIGTERM');
|
||||
escalation = setTimeout(() => kill('SIGKILL'), 1000);
|
||||
escalation.unref();
|
||||
};
|
||||
child.stdout.on('data', (chunk) => {
|
||||
if (capture) stdout += chunk;
|
||||
else process.stdout.write(chunk);
|
||||
});
|
||||
child.stderr.on('data', (chunk) => process.stderr.write(chunk));
|
||||
child.once('error', (error) => {
|
||||
failure = error;
|
||||
});
|
||||
child.once('exit', () => {
|
||||
// The shell can exit before a descendant that ignores SIGTERM.
|
||||
if (aborted) kill('SIGKILL');
|
||||
});
|
||||
child.once('close', (code) => {
|
||||
if (aborted) kill('SIGKILL');
|
||||
clearTimeout(escalation);
|
||||
signal?.removeEventListener('abort', abort);
|
||||
if (aborted) reject(signal.reason);
|
||||
else if (failure) reject(failure);
|
||||
else if (code === 0) resolve({ stdout });
|
||||
else reject(new Error(`${program} failed (exit ${code})`));
|
||||
});
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
if (signal?.aborted) abort();
|
||||
});
|
||||
}
|
||||
|
||||
async function withLifecycleHooks(root, tmp, env, operation) {
|
||||
const helpers = await fs.readFile(path.join(root, 'shell/share.sh'), 'utf8');
|
||||
const environment = await fs.readFile(
|
||||
path.join(root, 'shell/env.sh'),
|
||||
'utf8',
|
||||
);
|
||||
const directory = await fs.mkdtemp(path.join(tmp, 'upgrade-hooks-'));
|
||||
try {
|
||||
// Keep the installed functions available through replacement and rollback.
|
||||
// Their pkill patterns must never become part of the bash command line.
|
||||
const snapshot = path.join(directory, 'lifecycle.sh');
|
||||
await fs.writeFile(
|
||||
snapshot,
|
||||
`${helpers}\n${environment}\nimport_config
|
||||
case "$1" in
|
||||
stop) delete_pm2 ;;
|
||||
start) reload_pm2 ;;
|
||||
install) npm_install_2 "$2"; exit "$exit_status" ;;
|
||||
*) printf '%s\\n' 'Unknown lifecycle operation' >&2; exit 2 ;;
|
||||
esac
|
||||
`,
|
||||
{
|
||||
flag: 'wx',
|
||||
mode: 0o600,
|
||||
},
|
||||
);
|
||||
const hook = (operation, args = [], signal) =>
|
||||
runProcess(
|
||||
env,
|
||||
'bash',
|
||||
['--', snapshot, operation, ...args],
|
||||
false,
|
||||
signal,
|
||||
);
|
||||
return await operation(hook);
|
||||
} finally {
|
||||
await fs
|
||||
.rm(directory, { recursive: true, force: true })
|
||||
.catch(() =>
|
||||
console.error(
|
||||
`Retained lifecycle snapshot / 保留生命周期快照: ${directory}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const env = process.env,
|
||||
root = env.QL_DIR;
|
||||
if (!root || !path.isAbsolute(root))
|
||||
throw new Error('QL_DIR must be absolute.');
|
||||
const tmp = env.dir_tmp || path.join(root, '.tmp');
|
||||
const controller = new AbortController();
|
||||
for (const name of ['SIGINT', 'SIGTERM'])
|
||||
process.on(name, () =>
|
||||
controller.abort(new Error(`Upgrade interrupted (${name})`)),
|
||||
);
|
||||
const run = (program, args, capture) =>
|
||||
runProcess(env, program, args, capture, controller.signal);
|
||||
await withUpgradeLock(tmp, () =>
|
||||
withLifecycleHooks(root, tmp, env, async (hook) => {
|
||||
const lifecycle = {
|
||||
signal: controller.signal,
|
||||
stop: () => hook('stop'),
|
||||
start: async (recover) => {
|
||||
await hook('start', [], recover ? undefined : controller.signal);
|
||||
await waitForHealth(
|
||||
env,
|
||||
60000,
|
||||
recover ? undefined : controller.signal,
|
||||
);
|
||||
},
|
||||
};
|
||||
if (process.argv[2] === 'update') {
|
||||
await stageUpgrade({
|
||||
root,
|
||||
tmp,
|
||||
env,
|
||||
mirror: process.argv[3],
|
||||
run,
|
||||
signal: controller.signal,
|
||||
install: (source) => hook('install', [source], controller.signal),
|
||||
});
|
||||
console.log('更新包下载及校验成功...');
|
||||
if (process.argv[4] !== 'true') return;
|
||||
} else if (process.argv[2] !== 'reload')
|
||||
throw new Error('Unknown upgrade command.');
|
||||
await reloadSystem({
|
||||
root,
|
||||
tmp,
|
||||
env,
|
||||
data: env.dir_data || path.join(root, 'data'),
|
||||
staticRoot: env.dir_static || path.join(root, 'static'),
|
||||
config: env.dir_config || path.join(root, 'data/config'),
|
||||
lifecycle,
|
||||
});
|
||||
console.log('更新及启动检查成功...');
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
releaseBranch,
|
||||
verifyUpgrade,
|
||||
withUpgradeLock,
|
||||
stageUpgrade,
|
||||
selectedUpgrade,
|
||||
replaceAndReload,
|
||||
reloadSystem,
|
||||
waitForHealth,
|
||||
runProcess,
|
||||
withLifecycleHooks,
|
||||
main,
|
||||
};
|
||||
if (require.main === module)
|
||||
main().catch((error) => {
|
||||
console.error(`更新失败: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -15,8 +15,17 @@ const {
|
||||
reloadPanel,
|
||||
} = require('../../dist/internal/maintenance/upgrade');
|
||||
const { parse } = require('../helpers/commands.cjs');
|
||||
const {
|
||||
writeManifest,
|
||||
sourceCommit,
|
||||
} = require('../../../test/helpers/upgrade-fixture.cjs');
|
||||
|
||||
async function fixture(t) {
|
||||
t.mock.method(
|
||||
require('../../dist/internal/maintenance/upgradeArtifacts.cjs'),
|
||||
'waitForHealth',
|
||||
async () => {},
|
||||
);
|
||||
const root = await fs.realpath(
|
||||
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-operator-')),
|
||||
);
|
||||
@@ -119,14 +128,27 @@ test('PM2 restarts retain configured ports without mutating legacy environment',
|
||||
ctx.env.CALLS = path.join(ctx.root, 'ports.jsonl');
|
||||
ctx.env.BACK_PORT = '5600';
|
||||
ctx.env.GRPC_PORT = '5400';
|
||||
await stub(ctx, 'pm2', 'require("node:fs").appendFileSync(process.env.CALLS, JSON.stringify([process.env.BACK_PORT,process.env.GRPC_PORT])+"\\n");');
|
||||
await stub(
|
||||
ctx,
|
||||
'pm2',
|
||||
'require("node:fs").appendFileSync(process.env.CALLS, JSON.stringify([process.env.BACK_PORT,process.env.GRPC_PORT])+"\\n");',
|
||||
);
|
||||
await startPanel(ctx);
|
||||
ctx.env.QlPort = '5799';
|
||||
ctx.env.QlGrpcPort = '5599';
|
||||
await startPanel(ctx);
|
||||
assert.deepEqual((await fs.readFile(ctx.env.CALLS, 'utf8')).trim().split('\n').map(JSON.parse), [
|
||||
['5700', '5500'], ['5700', '5500'], ['5799', '5599'], ['5799', '5599'],
|
||||
]);
|
||||
assert.deepEqual(
|
||||
(await fs.readFile(ctx.env.CALLS, 'utf8'))
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map(JSON.parse),
|
||||
[
|
||||
['5700', '5500'],
|
||||
['5700', '5500'],
|
||||
['5799', '5599'],
|
||||
['5799', '5599'],
|
||||
],
|
||||
);
|
||||
assert.equal(ctx.env.BACK_PORT, '5600');
|
||||
assert.equal(ctx.env.GRPC_PORT, '5400');
|
||||
});
|
||||
@@ -199,24 +221,41 @@ test('upgrade staging extracts both archives before publishing readiness and rej
|
||||
const payloads = path.join(ctx.root, 'payloads');
|
||||
const archives = path.join(ctx.root, 'archives');
|
||||
await fs.mkdir(archives);
|
||||
await fs.mkdir(path.join(payloads, 'qinglong-master'), { recursive: true });
|
||||
await fs.mkdir(path.join(payloads, `qinglong-${sourceCommit}`), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.mkdir(path.join(payloads, 'qinglong-static-master/build'), {
|
||||
recursive: true,
|
||||
});
|
||||
const manifest = '{"name":"fixture"}';
|
||||
await fs.writeFile(path.join(ctx.root, 'package.json'), manifest);
|
||||
await fs.writeFile(
|
||||
path.join(payloads, 'qinglong-master/package.json'),
|
||||
path.join(payloads, `qinglong-${sourceCommit}/package.json`),
|
||||
manifest,
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(payloads, 'qinglong-static-master/build/app.js'),
|
||||
'console.log("fixture")',
|
||||
);
|
||||
await fs.mkdir(path.join(payloads, `qinglong-${sourceCommit}/sample`));
|
||||
await fs.writeFile(
|
||||
path.join(payloads, `qinglong-${sourceCommit}/sample/config.sample.sh`),
|
||||
'new sample',
|
||||
);
|
||||
writeManifest(
|
||||
path.join(payloads, `qinglong-${sourceCommit}`),
|
||||
path.join(payloads, 'qinglong-static-master'),
|
||||
);
|
||||
await fs.writeFile(path.join(ctx.root, 'pnpm-lock.yaml'), 'fixture lock\n');
|
||||
await fs.mkdir(path.join(ctx.root, 'node_modules'));
|
||||
for (const repo of ['qinglong', 'qinglong-static'])
|
||||
execFileSync(
|
||||
'/usr/bin/zip',
|
||||
['-qry', path.join(archives, `${repo}.zip`), `${repo}-master`],
|
||||
[
|
||||
'-qry',
|
||||
path.join(archives, `${repo}.zip`),
|
||||
`${repo}-${repo === 'qinglong' ? sourceCommit : 'master'}`,
|
||||
],
|
||||
{ cwd: payloads },
|
||||
);
|
||||
ctx.env.ARCHIVES = archives;
|
||||
@@ -225,18 +264,6 @@ test('upgrade staging extracts both archives before publishing readiness and rej
|
||||
'curl',
|
||||
'const fs=require("node:fs"),p=require("node:path"),args=process.argv.slice(2),out=args[args.indexOf("--output")+1]; fs.copyFileSync(p.join(process.env.ARCHIVES,p.basename(out)),out);',
|
||||
);
|
||||
await fs.mkdir(path.join(payloads, 'qinglong-master/sample'), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.writeFile(
|
||||
path.join(payloads, 'qinglong-master/sample/config.sample.sh'),
|
||||
'new sample',
|
||||
);
|
||||
execFileSync(
|
||||
'/usr/bin/zip',
|
||||
['-qry', path.join(archives, 'qinglong.zip'), 'qinglong-master'],
|
||||
{ cwd: payloads },
|
||||
);
|
||||
const staged = await stageUpgrade(ctx, 'github');
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(staged.source, 'package.json'), 'utf8'),
|
||||
@@ -249,15 +276,15 @@ test('upgrade staging extracts both archives before publishing readiness and rej
|
||||
'utf8',
|
||||
),
|
||||
),
|
||||
staged,
|
||||
{ ...staged, sourceCommit, dependenciesChanged: false },
|
||||
);
|
||||
await fs.symlink(
|
||||
'../../outside',
|
||||
path.join(payloads, 'qinglong-master/escape'),
|
||||
path.join(payloads, `qinglong-${sourceCommit}/escape`),
|
||||
);
|
||||
execFileSync(
|
||||
'/usr/bin/zip',
|
||||
['-qry', path.join(archives, 'qinglong.zip'), 'qinglong-master'],
|
||||
['-qry', path.join(archives, 'qinglong.zip'), `qinglong-${sourceCommit}`],
|
||||
{ cwd: payloads },
|
||||
);
|
||||
await assert.rejects(stageUpgrade(ctx, 'github'), /symlinks/);
|
||||
@@ -728,7 +755,7 @@ test('invalid or incomplete staged pointers cannot stop the installed panel', as
|
||||
reloadPanel(ctx, 'system'),
|
||||
(error) =>
|
||||
error.code === 'ENOENT' &&
|
||||
error.path === path.join(ctx.paths.dir_tmp, 'qinglong-master'),
|
||||
error.path === path.join(ctx.paths.dir_tmp, 'upgrade-ready-master.json'),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -783,6 +810,7 @@ test('system upgrade preserves installed dotenv bytes and permissions even when
|
||||
);
|
||||
await fs.writeFile(path.join(staticRoot, 'build/app.js'), '');
|
||||
await fs.writeFile(path.join(source, '.env'), 'PORT=9999\nREPLACE_ME=true\n');
|
||||
writeManifest(source, staticRoot);
|
||||
const target = path.join(ctx.root, '.env');
|
||||
const original = 'PORT=5700\nPRIVATE_VALUE=preserved\n';
|
||||
await fs.writeFile(target, original, { mode: 0o600 });
|
||||
|
||||
@@ -6,10 +6,16 @@ const os = require('node:os');
|
||||
const { createContext } = require('../../dist/internal/runtime/context');
|
||||
const { reloadPanel } = require('../../dist/internal/maintenance/upgrade');
|
||||
const operator = require('../../dist/internal/maintenance/operator');
|
||||
const { writeManifest } = require('../../../test/helpers/upgrade-fixture.cjs');
|
||||
const selectedLoader =
|
||||
'// QL_CLI_ROOT dist/local/entrypoints.js dist/local/cronEntrypoint.js\n';
|
||||
|
||||
async function fixture(t) {
|
||||
t.mock.method(
|
||||
require('../../dist/internal/maintenance/upgradeArtifacts.cjs'),
|
||||
'waitForHealth',
|
||||
async () => {},
|
||||
);
|
||||
const base = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'ql-upgrade-selection-'),
|
||||
);
|
||||
@@ -43,6 +49,7 @@ async function fixture(t) {
|
||||
await fs.writeFile(incoming, '// original released loader');
|
||||
await fs.writeFile(path.join(ctx.paths.dir_static, 'build/app.js'), 'old');
|
||||
await fs.writeFile(path.join(staticRoot, 'build/app.js'), 'new');
|
||||
writeManifest(source, staticRoot);
|
||||
return { ctx, source, staticRoot, loader, incoming };
|
||||
}
|
||||
|
||||
@@ -104,3 +111,33 @@ test('unsupported target and unrecognized loader fail before service stop', asyn
|
||||
assert.equal(stopped, false);
|
||||
assert.equal(await fs.readFile(f.loader, 'utf8'), 'unrecognized');
|
||||
});
|
||||
|
||||
test('successful process start with failed health check rolls back the installed version', async (t) => {
|
||||
const f = await fixture(t);
|
||||
t.mock.method(operator, 'stopPanel', async () => {});
|
||||
t.mock.method(operator, 'startPanel', async () => ({ manager: 'fixture' }));
|
||||
let probes = 0;
|
||||
t.mock.method(
|
||||
require('../../dist/internal/maintenance/upgradeArtifacts.cjs'),
|
||||
'waitForHealth',
|
||||
async () => {
|
||||
if (++probes === 1) throw new Error('health check failed');
|
||||
assert.equal(
|
||||
await fs.readFile(
|
||||
path.join(f.ctx.paths.dir_static, 'build/app.js'),
|
||||
'utf8',
|
||||
),
|
||||
'old',
|
||||
);
|
||||
},
|
||||
);
|
||||
await assert.rejects(
|
||||
reloadPanel(f.ctx, 'system', { source: f.source, static: f.staticRoot }),
|
||||
/health check failed/,
|
||||
);
|
||||
assert.equal(probes, 2);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.ctx.root, 'package.json'), 'utf8'),
|
||||
'{"version":"2.19.0"}',
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user