mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-15 19:57:07 +08:00
fix: 修复任务生命周期与调度就绪,优化执行和构建开销 (#3069)
* fix: harden task lifecycle and scheduler readiness * fix: confine log writes to the configured log directory * fix: verify complete build artifacts and untracked inputs * fix: reconcile scheduler state and make stop win startup races * fix: isolate cron generations and serialize scheduler recovery
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync, spawnSync } = require('node:child_process');
|
||||
|
||||
test('build verification accepts matching source and rejects stale or dirty artifacts', (t) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-build-source-'));
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
const git = (...args) =>
|
||||
execFileSync('git', args, { cwd: dir, stdio: 'pipe' });
|
||||
git('init');
|
||||
git('config', 'user.name', 'Build test');
|
||||
git('config', 'user.email', 'build-test@example.invalid');
|
||||
fs.writeFileSync(path.join(dir, 'pnpm-lock.yaml'), 'lockfileVersion: 6.0\n');
|
||||
fs.writeFileSync(path.join(dir, '.gitignore'), 'static/\n');
|
||||
git('add', '.');
|
||||
git('commit', '-m', 'fixture');
|
||||
fs.mkdirSync(path.join(dir, 'static/build'), { recursive: true });
|
||||
fs.mkdirSync(path.join(dir, 'static/dist'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'static/build/app.js'), '');
|
||||
fs.writeFileSync(path.join(dir, 'static/dist/index.html'), '');
|
||||
const write = path.resolve('scripts/write-build-info.cjs');
|
||||
const verify = path.resolve('docker/verify-build.cjs');
|
||||
execFileSync(process.execPath, [write], { cwd: dir });
|
||||
execFileSync(process.execPath, [verify], { cwd: dir });
|
||||
const manifestPath = path.join(dir, 'static/build-info.json');
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath));
|
||||
for (const invalid of [
|
||||
{ ...manifest, sourceCommit: '0'.repeat(40) },
|
||||
{ ...manifest, dirty: true },
|
||||
{ ...manifest, lockfileSha256: '0'.repeat(64) },
|
||||
]) {
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(invalid));
|
||||
assert.notEqual(
|
||||
spawnSync(process.execPath, [verify], { cwd: dir }).status,
|
||||
0,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('release images receive the same-run artifact and verify it before use', () => {
|
||||
const yaml = require('js-yaml');
|
||||
const workflow = yaml.load(
|
||||
fs.readFileSync('.github/workflows/build-docker-image.yml', 'utf8'),
|
||||
);
|
||||
assert.equal(workflow.jobs['build-static'].needs, 'validate');
|
||||
const upload = workflow.jobs['build-static'].steps.find(step => step.uses?.startsWith('actions/upload-artifact@'));
|
||||
assert.equal(upload.with['include-hidden-files'], true);
|
||||
for (const name of [
|
||||
'build-alpine',
|
||||
'build-debian',
|
||||
'build-alpine310',
|
||||
'build-debian310',
|
||||
]) {
|
||||
const job = workflow.jobs[name];
|
||||
assert.equal(job.needs, 'build-static');
|
||||
const download = job.steps.find((step) =>
|
||||
step.uses?.startsWith('actions/download-artifact@'),
|
||||
);
|
||||
assert.equal(download.with.name, 'qinglong-static-${{ github.sha }}');
|
||||
assert.equal(download.with.path, 'static/');
|
||||
const build = job.steps.find((step) =>
|
||||
step.uses?.startsWith('docker/build-push-action@'),
|
||||
);
|
||||
assert.match(
|
||||
build.with['build-args'],
|
||||
/SOURCE_COMMIT=\$\{\{ github.sha \}\}/,
|
||||
);
|
||||
const dockerfile = fs.readFileSync(build.with.file, 'utf8');
|
||||
assert.doesNotMatch(dockerfile, /git clone.*qinglong-static/);
|
||||
assert.match(
|
||||
dockerfile,
|
||||
/git fetch --depth=1 origin "\$\{SOURCE_COMMIT\}"/,
|
||||
);
|
||||
assert.match(dockerfile, /node \/tmp\/verify-build.cjs/);
|
||||
}
|
||||
});
|
||||
|
||||
test('complete artifact manifests reject changed, missing, extra files and untracked build inputs', (t) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-build-manifest-'));
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
const git = (...args) =>
|
||||
execFileSync('git', args, { cwd: dir, stdio: 'pipe' });
|
||||
git('init');
|
||||
git('config', 'user.name', 'Build test');
|
||||
git('config', 'user.email', 'build-test@example.invalid');
|
||||
fs.writeFileSync(path.join(dir, '.gitignore'), 'static/\n');
|
||||
fs.writeFileSync(path.join(dir, 'pnpm-lock.yaml'), 'lockfileVersion: 6.0\n');
|
||||
git('add', '.');
|
||||
git('commit', '-m', 'fixture');
|
||||
const artifacts = [
|
||||
'build/app.js',
|
||||
'build/services/http.js',
|
||||
'dist/index.html',
|
||||
'dist/chunks/main.js',
|
||||
'dist/assets/main.css',
|
||||
'dist/.well-known/config',
|
||||
];
|
||||
for (const file of artifacts) {
|
||||
fs.mkdirSync(path.dirname(path.join(dir, 'static', file)), {
|
||||
recursive: true,
|
||||
});
|
||||
fs.writeFileSync(path.join(dir, 'static', file), file);
|
||||
}
|
||||
const write = path.resolve('scripts/write-build-info.cjs');
|
||||
const verify = path.resolve('docker/verify-build.cjs');
|
||||
const run = (script) =>
|
||||
spawnSync(process.execPath, [script], { cwd: dir, encoding: 'utf8' });
|
||||
assert.equal(run(write).status, 0);
|
||||
const manifestFile = path.join(dir, 'static/build-info.json');
|
||||
const original = fs.readFileSync(manifestFile, 'utf8');
|
||||
assert.deepEqual(
|
||||
Object.keys(JSON.parse(original).files).sort(),
|
||||
artifacts.sort(),
|
||||
);
|
||||
assert.equal(run(write).status, 0);
|
||||
assert.equal(fs.readFileSync(manifestFile, 'utf8'), original);
|
||||
assert.equal(run(verify).status, 0);
|
||||
for (const name of [
|
||||
'build/services/http.js',
|
||||
'dist/chunks/main.js',
|
||||
'dist/assets/main.css',
|
||||
'dist/.well-known/config',
|
||||
]) {
|
||||
const file = path.join(dir, 'static', name);
|
||||
fs.writeFileSync(file, 'stale');
|
||||
assert.notEqual(run(verify).status, 0);
|
||||
fs.unlinkSync(file);
|
||||
assert.notEqual(run(verify).status, 0);
|
||||
fs.writeFileSync(file, name);
|
||||
assert.equal(run(verify).status, 0);
|
||||
}
|
||||
const extra = path.join(dir, 'static/build/stale.js');
|
||||
fs.writeFileSync(extra, 'extra');
|
||||
assert.notEqual(run(verify).status, 0);
|
||||
fs.unlinkSync(extra);
|
||||
const source = path.join(dir, 'custom.config.js');
|
||||
fs.writeFileSync(source, 'untracked build input');
|
||||
assert.equal(run(write).status, 0);
|
||||
assert.equal(JSON.parse(fs.readFileSync(manifestFile)).dirty, true);
|
||||
assert.notEqual(run(verify).status, 0);
|
||||
fs.unlinkSync(source);
|
||||
assert.equal(run(write).status, 0);
|
||||
assert.equal(run(verify).status, 0);
|
||||
fs.symlinkSync(path.join(dir, 'pnpm-lock.yaml'), extra);
|
||||
assert.notEqual(run(write).status, 0);
|
||||
assert.notEqual(run(verify).status, 0);
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const source = fs.readFileSync('shell/share.sh', 'utf8');
|
||||
function extract(name) {
|
||||
const start = source.indexOf(name + '() {');
|
||||
assert.ok(start >= 0);
|
||||
return source.slice(start, source.indexOf('\n}', start) + 2);
|
||||
}
|
||||
function fixture(t, content) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-env-names-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const file = path.join(root, 'env file.sh');
|
||||
fs.writeFileSync(file, content);
|
||||
return {
|
||||
root,
|
||||
file,
|
||||
run(script) {
|
||||
const r = spawnSync(
|
||||
'/bin/bash',
|
||||
[
|
||||
'-ec',
|
||||
extract('get_env_array') +
|
||||
'\n' +
|
||||
extract('clear_env') +
|
||||
'\n' +
|
||||
script,
|
||||
],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, file_env: file, FIXTURE_ROOT: root },
|
||||
},
|
||||
);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
return r.stdout;
|
||||
},
|
||||
};
|
||||
}
|
||||
test('matches legacy pipeline for whitespace, malformed lines, CRLF and final unterminated line', (t) => {
|
||||
const f = fixture(
|
||||
t,
|
||||
'# comment\nexport A=one\nexport B="two words"\n export IGNORED=1\nexport\tIGNORED2=1\nexport C = spaced\nexport \nexport -x X=1\nexport D=a=b\nexport E=crlf\r\nexport F\r\nexport G=last',
|
||||
);
|
||||
assert.equal(
|
||||
f.run('get_env_array; printf "%s\\0" "${exported_variables[@]}"'),
|
||||
f.run(
|
||||
'while IFS= read -r line; do printf "%s\\0" "$line"; done < <(grep "^export " "$file_env" | awk \'{print $2}\' | cut -d= -f1)',
|
||||
),
|
||||
);
|
||||
});
|
||||
test('resets prior names for an empty file', (t) => {
|
||||
const f = fixture(t, '');
|
||||
assert.equal(
|
||||
f.run(
|
||||
'exported_variables=(OLD); get_env_array; echo "${#exported_variables[@]}"',
|
||||
),
|
||||
'0\n',
|
||||
);
|
||||
});
|
||||
test('extracting names never evaluates command substitutions or shell syntax in values', (t) => {
|
||||
const f = fixture(
|
||||
t,
|
||||
'export A=$(touch "$FIXTURE_ROOT/unsafe")\nexport B=`touch "$FIXTURE_ROOT/unsafe2"`\n',
|
||||
);
|
||||
assert.equal(
|
||||
f.run('get_env_array; printf "%s\\n" "${exported_variables[@]}"'),
|
||||
'A\nB\n',
|
||||
);
|
||||
assert.equal(fs.existsSync(path.join(f.root, 'unsafe')), false);
|
||||
assert.equal(fs.existsSync(path.join(f.root, 'unsafe2')), false);
|
||||
});
|
||||
test('clear_env removes listed exports and preserves unrelated values', (t) => {
|
||||
const f = fixture(t, 'export USER_ONE=1\nexport USER_TWO=2\n');
|
||||
f.run(
|
||||
'USER_ONE=old; USER_TWO=old; UNRELATED=kept; get_env_array; clear_env; [[ ! ${USER_ONE+x} && ! ${USER_TWO+x} && "$UNRELATED" == kept ]]',
|
||||
);
|
||||
});
|
||||
test('large configuration preserves order and duplicate names', (t) => {
|
||||
const text = Array.from(
|
||||
{ length: 5000 },
|
||||
(_, i) => 'export V' + (i % 1000) + '=value with spaces',
|
||||
).join('\n');
|
||||
const f = fixture(t, text);
|
||||
const lines = f
|
||||
.run('get_env_array; printf "%s\\n" "${exported_variables[@]}"')
|
||||
.trim()
|
||||
.split('\n');
|
||||
assert.equal(lines.length, 5000);
|
||||
assert.deepEqual(
|
||||
lines,
|
||||
Array.from({ length: 5000 }, (_, i) => 'V' + (i % 1000)),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const { spawn } = require('node:child_process');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const { PassThrough } = require('node:stream');
|
||||
const fs = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const { observeChildProcess } = require('../../back/shared/childProcess');
|
||||
const { LogStreamManager } = require('../../back/shared/logStreamManager');
|
||||
|
||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const logger = { info() {}, error() {} };
|
||||
|
||||
test(
|
||||
'spawn failure settles without exit, and the next scheduled run can proceed',
|
||||
{ timeout: 3000 },
|
||||
async () => {
|
||||
let releases = 0;
|
||||
let active = 0;
|
||||
const { runCron } = load(path.resolve('back/shared/runCron.ts'), {
|
||||
'cross-spawn': {
|
||||
spawn: () => spawn('true', { shell: '/nonexistent-ql-test-shell' }),
|
||||
},
|
||||
'./pLimit': {
|
||||
runWithCronLimit: async (_cron, fn) => {
|
||||
active++;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
active--;
|
||||
}
|
||||
},
|
||||
removeQueuedCron: () => releases++,
|
||||
},
|
||||
'../loaders/logger': logger,
|
||||
'../data/cron': {
|
||||
CrontabModel: { findOne: async () => null },
|
||||
CrontabStatus: {},
|
||||
},
|
||||
'../data/runningInstance': {
|
||||
RunningInstanceModel: {},
|
||||
InstanceStatus: {},
|
||||
},
|
||||
'../config/util': { killTask: async () => {} },
|
||||
});
|
||||
await runCron('true', { id: '1' });
|
||||
await runCron('true', { id: '2' });
|
||||
assert.equal(active, 0);
|
||||
assert.equal(releases, 2);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'completion waits for slow log consumers and preserves the final output',
|
||||
{ timeout: 5000 },
|
||||
async () => {
|
||||
const chunks = [];
|
||||
const child = spawn(process.execPath, [
|
||||
'-e',
|
||||
'process.stdout.write("尾行\\n"); process.stderr.write("错误\\n")',
|
||||
]);
|
||||
const observed = observeChildProcess(child, {
|
||||
onStart: () => delay(25),
|
||||
onStdout: async (message) => {
|
||||
await delay(40);
|
||||
chunks.push(message);
|
||||
},
|
||||
onStderr: async (message) => {
|
||||
await delay(30);
|
||||
chunks.push(message);
|
||||
},
|
||||
});
|
||||
const result = await observed.completed;
|
||||
assert.equal(result.code, 0);
|
||||
assert.equal(result.error, undefined);
|
||||
assert.match(chunks.join(''), /尾行/);
|
||||
assert.match(chunks.join(''), /错误/);
|
||||
},
|
||||
);
|
||||
|
||||
test('exit is not completion, and UTF-8 split across writes remains intact', async () => {
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new PassThrough();
|
||||
child.stderr = new PassThrough();
|
||||
child.pid = 123;
|
||||
const chunks = [];
|
||||
const observed = observeChildProcess(child, {
|
||||
onStdout: async (data) => chunks.push(data),
|
||||
});
|
||||
child.emit('spawn');
|
||||
let done = false;
|
||||
observed.completed.then(() => {
|
||||
done = true;
|
||||
});
|
||||
child.emit('exit', 0, null);
|
||||
const data = Buffer.from('末尾中文');
|
||||
child.stdout.write(data.subarray(0, 2));
|
||||
await delay(10);
|
||||
assert.equal(done, false);
|
||||
child.stdout.end(data.subarray(2));
|
||||
child.stderr.end();
|
||||
child.emit('close', 0, null);
|
||||
await observed.completed;
|
||||
assert.equal(chunks.join(''), '末尾中文');
|
||||
});
|
||||
|
||||
test(
|
||||
'failed log sink drains large output instead of blocking the child',
|
||||
{ timeout: 5000 },
|
||||
async () => {
|
||||
const child = spawn(process.execPath, [
|
||||
'-e',
|
||||
'process.stdout.write("x".repeat(2 * 1024 * 1024))',
|
||||
]);
|
||||
const { completed } = observeChildProcess(child, {
|
||||
onStdout: async () => {
|
||||
throw new Error('ENOSPC');
|
||||
},
|
||||
});
|
||||
const result = await completed;
|
||||
assert.equal(result.code, 0);
|
||||
assert.equal(result.error.message, 'ENOSPC');
|
||||
},
|
||||
);
|
||||
|
||||
function scheduleFixture() {
|
||||
let active = 0;
|
||||
const limit = async (_params, fn) => {
|
||||
active++;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
active--;
|
||||
}
|
||||
};
|
||||
const Schedule = load(path.resolve('back/services/schedule.ts'), {
|
||||
'../shared/pLimit': {
|
||||
runWithScriptLimit: limit,
|
||||
runWithSystemLimit: limit,
|
||||
},
|
||||
'cross-spawn': {
|
||||
spawn: () =>
|
||||
spawn(process.execPath, [
|
||||
'-e',
|
||||
'setTimeout(() => process.stdout.write("done"), 150)',
|
||||
]),
|
||||
},
|
||||
}).default;
|
||||
return { service: new Schedule(logger), active: () => active };
|
||||
}
|
||||
|
||||
test(
|
||||
'PID response keeps queue capacity occupied until cleanup completes',
|
||||
{ timeout: 4000 },
|
||||
async () => {
|
||||
const fixture = scheduleFixture();
|
||||
let finish;
|
||||
const finished = new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const pid = await fixture.service.runTask(
|
||||
'ignored',
|
||||
{
|
||||
onEnd: async () => {
|
||||
await delay(20);
|
||||
finish();
|
||||
},
|
||||
},
|
||||
{ id: 'script', runOrigin: 'script' },
|
||||
'start',
|
||||
);
|
||||
assert.ok(pid > 0);
|
||||
assert.equal(fixture.active(), 1);
|
||||
await finished;
|
||||
await delay(0);
|
||||
assert.equal(fixture.active(), 0);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'before/error/end callback failures settle and release capacity',
|
||||
{ timeout: 3000 },
|
||||
async () => {
|
||||
const fixture = scheduleFixture();
|
||||
let ended = 0;
|
||||
const result = await fixture.service.runTask(
|
||||
'ignored',
|
||||
{
|
||||
onBefore: async () => {
|
||||
throw new Error('setup failed');
|
||||
},
|
||||
onError: async () => {
|
||||
throw new Error('sink failed');
|
||||
},
|
||||
onEnd: async (child) => {
|
||||
assert.equal(child, undefined);
|
||||
ended++;
|
||||
throw new Error('cleanup failed');
|
||||
},
|
||||
},
|
||||
{ id: 'system', runOrigin: 'system' },
|
||||
);
|
||||
assert.equal(result.error.message, 'setup failed');
|
||||
assert.equal(ended, 1);
|
||||
assert.equal(fixture.active(), 0);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'concurrent writes and closes preserve all bytes, and failed files can be closed',
|
||||
{ timeout: 5000 },
|
||||
async (t) => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-log-lifecycle-'));
|
||||
t.after(() => fs.rm(dir, { recursive: true, force: true }));
|
||||
const manager = new LogStreamManager(dir);
|
||||
const file = path.join(dir, 'out.log');
|
||||
const lines = Array.from({ length: 500 }, (_, i) => `${i}:末尾\n`);
|
||||
const writes = lines.map((line) => manager.write(file, line));
|
||||
await Promise.all([
|
||||
...writes,
|
||||
manager.closeStream(file),
|
||||
manager.closeStream(file),
|
||||
]);
|
||||
assert.equal(await fs.readFile(file, 'utf8'), lines.join(''));
|
||||
assert.equal(manager.getOpenStreamCount(), 0);
|
||||
const invalid = path.join(dir, 'missing', 'out.log');
|
||||
await assert.rejects(manager.write(invalid, 'fail'), /ENOENT/);
|
||||
await assert.rejects(manager.closeStream(invalid), /ENOENT/);
|
||||
assert.equal(manager.getOpenStreamCount(), 0);
|
||||
await manager.write(file, 'reopened');
|
||||
await manager.closeAll();
|
||||
assert.match(await fs.readFile(file, 'utf8'), /reopened$/);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,31 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const express = require('express');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const Http = load('back/services/http.ts', {
|
||||
'../config': {bindHost: '127.0.0.1'},
|
||||
'../loaders/logger': {debug(){},warn(){},error(){}},
|
||||
'./metrics': {metricsService: {record(){}}},
|
||||
typedi: {Service:()=>x=>x},
|
||||
}).HttpServerService;
|
||||
test('HTTP listener preserves private binding and releases its port after shutdown',async(t)=>{
|
||||
const app=express();app.get('/',(_q,r)=>r.send('ok'));
|
||||
const first=new Http();const server=await first.initialize(app,0);
|
||||
t.after(()=>first.shutdown());
|
||||
assert.equal(server.address().address,'127.0.0.1');
|
||||
const port=server.address().port;
|
||||
const response=await fetch(`http://127.0.0.1:${port}/`,{headers:{connection:'close'}});assert.equal(await response.text(),'ok');
|
||||
await assert.rejects(new Http().initialize(app,port),e=>e.code==='EADDRINUSE');
|
||||
await first.shutdown();const replacement=new Http();t.after(()=>replacement.shutdown());
|
||||
const next=await replacement.initialize(app,port);assert.equal(next.address().port,port);
|
||||
});
|
||||
test('custom cluster deployments can restore shared listening',async(t)=>{
|
||||
const previous=process.env.QL_HTTP_SHARED_LISTEN;
|
||||
t.after(()=>{if(previous===undefined)delete process.env.QL_HTTP_SHARED_LISTEN;else process.env.QL_HTTP_SHARED_LISTEN=previous;});
|
||||
for(const value of [undefined,'true']){
|
||||
if(value===undefined)delete process.env.QL_HTTP_SHARED_LISTEN;else process.env.QL_HTTP_SHARED_LISTEN=value;
|
||||
let options;const service=new Http();const app=express();const listen=app.listen.bind(app);
|
||||
app.listen=(opts,cb)=>{options=opts;return listen(opts,cb);};
|
||||
await service.initialize(app,0);assert.equal(options.exclusive,value!=='true');await service.shutdown();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const fs = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const { LogStreamManager } = require('../../back/shared/logStreamManager');
|
||||
|
||||
async function fixture(t) {
|
||||
const base = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-log-boundary-'));
|
||||
t.after(() => fs.rm(base, { recursive: true, force: true }));
|
||||
const root = path.join(base, 'log');
|
||||
const outside = path.join(base, 'log-other');
|
||||
await fs.mkdir(root);
|
||||
await fs.mkdir(outside);
|
||||
const victim = path.join(outside, 'victim.log');
|
||||
await fs.writeFile(victim, 'unchanged');
|
||||
await fs.symlink(outside, path.join(root, 'escape-dir'));
|
||||
await fs.symlink(victim, path.join(root, 'escape-file'));
|
||||
await fs.symlink(
|
||||
path.join(outside, 'missing.log'),
|
||||
path.join(root, 'dangling'),
|
||||
);
|
||||
const invalid = [
|
||||
path.join(root, '..', 'log-other', 'new.log'),
|
||||
path.join(root, 'escape-dir', 'new.log'),
|
||||
path.join(root, 'escape-file'),
|
||||
path.join(root, 'dangling'),
|
||||
root,
|
||||
path.join(root, 'bad\0name'),
|
||||
];
|
||||
return { root, outside, victim, invalid };
|
||||
}
|
||||
|
||||
test('log streams reject traversal, sibling prefixes and escaping symlinks before writing', async (t) => {
|
||||
const { root, outside, victim, invalid } = await fixture(t);
|
||||
const manager = new LogStreamManager(root);
|
||||
for (const target of invalid) {
|
||||
await assert.rejects(
|
||||
manager.write(target, 'overwrite'),
|
||||
/outside the log directory/,
|
||||
);
|
||||
await assert.rejects(
|
||||
manager.closeStream(target),
|
||||
/outside the log directory/,
|
||||
);
|
||||
assert.equal(manager.getOpenStreamCount(), 0);
|
||||
}
|
||||
assert.equal(await fs.readFile(victim, 'utf8'), 'unchanged');
|
||||
assert.deepEqual(await fs.readdir(outside), ['victim.log']);
|
||||
const folder = path.join(root, '中文 日志');
|
||||
await fs.mkdir(folder);
|
||||
const log = path.join(folder, 'task.log');
|
||||
await Promise.all([
|
||||
manager.write(log, '开始\n'),
|
||||
manager.write(log, '结束\n'),
|
||||
]);
|
||||
await manager.closeAll();
|
||||
assert.equal(await fs.readFile(log, 'utf8'), '开始\n结束\n');
|
||||
});
|
||||
|
||||
test('log initialization rejects unsafe paths before mkdir or file writes', async (t) => {
|
||||
const { root, outside, victim, invalid } = await fixture(t);
|
||||
const { handleLogPath } = load(path.resolve('back/config/util.ts'), {
|
||||
'./index': { logPath: root },
|
||||
'./share': {},
|
||||
'../loaders/logger': {},
|
||||
'../shared/utils': {
|
||||
writeFileWithLock: (file, data) => fs.writeFile(file, data),
|
||||
},
|
||||
'../data/dependence': { DependenceTypes: {} },
|
||||
});
|
||||
for (const target of invalid) {
|
||||
await assert.rejects(
|
||||
handleLogPath(target, 'overwrite'),
|
||||
/outside the log directory/,
|
||||
);
|
||||
}
|
||||
await assert.rejects(
|
||||
handleLogPath('../log-other/new/sub/task.log', 'overwrite'),
|
||||
/outside the log directory/,
|
||||
);
|
||||
assert.equal(await fs.readFile(victim, 'utf8'), 'unchanged');
|
||||
assert.deepEqual(await fs.readdir(outside), ['victim.log']);
|
||||
const log = await handleLogPath('中文 日志/nested/task.log', 'initial');
|
||||
assert.equal(await fs.readFile(log, 'utf8'), 'initial');
|
||||
assert.equal(await handleLogPath(log, 'ignored'), log);
|
||||
assert.equal(await fs.readFile(log, 'utf8'), 'initial');
|
||||
});
|
||||
|
||||
test('manual execution rejects escaping log names before creating directories or spawning', async (t) => {
|
||||
const { root, outside, victim } = await fixture(t);
|
||||
let spawned = 0;
|
||||
let releases = 0;
|
||||
const errors = [];
|
||||
const CronService = load(path.resolve('back/services/cron.ts'), {
|
||||
'../config': { logPath: root },
|
||||
'../data/cron': {
|
||||
CrontabStatus: { queued: 3, idle: 1 },
|
||||
CrontabModel: { update: async () => {} },
|
||||
},
|
||||
'../data/runningInstance': { RunningInstanceModel: {}, InstanceStatus: {} },
|
||||
'../config/util': {},
|
||||
'../config/const': {},
|
||||
'../schedule/client': {},
|
||||
'../shared/pLimit': {
|
||||
manualRunWithCronLimit: async (fn) => {
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
releases++;
|
||||
}
|
||||
},
|
||||
},
|
||||
'../shared/utils': {},
|
||||
'../shared/i18n': { t: (s) => s },
|
||||
'../shared/logReader': {},
|
||||
'../shared/logStreamManager': {
|
||||
logStreamManager: { closeStream: async () => {} },
|
||||
},
|
||||
'cross-spawn': {
|
||||
spawn: () => {
|
||||
spawned++;
|
||||
throw new Error('must not spawn');
|
||||
},
|
||||
},
|
||||
}).default;
|
||||
const service = new CronService({
|
||||
info() {},
|
||||
error: (...args) => errors.push(args),
|
||||
});
|
||||
for (const log_name of [
|
||||
'../log-other/new',
|
||||
outside,
|
||||
'escape-dir/new',
|
||||
'dangling',
|
||||
'bad\0name',
|
||||
]) {
|
||||
service.getDb = async () => ({
|
||||
id: 1,
|
||||
status: 3,
|
||||
command: 'ignored',
|
||||
log_path: '',
|
||||
log_name,
|
||||
});
|
||||
await service.runSingle(1);
|
||||
}
|
||||
assert.equal(spawned, 0);
|
||||
assert.equal(releases, 5);
|
||||
assert.equal(
|
||||
errors.filter((args) =>
|
||||
args.includes('Log path is outside the log directory'),
|
||||
).length,
|
||||
5,
|
||||
);
|
||||
assert.equal(await fs.readFile(victim, 'utf8'), 'unchanged');
|
||||
assert.deepEqual(await fs.readdir(outside), ['victim.log']);
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { spawn } = require('node:child_process');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const { LogStreamManager } = require('../../back/shared/logStreamManager');
|
||||
|
||||
test(
|
||||
'manual execution captures a short child before slow status storage and flushes before release',
|
||||
{ timeout: 5000 },
|
||||
async (t) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-manual-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const logs = new LogStreamManager(root);
|
||||
const updates = [];
|
||||
let active = 0;
|
||||
let failSpawn = false;
|
||||
const CronService = load(path.resolve('back/services/cron.ts'), {
|
||||
'../config': { logPath: root },
|
||||
'../data/cron': {
|
||||
CrontabStatus: { queued: 3, running: 0, idle: 1 },
|
||||
CrontabModel: {
|
||||
update: async (data, options) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
updates.push({ data, options });
|
||||
return [1];
|
||||
},
|
||||
},
|
||||
},
|
||||
'../data/runningInstance': {
|
||||
RunningInstanceModel: {},
|
||||
InstanceStatus: {},
|
||||
},
|
||||
'../config/util': { getUniqPath: async () => 'task' },
|
||||
'../config/const': { TASK_PREFIX: 'task ', QL_PREFIX: 'ql ' },
|
||||
'../schedule/client': {},
|
||||
'../shared/pLimit': {
|
||||
manualRunWithCronLimit: async (fn) => {
|
||||
active++;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
active--;
|
||||
}
|
||||
},
|
||||
},
|
||||
'../shared/utils': {},
|
||||
'../shared/i18n': { t: (s) => s },
|
||||
'../shared/logReader': {},
|
||||
'../shared/logStreamManager': { logStreamManager: logs },
|
||||
'cross-spawn': {
|
||||
spawn: () =>
|
||||
failSpawn
|
||||
? spawn('true', { shell: '/nonexistent-ql-manual-shell' })
|
||||
: spawn(process.execPath, [
|
||||
'-e',
|
||||
'process.stdout.write("末尾\\n")',
|
||||
]),
|
||||
},
|
||||
}).default;
|
||||
const service = new CronService({ info() {}, error() {} });
|
||||
service.getDb = async () => ({
|
||||
id: 1,
|
||||
status: 3,
|
||||
command: 'ignored',
|
||||
log_path: '',
|
||||
});
|
||||
service.makeCommand = () => 'ignored';
|
||||
await service.runSingle(1);
|
||||
const [file] = await fs.readdir(path.join(root, 'task'));
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(root, 'task', file), 'utf8'),
|
||||
'末尾\n',
|
||||
);
|
||||
assert.equal(logs.getOpenStreamCount(), 0);
|
||||
assert.equal(active, 0);
|
||||
assert.equal(updates.at(-1).data.status, 1);
|
||||
failSpawn = true;
|
||||
await service.runSingle(1);
|
||||
assert.equal(active, 0);
|
||||
assert.equal(updates.at(-1).data.status, 1);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,215 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawn } = require('node:child_process');
|
||||
const { Sequelize, DataTypes } = require('sequelize');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const { killTask } = require('../../back/config/util');
|
||||
const { LogStreamManager } = require('../../back/shared/logStreamManager');
|
||||
|
||||
for (const conflict of ['stop', 'newer-queue', 'stop-requeue']) {
|
||||
test(
|
||||
`manual startup loses its conditional claim after ${conflict} and terminates the late child`,
|
||||
{ timeout: 10000 },
|
||||
async (t) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-manual-claim-'));
|
||||
const db = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
storage: ':memory:',
|
||||
logging: false,
|
||||
});
|
||||
t.after(async () => {
|
||||
await db.close();
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
const crons = db.define('Cron', {
|
||||
status: DataTypes.INTEGER,
|
||||
pid: DataTypes.INTEGER,
|
||||
log_path: DataTypes.STRING,
|
||||
queued_token: DataTypes.STRING,
|
||||
command: DataTypes.STRING,
|
||||
});
|
||||
const instances = db.define('Instance', {
|
||||
cron_id: DataTypes.INTEGER,
|
||||
status: DataTypes.INTEGER,
|
||||
pid: DataTypes.INTEGER,
|
||||
});
|
||||
await db.sync();
|
||||
await crons.create({
|
||||
id: 1,
|
||||
status: 3,
|
||||
pid: null,
|
||||
log_path: 'previous.log',
|
||||
command: 'ignored',
|
||||
});
|
||||
let release,
|
||||
entered,
|
||||
child,
|
||||
released = 0;
|
||||
const pending = new Promise((resolve) => (entered = resolve));
|
||||
const gate = new Promise((resolve) => (release = resolve));
|
||||
const logs = new LogStreamManager(root);
|
||||
t.after(() => logs.closeAll());
|
||||
const CronService = load('back/services/cron.ts', {
|
||||
'../config': { logPath: root },
|
||||
'../data/cron': {
|
||||
CrontabModel: crons,
|
||||
CrontabStatus: { queued: 3, running: 0, idle: 1 },
|
||||
},
|
||||
'../data/runningInstance': {
|
||||
RunningInstanceModel: instances,
|
||||
InstanceStatus: { running: 0, stopped: 2 },
|
||||
},
|
||||
'../config/util': {
|
||||
getUniqPath: async () => {
|
||||
entered();
|
||||
await gate;
|
||||
return 'task';
|
||||
},
|
||||
killTask,
|
||||
},
|
||||
'../config/const': {},
|
||||
'../schedule/client': {},
|
||||
'../shared/pLimit': {
|
||||
manualRunWithCronLimit: async (fn) => {
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
released++;
|
||||
}
|
||||
},
|
||||
},
|
||||
'../shared/utils': {},
|
||||
'../shared/i18n': { t: (s) => s },
|
||||
'../shared/logReader': {},
|
||||
'../shared/logStreamManager': { logStreamManager: logs },
|
||||
'cross-spawn': {
|
||||
spawn: () => {
|
||||
child = spawn(process.execPath, [
|
||||
'-e',
|
||||
'setInterval(() => {}, 1000)',
|
||||
]);
|
||||
t.after(() => {
|
||||
if (child.exitCode === null && child.signalCode === null)
|
||||
child.kill('SIGKILL');
|
||||
});
|
||||
return child;
|
||||
},
|
||||
},
|
||||
}).default;
|
||||
const service = new CronService({ info() {}, error() {} });
|
||||
service.getDb = async () =>
|
||||
(await crons.findByPk(1)).get({ plain: true });
|
||||
service.makeCommand = () => 'ignored';
|
||||
const running = service.runSingle(1);
|
||||
await pending;
|
||||
if (conflict === 'stop-requeue') {
|
||||
await service.stop([1]);
|
||||
// Queue again through the real API; leave the new runner pending.
|
||||
const pendingRuns = [];
|
||||
service.runSingle = (id, token) => pendingRuns.push({ id, token });
|
||||
await service.run([1]);
|
||||
assert.equal(pendingRuns.length, 1);
|
||||
assert.equal(
|
||||
pendingRuns[0].token,
|
||||
(await crons.findByPk(1)).queued_token,
|
||||
);
|
||||
} else if (conflict === 'stop') await service.stop([1]);
|
||||
else await crons.update({ log_path: 'newer.log' }, { where: { id: 1 } });
|
||||
release();
|
||||
const result = await running;
|
||||
assert.match(result.error.message, /stopped or superseded/);
|
||||
assert.ok(child.signalCode || child.exitCode !== null);
|
||||
assert.throws(() => process.kill(child.pid, 0), { code: 'ESRCH' });
|
||||
const row = await crons.findByPk(1);
|
||||
assert.equal(row.status, conflict === 'stop' ? 1 : 3);
|
||||
assert.equal(
|
||||
row.log_path,
|
||||
conflict === 'newer-queue' ? 'newer.log' : 'previous.log',
|
||||
);
|
||||
assert.equal(row.pid, null);
|
||||
assert.equal(released, 1);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
'verified termination waits for a SIGTERM-resistant target to exit',
|
||||
{ timeout: 5000 },
|
||||
async (t) => {
|
||||
const child = spawn(process.execPath, [
|
||||
'-e',
|
||||
'process.on("SIGTERM",()=>{}); process.stdout.write("ready"); setInterval(()=>{},1000)',
|
||||
]);
|
||||
t.after(() => {
|
||||
if (child.exitCode === null && child.signalCode === null)
|
||||
child.kill('SIGKILL');
|
||||
});
|
||||
await new Promise((resolve) => child.stdout.once('data', resolve));
|
||||
await killTask(child.pid, true);
|
||||
assert.throws(() => process.kill(child.pid, 0), { code: 'ESRCH' });
|
||||
assert.equal(child.signalCode, 'SIGKILL');
|
||||
},
|
||||
);
|
||||
|
||||
test('a runner waiting for a concurrency slot cannot adopt a newer queued generation', async (t) => {
|
||||
const db = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
storage: ':memory:',
|
||||
logging: false,
|
||||
});
|
||||
t.after(() => db.close());
|
||||
const crons = db.define('Cron', {
|
||||
status: DataTypes.INTEGER,
|
||||
pid: DataTypes.INTEGER,
|
||||
log_path: DataTypes.STRING,
|
||||
queued_token: DataTypes.STRING,
|
||||
});
|
||||
await db.sync();
|
||||
await crons.create({ id: 1, status: 1, log_path: 'same.log' });
|
||||
const waiting = [];
|
||||
const CronService = load('back/services/cron.ts', {
|
||||
'../config': {},
|
||||
'../data/cron': {
|
||||
CrontabModel: crons,
|
||||
CrontabStatus: { queued: 3, idle: 1, running: 0 },
|
||||
},
|
||||
'../data/runningInstance': {
|
||||
RunningInstanceModel: { findAll: async () => [] },
|
||||
InstanceStatus: { running: 0 },
|
||||
},
|
||||
'../config/util': {
|
||||
getUniqPath: () => assert.fail('stale runner must not prepare a child'),
|
||||
},
|
||||
'../config/const': {},
|
||||
'../schedule/client': {},
|
||||
'../shared/pLimit': {
|
||||
manualRunWithCronLimit: (fn) => {
|
||||
waiting.push(fn);
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
'../shared/utils': {},
|
||||
'../shared/i18n': {},
|
||||
'../shared/logReader': {},
|
||||
'../shared/logStreamManager': {},
|
||||
'cross-spawn': { spawn: () => assert.fail('stale runner must not spawn') },
|
||||
}).default;
|
||||
const errors = [];
|
||||
const service = new CronService({ error: (...args) => errors.push(args) });
|
||||
await service.run([1]);
|
||||
const first = (await crons.findByPk(1)).queued_token;
|
||||
await service.stop([1]);
|
||||
await service.run([1]);
|
||||
const second = (await crons.findByPk(1)).queued_token;
|
||||
assert.notEqual(first, second);
|
||||
assert.equal(waiting.length, 2);
|
||||
await waiting[0]();
|
||||
const row = await crons.findByPk(1);
|
||||
assert.equal(row.status, 3);
|
||||
assert.equal(row.queued_token, second);
|
||||
assert.equal(row.log_path, 'same.log');
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync, spawn } = require('node:child_process');
|
||||
const helper = path.resolve('shell/node_path_cache.sh');
|
||||
function fixture(t) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-node-path-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const bin = path.join(root, 'bin');
|
||||
fs.mkdirSync(bin);
|
||||
fs.symlinkSync(process.execPath, path.join(bin, 'node'));
|
||||
const calls = path.join(root, 'calls');
|
||||
fs.writeFileSync(
|
||||
path.join(bin, 'pnpm'),
|
||||
'#!/bin/bash\necho call >> "$CALLS"\n[[ "${FAIL:-0}" == 1 ]] && exit 1\nprintf "%s\\n" "${ANSWER:-/test/global/node_modules}"\n',
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
const env = {
|
||||
...process.env,
|
||||
HOME: root,
|
||||
PATH: `${bin}:${process.env.PATH}`,
|
||||
dir_tmp: path.join(root, 'cache'),
|
||||
CALLS: calls,
|
||||
HELPER: helper,
|
||||
};
|
||||
const command = '. "$HELPER"; ql_get_node_global_path';
|
||||
const run = (extra = {}, code = command) =>
|
||||
spawnSync('/bin/bash', ['-uc', code], {
|
||||
cwd: root,
|
||||
env: { ...env, ...extra },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
const count = () =>
|
||||
fs.existsSync(calls)
|
||||
? fs.readFileSync(calls, 'utf8').trim().split('\n').length
|
||||
: 0;
|
||||
return { root, bin, env, command, run, count };
|
||||
}
|
||||
test('warm cache avoids pnpm, preserves spaces, and never evaluates cached text', (t) => {
|
||||
const f = fixture(t);
|
||||
const answer = '/test/path with spaces/$(touch injected)';
|
||||
assert.equal(f.run({ ANSWER: answer }).stdout.trim(), answer);
|
||||
assert.equal(f.run({ ANSWER: '/different' }).stdout.trim(), answer);
|
||||
assert.equal(f.count(), 1);
|
||||
assert.equal(fs.existsSync(path.join(f.root, 'injected')), false);
|
||||
});
|
||||
test('config, environment, cwd, and executable changes invalidate discovery', (t) => {
|
||||
const f = fixture(t);
|
||||
f.run();
|
||||
fs.writeFileSync(path.join(f.root, '.npmrc'), 'global-dir=/other\n');
|
||||
f.run();
|
||||
assert.equal(f.count(), 2);
|
||||
f.run({ npm_config_global_dir: '/third' });
|
||||
assert.equal(f.count(), 3);
|
||||
f.run({ PNPM_HOME: '/fourth' });
|
||||
assert.equal(f.count(), 4);
|
||||
fs.mkdirSync(path.join(f.root, 'child'));
|
||||
f.run({}, 'cd child; ' + f.command);
|
||||
assert.equal(f.count(), 5);
|
||||
f.run();
|
||||
const before = f.count();
|
||||
fs.appendFileSync(path.join(f.bin, 'pnpm'), '# upgrade\n');
|
||||
f.run();
|
||||
assert.equal(f.count(), before + 1);
|
||||
});
|
||||
test('expired or malformed records refresh and failed lookups are not cached', (t) => {
|
||||
const f = fixture(t);
|
||||
f.run();
|
||||
const file = path.join(
|
||||
f.env.dir_tmp,
|
||||
fs.readdirSync(f.env.dir_tmp).find((name) => name.endsWith('.cache')),
|
||||
);
|
||||
let lines = fs.readFileSync(file, 'utf8').split('\n');
|
||||
lines[1] = '0';
|
||||
fs.writeFileSync(file, lines.join('\n'));
|
||||
f.run();
|
||||
assert.equal(f.count(), 2);
|
||||
fs.writeFileSync(file, lines[0] + '\n08\n/incorrect\n');
|
||||
assert.equal(f.run().status, 0);
|
||||
assert.equal(f.count(), 3);
|
||||
fs.writeFileSync(file, lines[0]);
|
||||
assert.equal(f.run().status, 0);
|
||||
assert.equal(f.count(), 4);
|
||||
fs.rmSync(file);
|
||||
assert.equal(f.run({ FAIL: '1' }).status, 1);
|
||||
assert.equal(fs.existsSync(file), false);
|
||||
assert.equal(f.run().status, 0);
|
||||
assert.equal(f.count(), 6);
|
||||
});
|
||||
test('disabled or unavailable cache falls back without changing discovery output', (t) => {
|
||||
const f = fixture(t);
|
||||
for (let i = 0; i < 2; i++)
|
||||
assert.equal(
|
||||
f.run({ QL_NODE_PATH_CACHE: '0' }).stdout.trim(),
|
||||
'/test/global/node_modules',
|
||||
);
|
||||
assert.equal(f.count(), 2);
|
||||
assert.equal(fs.existsSync(f.env.dir_tmp), false);
|
||||
const blocked = path.join(f.root, 'file');
|
||||
fs.writeFileSync(blocked, '');
|
||||
assert.equal(
|
||||
f.run({ dir_tmp: blocked }).stdout.trim(),
|
||||
'/test/global/node_modules',
|
||||
);
|
||||
assert.equal(f.count(), 3);
|
||||
});
|
||||
test('concurrent refreshes publish complete records and do not overwrite symlink targets', async (t) => {
|
||||
const f = fixture(t);
|
||||
await Promise.all(
|
||||
Array.from(
|
||||
{ length: 8 },
|
||||
() =>
|
||||
new Promise((resolve, reject) => {
|
||||
const p = spawn('/bin/bash', ['-uc', f.command], {
|
||||
cwd: f.root,
|
||||
env: f.env,
|
||||
});
|
||||
let out = '';
|
||||
p.stdout.on('data', (c) => (out += c));
|
||||
p.once('error', reject);
|
||||
p.once('close', (code) => {
|
||||
try {
|
||||
assert.equal(code, 0);
|
||||
assert.equal(out.trim(), '/test/global/node_modules');
|
||||
resolve();
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
const before = f.count();
|
||||
f.run();
|
||||
assert.equal(f.count(), before);
|
||||
const files = fs
|
||||
.readdirSync(f.env.dir_tmp)
|
||||
.filter((file) => file.endsWith('.cache'));
|
||||
assert.equal(files.length, 1);
|
||||
const file = path.join(f.env.dir_tmp, files[0]);
|
||||
fs.rmSync(file);
|
||||
const victim = path.join(f.root, 'victim');
|
||||
fs.writeFileSync(victim, 'unchanged');
|
||||
fs.symlinkSync(victim, file);
|
||||
assert.equal(f.run().status, 0);
|
||||
assert.equal(fs.readFileSync(victim, 'utf8'), 'unchanged');
|
||||
assert.equal(fs.lstatSync(file).isSymbolicLink(), false);
|
||||
});
|
||||
|
||||
test('task entry tolerates discovery failure with errexit enabled', (t) => {
|
||||
const f = fixture(t);
|
||||
const source = fs.readFileSync(path.resolve('shell/otask.sh'), 'utf8');
|
||||
const start = source.indexOf('append_node_dependency_path() {');
|
||||
const end = source.indexOf('\nenter_script_workdir()', start);
|
||||
const script =
|
||||
source.slice(start, end) +
|
||||
'\nappend_node_dependency_path; printf "continued:%s" "$NODE_PATH"';
|
||||
for (const dir of [path.dirname(helper), f.root]) {
|
||||
const r = f.run(
|
||||
{
|
||||
FAIL: '1',
|
||||
dir_shell: dir,
|
||||
dir_dep: '/legacy/deps',
|
||||
NODE_PATH: '/existing',
|
||||
},
|
||||
'set -e; ' + script,
|
||||
);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.equal(r.stdout, 'continued:/existing:/legacy/deps');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawn, spawnSync } = require('node:child_process');
|
||||
const helper = path.resolve('shell/node_path_cache.sh');
|
||||
const hasFlock = spawnSync('/bin/bash', ['-c', 'type -P flock']).status === 0;
|
||||
function fixture(t) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-path-lock-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const bin = path.join(root, 'bin');
|
||||
fs.mkdirSync(bin);
|
||||
fs.symlinkSync(process.execPath, path.join(bin, 'node'));
|
||||
fs.writeFileSync(
|
||||
path.join(bin, 'pnpm'),
|
||||
'#!/bin/bash\nprintf "call\\n" >> "$CALLS"\n[[ -n "${UMASK_FILE:-}" ]] && umask > "$UMASK_FILE"\nsleep "${LOOKUP_DELAY:-0.2}"\n[[ "${FAIL:-0}" == 1 ]] && exit 17\nprintf "%s\\n" "${npm_config_global_dir:-/test/global/node_modules}"\n',
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
const env = {
|
||||
...process.env,
|
||||
HOME: root,
|
||||
PATH: bin + ':' + process.env.PATH,
|
||||
dir_tmp: path.join(root, 'cache'),
|
||||
CALLS: path.join(root, 'calls'),
|
||||
HELPER: helper,
|
||||
};
|
||||
const command = '. "$HELPER"; ql_get_node_global_path';
|
||||
const run = (extra = {}, code = command) =>
|
||||
spawnSync('/bin/bash', ['-euc', code], {
|
||||
cwd: root,
|
||||
env: { ...env, ...extra },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
const asyncRun = (extra = {}) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const child = spawn('/bin/bash', ['-euc', command], {
|
||||
cwd: root,
|
||||
env: { ...env, ...extra },
|
||||
});
|
||||
let out = '',
|
||||
err = '';
|
||||
child.stdout.on('data', (c) => (out += c));
|
||||
child.stderr.on('data', (c) => (err += c));
|
||||
child.on('error', reject);
|
||||
child.on('close', (status) =>
|
||||
resolve({ status, stdout: out, stderr: err }),
|
||||
);
|
||||
});
|
||||
const count = () =>
|
||||
fs.existsSync(env.CALLS)
|
||||
? fs.readFileSync(env.CALLS, 'utf8').trim().split('\n').length
|
||||
: 0;
|
||||
const cache = path.join(env.dir_tmp, `pnpm-root-${process.getuid()}.cache`);
|
||||
return {
|
||||
root,
|
||||
bin,
|
||||
env,
|
||||
command,
|
||||
run,
|
||||
asyncRun,
|
||||
count,
|
||||
cache,
|
||||
lock: cache + '.lock',
|
||||
};
|
||||
}
|
||||
function check(r, output = '/test/global/node_modules') {
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.equal(r.stdout.trim(), output);
|
||||
}
|
||||
test(
|
||||
'simultaneous cold and expired lookups share one successful refresh',
|
||||
{ skip: !hasFlock },
|
||||
async (t) => {
|
||||
const f = fixture(t);
|
||||
for (let batch = 0; batch < 2; batch++) {
|
||||
if (batch) {
|
||||
const lines = fs.readFileSync(f.cache, 'utf8').split('\n');
|
||||
lines[1] = '0';
|
||||
fs.writeFileSync(f.cache, lines.join('\n'));
|
||||
}
|
||||
const rows = await Promise.all(
|
||||
Array.from({ length: 8 }, () => f.asyncRun({ LOOKUP_DELAY: '0.5' })),
|
||||
);
|
||||
rows.forEach((r) => check(r));
|
||||
assert.equal(f.count(), batch + 1);
|
||||
}
|
||||
assert.equal(fs.statSync(f.lock).mode & 0o777, 0o600);
|
||||
check(f.run());
|
||||
assert.equal(f.count(), 2);
|
||||
},
|
||||
);
|
||||
test(
|
||||
'waiters with different configuration do not reuse another key',
|
||||
{ skip: !hasFlock },
|
||||
async (t) => {
|
||||
const f = fixture(t);
|
||||
const rows = await Promise.all(
|
||||
['/first', '/second'].map((v) =>
|
||||
f.asyncRun({ npm_config_global_dir: v }),
|
||||
),
|
||||
);
|
||||
rows.forEach((r, i) => check(r, ['/first', '/second'][i]));
|
||||
assert.equal(f.count(), 2);
|
||||
},
|
||||
);
|
||||
test(
|
||||
'lock timeout falls back while a holder is still alive',
|
||||
{ skip: !hasFlock },
|
||||
async (t) => {
|
||||
const f = fixture(t);
|
||||
fs.mkdirSync(f.env.dir_tmp);
|
||||
const holder = spawn(
|
||||
'/bin/bash',
|
||||
['-c', 'exec 9>> "$LOCK"; flock 9; echo ready; sleep 30'],
|
||||
{ env: { ...f.env, LOCK: f.lock }, detached: true },
|
||||
);
|
||||
t.after(() => {
|
||||
try {
|
||||
process.kill(-holder.pid, 'SIGKILL');
|
||||
} catch {}
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
holder.stdout.once('data', resolve);
|
||||
holder.once('error', reject);
|
||||
});
|
||||
const start = Date.now();
|
||||
check(f.run());
|
||||
assert.ok(Date.now() - start >= 1800);
|
||||
assert.equal(holder.exitCode, null);
|
||||
assert.equal(f.count(), 1);
|
||||
},
|
||||
);
|
||||
test('absent or unsupported flock and unsafe lock paths preserve discovery', (t) => {
|
||||
const f = fixture(t);
|
||||
check(
|
||||
f.run(
|
||||
{},
|
||||
'type(){ if [[ "$*" == "-P flock" ]]; then return 1; fi; builtin type "$@"; }; ' +
|
||||
f.command,
|
||||
),
|
||||
);
|
||||
fs.rmSync(f.cache);
|
||||
fs.writeFileSync(path.join(f.bin, 'flock'), '#!/bin/bash\nexit 64\n', {
|
||||
mode: 0o755,
|
||||
});
|
||||
check(f.run());
|
||||
fs.rmSync(f.cache);
|
||||
fs.rmSync(f.lock, { force: true });
|
||||
const victim = path.join(f.root, 'victim');
|
||||
fs.writeFileSync(victim, 'untouched');
|
||||
fs.symlinkSync(victim, f.lock);
|
||||
check(f.run());
|
||||
assert.equal(fs.readFileSync(victim, 'utf8'), 'untouched');
|
||||
assert.equal(f.count(), 3);
|
||||
});
|
||||
test('failed refresh releases lock and does not publish a success', (t) => {
|
||||
const f = fixture(t);
|
||||
assert.equal(f.run({ FAIL: '1' }).status, 17);
|
||||
assert.equal(fs.existsSync(f.cache), false);
|
||||
check(f.run());
|
||||
assert.equal(f.count(), 2);
|
||||
});
|
||||
test('refresh leaves the caller file descriptor and umask unchanged', (t) => {
|
||||
const f = fixture(t);
|
||||
const sentinel = path.join(f.root, 'fd');
|
||||
const mask = path.join(f.root, 'mask');
|
||||
const r = f.run(
|
||||
{ SENTINEL: sentinel, UMASK_FILE: mask },
|
||||
'umask 022; exec 9> "$SENTINEL"; ' +
|
||||
f.command +
|
||||
' >/dev/null; printf preserved >&9; umask',
|
||||
);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.equal(r.stdout.trim(), '0022');
|
||||
assert.equal(fs.readFileSync(sentinel, 'utf8'), 'preserved');
|
||||
assert.equal(fs.readFileSync(mask, 'utf8').trim(), '0022');
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const vm = require('node:vm');
|
||||
const fs = require('node:fs');
|
||||
const source = fs.readFileSync('ecosystem.config.js', 'utf8');
|
||||
const appSource = fs.readFileSync('back/app.ts', 'utf8');
|
||||
const ts = require('typescript');
|
||||
const compiled = ts.transpileModule(appSource.slice(0, appSource.indexOf('\nconst app = new Application();')) + '\nmodule.exports = Application;', {compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,esModuleInterop:true}}).outputText;
|
||||
function config(env) {const module={exports:{}};vm.runInNewContext(source,{module,process:{env}});return module.exports.apps[0];}
|
||||
test('container APM policy keeps worker isolation and can restore all inherited probes', () => {
|
||||
for (const primary of [undefined, 'true']) for (const workers of [undefined, 'true']) {
|
||||
const env={QL_CONTAINER:'true',QL_PRIMARY_APM:primary,QL_WORKER_APM:workers};
|
||||
const conf=config(env);env.pm_id='0';env.pmx=String(conf.pmx);
|
||||
const calls=[],module={exports:{}};
|
||||
vm.runInNewContext(compiled,{module,exports:module.exports,process:{env},require(name){if(name==='cluster')return {fork(options){calls.push({...env,...options});return {id:1,process:{pid:123}}}};if(name==='express')return ()=>({use(){}});return {};}});
|
||||
new module.exports().forkWorker('http');
|
||||
assert.equal(conf.pmx,primary==='true');
|
||||
assert.equal(calls[0].pmx,primary==='true'&&workers==='true'?'true':'false');
|
||||
assert.equal(conf.max_restarts,5);assert.equal(conf.script,'static/build/app.js');
|
||||
}
|
||||
});
|
||||
test('standalone PM2 monitoring stays enabled', () => {
|
||||
for(const QL_PRIMARY_APM of [undefined,'true','false']) assert.equal(config({QL_PRIMARY_APM}).pmx,true);
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawn } = require('node:child_process');
|
||||
const { once } = require('node:events');
|
||||
const { setTimeout: delay } = require('node:timers/promises');
|
||||
const { Sequelize, DataTypes } = require('sequelize');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
|
||||
function gate() {
|
||||
let release;
|
||||
const promise = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return { promise, release };
|
||||
}
|
||||
|
||||
async function fixture(t) {
|
||||
const root = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'ql-scheduler-mutation-'),
|
||||
);
|
||||
const db = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
storage: ':memory:',
|
||||
logging: false,
|
||||
});
|
||||
const crons = db.define('Cron', {
|
||||
name: DataTypes.STRING,
|
||||
command: DataTypes.STRING,
|
||||
schedule: DataTypes.STRING,
|
||||
isDisabled: DataTypes.INTEGER,
|
||||
saved: DataTypes.BOOLEAN,
|
||||
queued_token: DataTypes.STRING,
|
||||
});
|
||||
await db.sync();
|
||||
t.after(async () => {
|
||||
await db.close();
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
const jobs = new Map();
|
||||
const client = {
|
||||
addCron: async (items, replace) => {
|
||||
if (replace) jobs.clear();
|
||||
items.forEach((item) => jobs.set(item.id, item.command));
|
||||
},
|
||||
delCron: async (ids) => ids.forEach((id) => jobs.delete(id)),
|
||||
};
|
||||
const CronService = load('back/services/cron.ts', {
|
||||
'../config': {
|
||||
crontabFile: path.join(root, 'crontab.list'),
|
||||
logPath: root,
|
||||
},
|
||||
'../data/cron': {
|
||||
CrontabModel: crons,
|
||||
Crontab: class {
|
||||
constructor(options) {
|
||||
Object.assign(this, { isDisabled: 0 }, options);
|
||||
}
|
||||
},
|
||||
},
|
||||
'../data/runningInstance': {},
|
||||
'../config/util': { isDemoEnv: () => false },
|
||||
'../config/const': {},
|
||||
'../schedule/client': client,
|
||||
'../shared/pLimit': {},
|
||||
'../shared/utils': {},
|
||||
'../shared/i18n': { t: (s) => s },
|
||||
'../shared/logReader': {},
|
||||
'../shared/logStreamManager': {},
|
||||
}).default;
|
||||
const service = new CronService({ error() {}, warn() {}, info() {} });
|
||||
service.getLogName = async () => 'task';
|
||||
service.shouldUseCronClient = () => true;
|
||||
service.makeCommand = (doc) => doc.command;
|
||||
service.crontabs = async () => ({ data: await crons.findAll({ raw: true }) });
|
||||
service.setCrontab = async () => {};
|
||||
return { service, crons, jobs, client, root };
|
||||
}
|
||||
|
||||
for (const operation of ['create', 'update', 'remove', 'disabled', 'enabled']) {
|
||||
test(`recovery snapshot cannot overwrite concurrent ${operation}`, async (t) => {
|
||||
const { service, crons, jobs } = await fixture(t);
|
||||
await crons.create({
|
||||
id: 1,
|
||||
command: 'old',
|
||||
schedule: '* * * * *',
|
||||
isDisabled: operation === 'enabled' ? 1 : 0,
|
||||
});
|
||||
const entered = gate(),
|
||||
release = gate();
|
||||
let first = true;
|
||||
service.setCrontab = async () => {
|
||||
if (first) {
|
||||
first = false;
|
||||
entered.release();
|
||||
await release.promise;
|
||||
}
|
||||
};
|
||||
const recovery = service.autosave_crontab(true);
|
||||
await entered.promise;
|
||||
const before = await crons.findAll({ raw: true });
|
||||
const mutation =
|
||||
operation === 'create'
|
||||
? service.create({ command: 'new', schedule: '* * * * *' })
|
||||
: operation === 'update'
|
||||
? service.update({ id: 1, command: 'new' })
|
||||
: service[operation]([1]);
|
||||
await delay(50);
|
||||
assert.deepEqual(
|
||||
await crons.findAll({ raw: true }),
|
||||
before,
|
||||
'DB writes must also wait for recovery',
|
||||
);
|
||||
release.release();
|
||||
await Promise.all([recovery, mutation]);
|
||||
const rows = await crons.findAll({ raw: true });
|
||||
assert.deepEqual(
|
||||
[...jobs.entries()].sort(),
|
||||
rows
|
||||
.filter((row) => !row.isDisabled)
|
||||
.map((row) => [String(row.id), row.command])
|
||||
.sort(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('recovery waits for an admitted mutation to finish registration before reading the DB', async (t) => {
|
||||
const { service, client, jobs } = await fixture(t);
|
||||
const entered = gate(),
|
||||
release = gate();
|
||||
const add = client.addCron;
|
||||
client.addCron = async (items, replace) => {
|
||||
if (!replace) {
|
||||
entered.release();
|
||||
await release.promise;
|
||||
}
|
||||
return add(items, replace);
|
||||
};
|
||||
const mutation = service.create({ command: 'new', schedule: '* * * * *' });
|
||||
await entered.promise;
|
||||
let reads = 0;
|
||||
const read = service.crontabs;
|
||||
service.crontabs = async () => {
|
||||
reads++;
|
||||
return read();
|
||||
};
|
||||
const recovery = service.autosave_crontab(true);
|
||||
await delay(50);
|
||||
assert.equal(reads, 0);
|
||||
release.release();
|
||||
await Promise.all([mutation, recovery]);
|
||||
assert.deepEqual([...jobs.values()], ['new']);
|
||||
});
|
||||
|
||||
for (const operation of ['create', 'update', 'enabled']) {
|
||||
test(`${operation} preserves scheduler 503 after rollback and releases its lock`, async (t) => {
|
||||
const { service, crons, client } = await fixture(t);
|
||||
if (operation !== 'create')
|
||||
await crons.create({
|
||||
id: 1,
|
||||
command: 'old',
|
||||
schedule: '* * * * *',
|
||||
isDisabled: operation === 'enabled' ? 1 : 0,
|
||||
});
|
||||
const cause = Object.assign(Error('unavailable'), { status: 503 });
|
||||
client.addCron = async () => {
|
||||
throw cause;
|
||||
};
|
||||
const mutation =
|
||||
operation === 'create'
|
||||
? service.create({ command: 'new', schedule: '* * * * *' })
|
||||
: operation === 'update'
|
||||
? service.update({ id: 1, command: 'new' })
|
||||
: service.enabled([1]);
|
||||
await assert.rejects(
|
||||
mutation,
|
||||
(err) =>
|
||||
err.status === 503 && err.cause === cause && /回滚/.test(err.message),
|
||||
);
|
||||
if (operation === 'create') assert.equal(await crons.count(), 0);
|
||||
else {
|
||||
const row = await crons.findByPk(1);
|
||||
assert.equal(row.command, 'old');
|
||||
assert.equal(row.isDisabled, operation === 'enabled' ? 1 : 0);
|
||||
}
|
||||
client.addCron = async () => {};
|
||||
await service.autosave_crontab(true);
|
||||
});
|
||||
}
|
||||
|
||||
test(
|
||||
'scheduler lock excludes a second OS process and leaves no lock after completion',
|
||||
{ timeout: 10000 },
|
||||
async (t) => {
|
||||
const root = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'ql-scheduler-process-'),
|
||||
);
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const config = { crontabFile: path.join(root, 'crontab.list') };
|
||||
const { withSchedulerMutation } = load(
|
||||
'back/shared/schedulerMutationLock.ts',
|
||||
{ '../config': config },
|
||||
);
|
||||
const entered = gate(),
|
||||
release = gate();
|
||||
const owner = withSchedulerMutation(async () => {
|
||||
entered.release();
|
||||
await release.promise;
|
||||
});
|
||||
await entered.promise;
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
'-e',
|
||||
`
|
||||
const load = require('./test/helpers/load-security-module.cjs');
|
||||
const { withSchedulerMutation } = load('back/shared/schedulerMutationLock.ts', {'../config': JSON.parse(process.argv[1])});
|
||||
process.send('attempting');
|
||||
withSchedulerMutation(async () => process.send('entered')).then(() => process.disconnect()).catch(err => { console.error(err); process.exit(1); });
|
||||
`,
|
||||
JSON.stringify(config),
|
||||
],
|
||||
{ stdio: ['ignore', 'pipe', 'pipe', 'ipc'] },
|
||||
);
|
||||
t.after(() => {
|
||||
if (child.exitCode === null) child.kill('SIGKILL');
|
||||
release.release();
|
||||
});
|
||||
const closed = once(child, 'close');
|
||||
assert.equal((await once(child, 'message'))[0], 'attempting');
|
||||
let acquired = false;
|
||||
const acquisition = once(child, 'message').then(([message]) => {
|
||||
acquired = true;
|
||||
assert.equal(message, 'entered');
|
||||
});
|
||||
await delay(150);
|
||||
assert.equal(acquired, false);
|
||||
release.release();
|
||||
await owner;
|
||||
await acquisition;
|
||||
assert.equal((await closed)[0], 0);
|
||||
await assert.rejects(fs.stat(`${config.crontabFile}.scheduler.lock`), {
|
||||
code: 'ENOENT',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test('configuration rollback cannot restore an obsolete manual queue token', async (t) => {
|
||||
const { service, crons, client } = await fixture(t);
|
||||
await crons.create({
|
||||
id: 1,
|
||||
command: 'old',
|
||||
schedule: '* * * * *',
|
||||
isDisabled: 0,
|
||||
queued_token: 'old-token',
|
||||
});
|
||||
let first = true;
|
||||
client.addCron = async () => {
|
||||
if (first) {
|
||||
first = false;
|
||||
await crons.update({ queued_token: 'new-token' }, { where: { id: 1 } });
|
||||
throw Object.assign(Error('unavailable'), { status: 503 });
|
||||
}
|
||||
};
|
||||
await assert.rejects(
|
||||
service.update({ id: 1, command: 'new' }),
|
||||
(err) => err.status === 503,
|
||||
);
|
||||
const row = await crons.findByPk(1);
|
||||
assert.equal(row.command, 'old');
|
||||
assert.equal(row.queued_token, 'new-token');
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
const test=require('node:test');
|
||||
const assert=require('node:assert/strict');
|
||||
const {setTimeout:sleep}=require('node:timers/promises');
|
||||
const {SchedulerReadiness}=require('../../back/shared/schedulerReadiness');
|
||||
const load=require('../helpers/load-security-module.cjs');
|
||||
const fs=require('node:fs');
|
||||
const ts=require('typescript');
|
||||
|
||||
test('readiness stays false through failed restoration and retries without idle polling',async()=>{
|
||||
let probes=0,restores=0,fail=true;
|
||||
const state=new SchedulerReadiness(async()=>{probes++;},10);
|
||||
state.configure(async()=>{restores++;if(fail)throw Error('registration failed');});
|
||||
assert.equal(await state.recover(),false);assert.equal(await state.check(),false);
|
||||
fail=false;await sleep(40);assert.equal(await state.check(),true);assert.ok(restores>=2);
|
||||
const before=probes;await sleep(40);assert.equal(probes,before);
|
||||
});
|
||||
test('single-flight recovery cannot mark an invalidated generation ready',async()=>{
|
||||
let release,restores=0;
|
||||
const state=new SchedulerReadiness(async()=>{},10);
|
||||
state.configure(async()=>{if(++restores===1)await new Promise(r=>release=r);});
|
||||
const a=state.recover();assert.equal(state.recover(),a);await sleep(1);
|
||||
state.invalidate();release();assert.equal(await a,false);assert.equal(await state.check(),false);
|
||||
await sleep(30);assert.equal(await state.check(),true);assert.equal(restores,2);
|
||||
});
|
||||
test('mutation waiting is bounded, a later recovery can succeed',async()=>{
|
||||
let release;
|
||||
const state=new SchedulerReadiness(async()=>{},10);state.configure(()=>new Promise(r=>release=r));
|
||||
await assert.rejects(state.ensureReady(15),e=>e.status===503);
|
||||
release();await sleep(1);assert.equal(await state.check(),true);
|
||||
});
|
||||
test('probe failure immediately invalidates a previously ready scheduler',async()=>{
|
||||
let fail=false;
|
||||
const state=new SchedulerReadiness(async()=>{if(fail)throw Error('unavailable');},10);
|
||||
state.configure(async()=>{});assert.equal(await state.recover(),true);
|
||||
fail=true;assert.equal(await state.check(),false);
|
||||
fail=false;await sleep(40);assert.equal(await state.check(),true);
|
||||
});
|
||||
test('health uses actual readiness and returns HTTP 503 until recovery',async(t)=>{
|
||||
let ready=false;const express=require('express');
|
||||
const {HealthService}=load('back/services/health.ts',{
|
||||
typedi:{Service:()=>x=>x},'../loaders/logger':{error(){}},'./http':{},
|
||||
'../schedule/client':{readiness:{check:async()=>ready}},
|
||||
});
|
||||
const service=new HealthService({getServer:()=>({})});
|
||||
const app=express();load('back/api/health.ts',{
|
||||
typedi:{get:()=>service},'../services/health':{HealthService},'../loaders/logger':{error(){}},
|
||||
}).default(app);
|
||||
const server=app.listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));
|
||||
t.after(()=>new Promise(r=>{server.close(r);server.closeAllConnections();}));
|
||||
for(const expected of [503,200,503]){
|
||||
ready=expected===200;const r=await fetch(`http://127.0.0.1:${server.address().port}/health`);const b=await r.json();
|
||||
assert.equal(r.status,expected);assert.equal(b.code,expected);assert.equal(b.data.services.grpc,ready);
|
||||
}
|
||||
});
|
||||
test('recovery registration errors propagate while ordinary autosave retains file synchronization',async()=>{
|
||||
const source=fs.readFileSync('back/services/cron.ts','utf8');const a=source.indexOf(' public async autosave_crontab('),z=source.indexOf(' public async bootTask',a);
|
||||
const js=ts.transpileModule('class Fixture {\n'+source.slice(a,z)+'}\nmodule.exports=Fixture;', {compilerOptions:{target:ts.ScriptTarget.ES2020}}).outputText;
|
||||
const module={exports:{}};let files=0;
|
||||
new Function('module','isDemoEnv','cronClient','withSchedulerMutation',js)(module,()=>false,{addCron:async()=>{throw Error('registration unavailable');}},fn=>fn());
|
||||
const fixture=new module.exports();fixture.crontabs=async()=>({data:[]});fixture.setCrontab=async()=>{files++;};fixture.logger={warn(){}};
|
||||
await fixture.autosave_crontab();assert.equal(files,1);
|
||||
await assert.rejects(fixture.autosave_crontab(true),/registration unavailable/);assert.equal(files,2);
|
||||
});
|
||||
test('scheduler probe uses the cron channel and failed writes are not replayed',async()=>{
|
||||
const calls=[];const fake={
|
||||
waitForReady(deadline,cb){calls.push(['wait',deadline]);cb();},
|
||||
makeUnaryRequest(path,serialize,deserialize,request,options,cb){calls.push(['probe',path,request,options]);cb(null,{status:1});},
|
||||
addCron(request,metadata,options,cb){calls.push(['add',request,options]);cb(Object.assign(Error('invalid'),{code:3}));},
|
||||
};
|
||||
const client=load('back/schedule/client.ts',{
|
||||
'../protos/cron':{CronClient:class{constructor(){return fake;}}},
|
||||
'../config':{grpcPort:5500},'../config/grpcCerts':{getGrpcCerts:()=>({caCert:'ca',clientKey:'key',clientCert:'cert'})},
|
||||
'@grpc/grpc-js':{...require('@grpc/grpc-js'),credentials:{createSsl:()=>({})},status:{UNAVAILABLE:14},Metadata:class{}},
|
||||
}).default;
|
||||
client.readiness.configure(async()=>{});assert.equal(await client.readiness.recover(),true);
|
||||
assert.ok(calls.filter(x=>x[0]==='probe').every(x=>x[1]==='/com.ql.health.Health/Check'&&x[2].service==='scheduler'&&x[3].deadline>Date.now()));
|
||||
await assert.rejects(client.addCron([]),/invalid/);assert.equal(calls.filter(x=>x[0]==='add').length,1);
|
||||
});
|
||||
test('scheduler health probe never calls back into HTTP health',async()=>{
|
||||
const {check}=load('back/schedule/health.ts',{
|
||||
'../config':{},undici:{request:()=>{throw Error('recursive HTTP health call');}},
|
||||
});
|
||||
const result=await new Promise(resolve=>check({request:{service:'scheduler'}},(error,response)=>resolve({error,response})));
|
||||
assert.equal(result.error,null);assert.deepEqual(result.response,{status:1});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const ts = require('typescript');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const { SchedulerReadiness } = require('../../back/shared/schedulerReadiness');
|
||||
const { AddCronRequest } = require('../../back/protos/cron');
|
||||
|
||||
function fixture(client) {
|
||||
const source = fs.readFileSync('back/services/cron.ts', 'utf8');
|
||||
const a = source.indexOf(' public async autosave_crontab(');
|
||||
const z = source.indexOf(' public async bootTask', a);
|
||||
const js = ts.transpileModule(
|
||||
'class Fixture {\n' + source.slice(a, z) + '}\nmodule.exports=Fixture;',
|
||||
{ compilerOptions: { target: ts.ScriptTarget.ES2020 } },
|
||||
).outputText;
|
||||
const module = { exports: {} };
|
||||
new Function('module', 'isDemoEnv', 'cronClient', 'withSchedulerMutation', js)(
|
||||
module,
|
||||
() => false,
|
||||
client,
|
||||
(fn) => fn(),
|
||||
);
|
||||
const service = new module.exports();
|
||||
service.setCrontab = async () => {};
|
||||
service.logger = { warn() {} };
|
||||
service.shouldUseCronClient = () => true;
|
||||
service.makeCommand = () => 'true';
|
||||
return service;
|
||||
}
|
||||
|
||||
test('recovery reconciles a surviving scheduler after missed deletes and disables, including an empty DB', async () => {
|
||||
const cancelled = [];
|
||||
const stacks = new Map(
|
||||
['removed', 'disabled', 'kept'].map((id) => [
|
||||
id,
|
||||
[{ cancel: () => cancelled.push(id) }],
|
||||
]),
|
||||
);
|
||||
const { addCron } = load('back/schedule/addCron.ts', {
|
||||
'./data': { scheduleStacks: stacks },
|
||||
'node-schedule': {
|
||||
scheduleJob: (id) => ({ cancel: () => cancelled.push(id) }),
|
||||
},
|
||||
'../shared/runCron': {},
|
||||
'../loaders/logger': { info() {}, warn() {} },
|
||||
'../shared/i18n': { tf: (s) => s },
|
||||
});
|
||||
const client = {
|
||||
addCron: async (crons, replace) => {
|
||||
// Exercise the real protobuf field, not just a JavaScript-only flag.
|
||||
const request = AddCronRequest.decode(
|
||||
AddCronRequest.encode({ crons, replace }).finish(),
|
||||
);
|
||||
await new Promise((resolve, reject) =>
|
||||
addCron({ request }, (err) => (err ? reject(err) : resolve())),
|
||||
);
|
||||
},
|
||||
};
|
||||
const service = fixture(client);
|
||||
let rows = [
|
||||
{ id: 'kept', schedule: '* * * * *', isDisabled: 0 },
|
||||
{ id: 'disabled', schedule: '* * * * *', isDisabled: 1 },
|
||||
];
|
||||
service.crontabs = async () => ({ data: rows });
|
||||
const state = new SchedulerReadiness(async () => {});
|
||||
state.configure(() => service.autosave_crontab(true));
|
||||
assert.equal(await state.recover(), true);
|
||||
assert.equal(await state.check(), true);
|
||||
assert.deepEqual([...stacks.keys()], ['kept']);
|
||||
assert.deepEqual(cancelled.sort(), ['disabled', 'kept', 'removed']);
|
||||
rows = [];
|
||||
state.invalidate();
|
||||
assert.equal(await state.recover(), true);
|
||||
assert.equal(stacks.size, 0);
|
||||
assert.equal(await state.check(), true);
|
||||
});
|
||||
|
||||
test('invalid replacement leaves the previous schedule intact', async () => {
|
||||
const stacks = new Map([
|
||||
[
|
||||
'old',
|
||||
[
|
||||
{
|
||||
cancel: () => {
|
||||
throw Error('must not cancel');
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
]);
|
||||
const { addCron } = load('back/schedule/addCron.ts', {
|
||||
'./data': { scheduleStacks: stacks },
|
||||
'../shared/runCron': {},
|
||||
'../loaders/logger': {},
|
||||
'../shared/i18n': { tf: (s) => s },
|
||||
});
|
||||
await assert.rejects(
|
||||
new Promise((resolve, reject) =>
|
||||
addCron(
|
||||
{
|
||||
request: {
|
||||
replace: true,
|
||||
crons: [{ id: 'bad', schedule: '?', extra_schedules: [] }],
|
||||
},
|
||||
},
|
||||
(err) => (err ? reject(err) : resolve()),
|
||||
),
|
||||
),
|
||||
);
|
||||
assert.deepEqual([...stacks.keys()], ['old']);
|
||||
});
|
||||
|
||||
test('pre-RPC channel failures invalidate readiness and return 503 without executing or replaying writes', async () => {
|
||||
for (const method of ['addCron', 'delCron']) {
|
||||
let invalidations = 0,
|
||||
writes = 0;
|
||||
const fake = {
|
||||
waitForReady: (_deadline, cb) => cb(Error('channel unavailable')),
|
||||
addCron: () => writes++,
|
||||
delCron: () => writes++,
|
||||
};
|
||||
const client = load('back/schedule/client.ts', {
|
||||
'../protos/cron': {
|
||||
CronClient: class {
|
||||
constructor() {
|
||||
return fake;
|
||||
}
|
||||
},
|
||||
},
|
||||
'../config': { grpcPort: 5500 },
|
||||
'../config/grpcCerts': {
|
||||
getGrpcCerts: () => ({
|
||||
caCert: 'ca',
|
||||
clientKey: 'key',
|
||||
clientCert: 'cert',
|
||||
}),
|
||||
},
|
||||
'@grpc/grpc-js': {
|
||||
...require('@grpc/grpc-js'),
|
||||
credentials: { createSsl: () => ({}) },
|
||||
},
|
||||
}).default;
|
||||
client.readiness.invalidate = () => invalidations++;
|
||||
await assert.rejects(
|
||||
client[method]([]),
|
||||
(err) => err.status === 503 && /channel unavailable/.test(err.message),
|
||||
);
|
||||
assert.equal(invalidations, 1);
|
||||
assert.equal(writes, 0);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { Sequelize, QueryTypes } = require('sequelize');
|
||||
const { migrateSchema } = require('../../back/shared/schemaMigrations');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
|
||||
async function createLegacy(storage = ':memory:', omitEnvs = false) {
|
||||
const database = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
storage,
|
||||
logging: false,
|
||||
});
|
||||
for (const table of [
|
||||
'CrontabViews',
|
||||
'Subscriptions',
|
||||
'Crontabs',
|
||||
...(omitEnvs ? [] : ['Envs']),
|
||||
]) {
|
||||
await database.query(
|
||||
`CREATE TABLE "${table}" (id INTEGER PRIMARY KEY, name TEXT)`,
|
||||
);
|
||||
}
|
||||
await database.query(
|
||||
'INSERT INTO "Crontabs" (id, name) VALUES (1, \'keep-me\')',
|
||||
);
|
||||
return database;
|
||||
}
|
||||
|
||||
test('legacy database upgrades without losing rows and repeated migration is idempotent', async (t) => {
|
||||
const database = await createLegacy();
|
||||
t.after(() => database.close());
|
||||
await migrateSchema(database);
|
||||
await migrateSchema(database);
|
||||
const rows = await database.query('SELECT * FROM "Crontabs"', {
|
||||
type: QueryTypes.SELECT,
|
||||
});
|
||||
assert.equal(rows[0].name, 'keep-me');
|
||||
assert.equal(rows[0].queued_token, null);
|
||||
assert.ok(Object.hasOwn(rows[0], 'allow_multiple_instances'));
|
||||
const applied = await database.query('SELECT id FROM "SchemaMigrations"', {
|
||||
type: QueryTypes.SELECT,
|
||||
});
|
||||
assert.equal(applied.length, 15);
|
||||
});
|
||||
|
||||
test('migration failure rolls back added columns and can be retried after repair', async (t) => {
|
||||
const database = await createLegacy(':memory:', true);
|
||||
t.after(() => database.close());
|
||||
await assert.rejects(migrateSchema(database));
|
||||
const columns = await database.getQueryInterface().describeTable('Crontabs');
|
||||
assert.equal(Object.hasOwn(columns, 'work_dir'), false);
|
||||
await database.query(
|
||||
'CREATE TABLE "Envs" (id INTEGER PRIMARY KEY, name TEXT)',
|
||||
);
|
||||
await migrateSchema(database);
|
||||
assert.ok(
|
||||
Object.hasOwn(
|
||||
await database.getQueryInterface().describeTable('Crontabs'),
|
||||
'work_dir',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('offline pre-upgrade backup restores the legacy schema and data', async (t) => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-migration-'));
|
||||
t.after(() => fs.rm(dir, { recursive: true, force: true }));
|
||||
const file = path.join(dir, 'database.sqlite');
|
||||
const backup = path.join(dir, 'before.sqlite');
|
||||
let database = await createLegacy(file);
|
||||
await database.close();
|
||||
await fs.copyFile(file, backup);
|
||||
database = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
storage: file,
|
||||
logging: false,
|
||||
});
|
||||
await migrateSchema(database);
|
||||
await database.close();
|
||||
await fs.copyFile(backup, file);
|
||||
database = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
storage: file,
|
||||
logging: false,
|
||||
});
|
||||
try {
|
||||
assert.equal(
|
||||
Object.hasOwn(
|
||||
await database.getQueryInterface().describeTable('Crontabs'),
|
||||
'work_dir',
|
||||
),
|
||||
false,
|
||||
);
|
||||
const rows = await database.query('SELECT name FROM "Crontabs"', {
|
||||
type: QueryTypes.SELECT,
|
||||
});
|
||||
assert.equal(rows[0].name, 'keep-me');
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('database loader rejects initialization failure rather than allowing workers to start', async () => {
|
||||
const mocks = { './logger': { error() {}, info() {} } };
|
||||
for (const [file, model] of [
|
||||
['env', 'EnvModel'],
|
||||
['cron', 'CrontabModel'],
|
||||
['dependence', 'DependenceModel'],
|
||||
['open', 'AppModel'],
|
||||
['system', 'SystemModel'],
|
||||
['subscription', 'SubscriptionModel'],
|
||||
['cronView', 'CrontabViewModel'],
|
||||
['cronStats', 'CrontabStatModel'],
|
||||
['runningInstance', 'RunningInstanceModel'],
|
||||
])
|
||||
mocks[`../data/${file}`] = { [model]: { sync: async () => {} } };
|
||||
mocks['../data'] = { sequelize: {} };
|
||||
mocks['../shared/schemaMigrations'] = {
|
||||
migrateSchema: async () => {
|
||||
throw new Error('SQLITE_FULL');
|
||||
},
|
||||
};
|
||||
const initialize = load(path.resolve('back/loaders/db.ts'), mocks).default;
|
||||
await assert.rejects(initialize(), /SQLITE_FULL/);
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const source = fs
|
||||
.readFileSync(path.resolve('shell/api.sh'), 'utf8')
|
||||
.replace(/\nget_token\s*$/, '\n');
|
||||
const realJq = spawnSync('which', ['jq'], { encoding: 'utf8' }).stdout.trim();
|
||||
function fixture(t) {
|
||||
assert.ok(realJq, 'jq is required');
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-api-parse-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const bin = path.join(root, 'bin');
|
||||
fs.mkdirSync(bin);
|
||||
fs.writeFileSync(
|
||||
path.join(bin, 'jq'),
|
||||
'#!/bin/bash\nprintf "call\\n" >> "$JQ_CALLS"\nexec "$REAL_JQ" "$@"\n',
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
const token = path.join(root, 'token.json'),
|
||||
response = path.join(root, 'response.json'),
|
||||
calls = path.join(root, 'calls');
|
||||
const run = (code) =>
|
||||
spawnSync(
|
||||
'/bin/bash',
|
||||
[
|
||||
'-euc',
|
||||
source +
|
||||
'\ncreate_token(){ __ql_token__=generated; generated=1; }; curl(){ cat "$RESPONSE_FILE"; };\n' +
|
||||
code,
|
||||
],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: bin + ':' + process.env.PATH,
|
||||
REAL_JQ: realJq,
|
||||
JQ_CALLS: calls,
|
||||
file_auth_token: token,
|
||||
RESPONSE_FILE: response,
|
||||
ql_port: '5700',
|
||||
__ql_token__: 'test-token',
|
||||
},
|
||||
},
|
||||
);
|
||||
return {
|
||||
token,
|
||||
response,
|
||||
run,
|
||||
count: () =>
|
||||
fs.existsSync(calls)
|
||||
? fs.readFileSync(calls, 'utf8').trim().split('\n').length
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
test('valid token cache is read with one jq and reused without generation', (t) => {
|
||||
const f = fixture(t);
|
||||
fs.writeFileSync(
|
||||
f.token,
|
||||
JSON.stringify({
|
||||
value: 'header.payload.signature',
|
||||
expiration: 4102444800,
|
||||
}),
|
||||
);
|
||||
const r = f.run(
|
||||
'get_token; printf "%s:%s" "$__ql_token__" "${generated:-0}"',
|
||||
);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.equal(r.stdout, 'header.payload.signature:0');
|
||||
assert.equal(f.count(), 1);
|
||||
});
|
||||
test('expired, corrupt, missing or header-unsafe token cache refreshes under errexit', (t) => {
|
||||
const f = fixture(t);
|
||||
for (const content of [
|
||||
undefined,
|
||||
'broken',
|
||||
'{}',
|
||||
JSON.stringify({ value: 'old', expiration: 1 }),
|
||||
JSON.stringify({ value: 'old', expiration: '4102444800' }),
|
||||
JSON.stringify({ value: '', expiration: 4102444800 }),
|
||||
JSON.stringify({ value: 'bad\ninjected', expiration: 4102444800 }),
|
||||
JSON.stringify({ value: 'bad\n', expiration: 4102444800 }),
|
||||
JSON.stringify({ value: 'bad\rinjected', expiration: 4102444800 }),
|
||||
]) {
|
||||
if (content === undefined) fs.rmSync(f.token, { force: true });
|
||||
else fs.writeFileSync(f.token, content);
|
||||
const r = f.run('get_token; printf "%s:%s" "$__ql_token__" "$generated"');
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.equal(r.stdout, 'generated:1');
|
||||
}
|
||||
});
|
||||
test('success with a message still parses once and stays silent', (t) => {
|
||||
const f = fixture(t);
|
||||
fs.writeFileSync(f.response, JSON.stringify({ code: 200, message: 'ok' }));
|
||||
const r = f.run('update_cron 1 0 123 log 1');
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.equal(r.stdout, '');
|
||||
assert.equal(f.count(), 1);
|
||||
});
|
||||
test('status and statistics errors preserve multiline Unicode messages', (t) => {
|
||||
const f = fixture(t);
|
||||
fs.writeFileSync(
|
||||
f.response,
|
||||
JSON.stringify({ code: 500, message: '写入失败\n请重试 "原任务"' }),
|
||||
);
|
||||
for (const command of [
|
||||
'update_cron 1 1 123 log 1 2 7',
|
||||
'record_cron_stat 1 7 2',
|
||||
]) {
|
||||
const r = f.run(command);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.equal(r.stdout, '写入失败\n请重试 "原任务"\n');
|
||||
}
|
||||
assert.equal(f.count(), 2);
|
||||
});
|
||||
test('non-JSON errors fall back to raw text and absent messages retain legacy null', (t) => {
|
||||
const f = fixture(t);
|
||||
fs.writeFileSync(f.response, 'upstream unavailable');
|
||||
let r = f.run('update_cron 1 0 123 log 1');
|
||||
assert.equal(r.status, 0);
|
||||
assert.equal(r.stdout, 'upstream unavailable\n');
|
||||
fs.writeFileSync(f.response, '{"code":500}');
|
||||
r = f.run('record_cron_stat 1 7 2');
|
||||
assert.equal(r.status, 0);
|
||||
assert.equal(r.stdout, 'null\n');
|
||||
});
|
||||
test('statistics without a task id perform no JSON parsing', (t) => {
|
||||
const f = fixture(t);
|
||||
const r = f.run('record_cron_stat "" 0 1 || true');
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.equal(f.count(), 0);
|
||||
});
|
||||
|
||||
test('canonical success resets prior errors and matches jq missing-message semantics', (t) => {
|
||||
const f = fixture(t);
|
||||
fs.writeFileSync(f.response, '{"code":200}');
|
||||
const r = f.run(
|
||||
'code=500; message=old; ql_parse_status_response "$(cat "$RESPONSE_FILE")"; printf "%s|%s" "$code" "$message"; record_cron_stat 1 0 1',
|
||||
);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.equal(r.stdout, '200|null');
|
||||
assert.equal(f.count(), 0);
|
||||
});
|
||||
|
||||
test('noncanonical JSON always uses jq, including whitespace, duplicate keys and embedded success text', (t) => {
|
||||
const f = fixture(t);
|
||||
const cases = [
|
||||
[' {"code":200} ', '200|null'],
|
||||
['{"code": 200}', '200|null'],
|
||||
['{"code":200,"message":"完成\\n第二行"}', '200|完成\n第二行'],
|
||||
['{"code":200,"data":{"code":500}}', '200|null'],
|
||||
['{"code":"200"}', '200|null'],
|
||||
['{"code":200,"code":500,"message":"error"}', '500|error'],
|
||||
['{"code":500,"message":"{\\"code\\":200}"}', '500|{"code":200}'],
|
||||
['{"code":500}', '500|null'],
|
||||
];
|
||||
for (const [body, expected] of cases) {
|
||||
fs.writeFileSync(f.response, body);
|
||||
const r = f.run(
|
||||
'ql_parse_status_response "$(cat "$RESPONSE_FILE")"; printf "%s|%s" "$code" "$message"',
|
||||
);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.equal(r.stdout, expected);
|
||||
}
|
||||
assert.equal(f.count(), cases.length);
|
||||
});
|
||||
|
||||
test('success-shaped invalid documents retain the raw-response error fallback', (t) => {
|
||||
const f = fixture(t);
|
||||
const cases = [
|
||||
'{"code":200}trailing',
|
||||
'{"code":200',
|
||||
'<html>{"code":200}</html>',
|
||||
];
|
||||
for (const body of cases) {
|
||||
fs.writeFileSync(f.response, body);
|
||||
const r = f.run('update_cron 1 0 123 log 1');
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.equal(r.stdout, body + '\n');
|
||||
}
|
||||
assert.equal(f.count(), cases.length);
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const { Sequelize, DataTypes } = require('sequelize');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
async function fixture(t, onKill = async () => {}) {
|
||||
const db = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
storage: ':memory:',
|
||||
logging: false,
|
||||
});
|
||||
t.after(() => db.close());
|
||||
const crons = db.define('Cron', {
|
||||
command: DataTypes.STRING,
|
||||
status: DataTypes.INTEGER,
|
||||
pid: DataTypes.INTEGER,
|
||||
log_path: DataTypes.STRING,
|
||||
queued_token: DataTypes.STRING,
|
||||
last_execution_time: DataTypes.INTEGER,
|
||||
last_running_time: DataTypes.INTEGER,
|
||||
});
|
||||
const instances = db.define('Instance', {
|
||||
cron_id: DataTypes.INTEGER,
|
||||
pid: DataTypes.INTEGER,
|
||||
status: DataTypes.INTEGER,
|
||||
exit_code: DataTypes.INTEGER,
|
||||
finished_at: DataTypes.INTEGER,
|
||||
});
|
||||
await db.sync();
|
||||
await crons.bulkCreate([
|
||||
{ id: 1, command: 'task one.js', status: 0, pid: 101, log_path: 'one.log' },
|
||||
{ id: 2, command: 'task two.js', status: 0, pid: 201, log_path: 'two.log' },
|
||||
]);
|
||||
await instances.bulkCreate([
|
||||
{ id: 1, cron_id: 1, pid: 101, status: 0 },
|
||||
{ id: 2, cron_id: 1, pid: 102, status: 0 },
|
||||
{ id: 3, cron_id: 1, pid: 99, status: 1, exit_code: 0 },
|
||||
{ id: 4, cron_id: 1, pid: 98, status: 3, exit_code: 7 },
|
||||
{ id: 5, cron_id: 2, pid: 201, status: 0 },
|
||||
]);
|
||||
let service;
|
||||
const killed = [];
|
||||
const CronService = load(path.resolve('back/services/cron.ts'), {
|
||||
'../config': {},
|
||||
'../data/cron': {
|
||||
CrontabModel: crons,
|
||||
CrontabStatus: { queued: 3, running: 0, idle: 1 },
|
||||
},
|
||||
'../data/runningInstance': {
|
||||
RunningInstanceModel: instances,
|
||||
InstanceStatus: { running: 0, finished: 1, stopped: 2, error: 3 },
|
||||
},
|
||||
'../config/util': {
|
||||
killTask: async (pid, wait) => {
|
||||
assert.equal(wait, true);
|
||||
killed.push(pid);
|
||||
await onKill({ pid, service, instances, crons });
|
||||
},
|
||||
killAllTasks: async () => {
|
||||
throw Error('command-wide scans must not run');
|
||||
},
|
||||
},
|
||||
'../config/const': {},
|
||||
'../schedule/client': {},
|
||||
'../shared/pLimit': {},
|
||||
'../shared/utils': {},
|
||||
'../shared/i18n': { t: (s) => s },
|
||||
'../shared/logReader': {},
|
||||
'../shared/logStreamManager': {},
|
||||
'../shared/childProcess': require('../../back/shared/childProcess'),
|
||||
}).default;
|
||||
service = new CronService({ info() {}, error() {} });
|
||||
service.getDb = ({ id }) => crons.findByPk(id);
|
||||
return { service, instances, crons, killed };
|
||||
}
|
||||
async function report(service, pid, code) {
|
||||
await service.status({
|
||||
ids: [1],
|
||||
status: 1,
|
||||
pid,
|
||||
log_path: 'one.log',
|
||||
last_running_time: 1,
|
||||
last_execution_time: 100,
|
||||
exit_code: code,
|
||||
});
|
||||
}
|
||||
test('stop wins when shell exit writes error or success during termination, preserving exit codes', async (t) => {
|
||||
const f = await fixture(t, async ({ service }) => {
|
||||
await report(service, 101, 143);
|
||||
await report(service, 102, 0);
|
||||
});
|
||||
await f.service.stop([1]);
|
||||
const rows = await f.instances.findAll({ order: [['id', 'ASC']], raw: true });
|
||||
assert.deepEqual(
|
||||
rows.map((r) => [r.id, r.status, r.exit_code]),
|
||||
[
|
||||
[1, 2, 143],
|
||||
[2, 2, 0],
|
||||
[3, 1, 0],
|
||||
[4, 3, 7],
|
||||
[5, 0, null],
|
||||
],
|
||||
);
|
||||
});
|
||||
test('late shell completion cannot overwrite stopped instances', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.service.stop([1]);
|
||||
await report(f.service, 101, 143);
|
||||
assert.equal((await f.instances.findByPk(1)).status, 2);
|
||||
assert.equal((await f.instances.findByPk(2)).status, 2);
|
||||
});
|
||||
test('rows created after the stop snapshot are not relabelled', async (t) => {
|
||||
const f = await fixture(t, async ({ instances }) => {
|
||||
if (!(await instances.findByPk(6)))
|
||||
await instances.create({ id: 6, cron_id: 1, pid: 103, status: 0 });
|
||||
});
|
||||
await f.service.stop([1]);
|
||||
assert.equal((await f.instances.findByPk(6)).status, 0);
|
||||
assert.equal((await f.instances.findByPk(1)).status, 2);
|
||||
});
|
||||
test('repeated stop does not rewrite historical rows or their finished timestamps', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.service.stop([1]);
|
||||
const before = await f.instances.findAll({
|
||||
raw: true,
|
||||
order: [['id', 'ASC']],
|
||||
});
|
||||
await f.service.stop([1]);
|
||||
assert.deepEqual(
|
||||
await f.instances.findAll({ raw: true, order: [['id', 'ASC']] }),
|
||||
before,
|
||||
);
|
||||
});
|
||||
test('batch stop captures running instances from every requested cron', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.service.stop([1, 2]);
|
||||
assert.equal((await f.instances.findByPk(5)).status, 2);
|
||||
assert.equal((await f.instances.findByPk(3)).status, 1);
|
||||
});
|
||||
|
||||
test('stop signals every snapshotted PID exactly once and preserves a later running instance', async (t) => {
|
||||
const f = await fixture(t, async ({ instances, crons }) => {
|
||||
if (!(await instances.findByPk(6))) {
|
||||
await instances.create({ id: 6, cron_id: 1, pid: 103, status: 0 });
|
||||
await crons.update(
|
||||
{ pid: 103, log_path: 'later.log', status: 0 },
|
||||
{ where: { id: 1 } },
|
||||
);
|
||||
}
|
||||
});
|
||||
await f.service.stop([1]);
|
||||
assert.deepEqual(f.killed, [101, 102]);
|
||||
assert.equal((await f.instances.findByPk(6)).status, 0);
|
||||
assert.equal((await f.crons.findByPk(1)).pid, 103);
|
||||
assert.equal((await f.crons.findByPk(1)).status, 0);
|
||||
});
|
||||
|
||||
test('failed termination is not finalized as stopped', async (t) => {
|
||||
const f = await fixture(t, async ({ pid }) => {
|
||||
if (pid === 102) throw Error('still alive');
|
||||
});
|
||||
await f.service.stop([1]);
|
||||
assert.deepEqual(f.killed, [101, 102]);
|
||||
assert.equal((await f.instances.findByPk(1)).status, 2);
|
||||
assert.equal((await f.instances.findByPk(2)).status, 0);
|
||||
assert.equal((await f.crons.findByPk(1)).status, 0);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const path = require('node:path');
|
||||
const dayjs = require('dayjs');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
|
||||
test('subscription becomes idle and closes its log even when completion logging fails', async () => {
|
||||
const updates = [];
|
||||
let closed = 0;
|
||||
let notified = 0;
|
||||
const Subscription = load(path.resolve('back/services/subscription.ts'), {
|
||||
'../config': {},
|
||||
'../data/subscription': {
|
||||
SubscriptionModel: { update: async (values) => updates.push(values) },
|
||||
SubscriptionStatus: { idle: 1 },
|
||||
},
|
||||
'../data/cron': {},
|
||||
'../config/util': { handleLogPath: async () => '/tmp/unused-log' },
|
||||
'../config/const': { LOG_END_SYMBOL: 'end' },
|
||||
'../config/subscription': {},
|
||||
'../shared/i18n': { t: (s) => s, tf: (s) => s },
|
||||
'../shared/pLimit': {},
|
||||
'../shared/logReader': {},
|
||||
'../shared/logStreamManager': {
|
||||
logStreamManager: {
|
||||
write: async () => {
|
||||
throw new Error('ENOSPC');
|
||||
},
|
||||
closeStream: async () => {
|
||||
closed++;
|
||||
throw new Error('ENOSPC');
|
||||
},
|
||||
},
|
||||
},
|
||||
'./schedule': {},
|
||||
'./sock': {},
|
||||
'./sshKey': {},
|
||||
'./cron': {},
|
||||
}).default;
|
||||
const service = new Subscription(
|
||||
{},
|
||||
{},
|
||||
{ sendMessage: () => notified++ },
|
||||
{},
|
||||
{},
|
||||
);
|
||||
service.getDb = async () => ({ id: 1, log_path: 'log' });
|
||||
const callbacks = service.taskCallbacks({ id: 1 });
|
||||
await assert.rejects(callbacks.onEnd(undefined, dayjs(), 1), /ENOSPC/);
|
||||
assert.equal(closed, 1);
|
||||
assert.equal(updates.at(-1).status, 1);
|
||||
assert.equal(notified, 1);
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
function extract(file, name) {
|
||||
const text = fs.readFileSync(file, 'utf8');
|
||||
const start = text.indexOf(name + '() {');
|
||||
assert.ok(start >= 0);
|
||||
return text.slice(start, text.indexOf('\n}', start) + 2);
|
||||
}
|
||||
const source =
|
||||
['handle_log_path', 'init_begin_time']
|
||||
.map((n) => extract('shell/task.sh', n))
|
||||
.join('\n') +
|
||||
'\n' +
|
||||
['format_time', 'format_log_time', 'format_timestamp', 'handle_task_end']
|
||||
.map((n) => extract('shell/share.sh', n))
|
||||
.join('\n');
|
||||
function run(t, body) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-task-time-'));
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
const result = spawnSync(
|
||||
'/bin/bash',
|
||||
[
|
||||
'-ec',
|
||||
source +
|
||||
`
|
||||
is_macos=0
|
||||
mtime_format='%Y-%m-%d %H:%M:%S.%3N'
|
||||
time_format='%Y-%m-%d %H:%M:%S'
|
||||
ID=42; dir_log=/tmp; log_name=''; real_log_path=''; no_tee=''; real_time=''
|
||||
make_dir() { :; }
|
||||
t() { printf '%s\\n' "$*" >> "$TEST_DIR/messages"; }
|
||||
update_cron() { printf '%s\\n' "$*" >> "$TEST_DIR/status"; }
|
||||
record_cron_stat() { printf '%s\\n' "$*" >> "$TEST_DIR/stats"; }
|
||||
date() {
|
||||
printf '%s\\n' "$*" >> "$TEST_DIR/dates"
|
||||
case "$1" in
|
||||
'+%Y-%m-%d %H:%M:%S.%3N|%Y-%m-%d-%H-%M-%S-%3N') printf '%s\\n' '2026-09-12 23:59:59.999|2026-09-12-23-59-59-999';;
|
||||
'+%Y-%m-%d %H:%M:%S|%s') printf '%s\\n' '2026-09-13 00:00:00|101';;
|
||||
'-d') [[ "$3" == '+%s' ]] && echo 100;;
|
||||
*) echo raw-time;;
|
||||
esac
|
||||
}
|
||||
` +
|
||||
body,
|
||||
],
|
||||
{ encoding: 'utf8', env: { ...process.env, TEST_DIR: dir } },
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr + result.stdout);
|
||||
return {
|
||||
dir,
|
||||
text: (name) =>
|
||||
fs.existsSync(path.join(dir, name))
|
||||
? fs.readFileSync(path.join(dir, name), 'utf8')
|
||||
: '',
|
||||
};
|
||||
}
|
||||
test('Linux task timestamps share a snapshot and retain millisecond log names across midnight', (t) => {
|
||||
const r = run(
|
||||
t,
|
||||
`handle_log_path 'folder/example.js'
|
||||
init_begin_time
|
||||
[[ "$log_path" == 'folder_example_42/2026-09-12-23-59-59-999.log' ]]
|
||||
[[ "$time" == '2026-09-12 23:59:59.999' && "$begin_time" == '2026-09-12 23:59:59' && "$begin_timestamp" == 100 ]]
|
||||
handle_task_end`,
|
||||
);
|
||||
assert.equal(r.text('dates').trim().split('\n').length, 3);
|
||||
assert.match(r.text('stats'), /^42 0 1\n$/);
|
||||
assert.match(r.text('messages'), /2026-09-13 00:00:00/);
|
||||
});
|
||||
test('explicit log paths and output mode remain intact', (t) => {
|
||||
run(
|
||||
t,
|
||||
`real_log_path='custom/output.log'; real_time=true
|
||||
handle_log_path 'folder/example.js'
|
||||
[[ "$log_path" == 'custom/output.log' && "$cmd" == '' ]]`,
|
||||
);
|
||||
});
|
||||
test('task failure keeps exit code and minimum one-second runtime', (t) => {
|
||||
const r = run(
|
||||
t,
|
||||
`begin_timestamp=101; _task_exit_code=7; log_path=test.log
|
||||
handle_task_end`,
|
||||
);
|
||||
assert.equal(r.text('stats'), '42 7 1\n');
|
||||
assert.match(r.text('messages'), /失败/);
|
||||
assert.match(r.text('status'), /101 1 7/);
|
||||
});
|
||||
test('manual stop retains stopped message and exit status reporting', (t) => {
|
||||
const r = run(
|
||||
t,
|
||||
`begin_timestamp=99; _task_exit_code=1; log_path=test.log; MANUAL=true
|
||||
handle_task_end`,
|
||||
);
|
||||
assert.equal(r.text('stats'), '42 1 2\n');
|
||||
assert.match(r.text('messages'), /已停止/);
|
||||
});
|
||||
for (const mode of ['macos', 'custom'])
|
||||
test(`${mode} keeps legacy time conversion helpers`, (t) => {
|
||||
run(
|
||||
t,
|
||||
`
|
||||
${mode === 'macos' ? 'is_macos=1' : 'mtime_format=custom; time_format=custom'}
|
||||
format_log_time() { echo legacy-log; }
|
||||
format_time() { echo legacy-time; }
|
||||
format_timestamp() { echo 100; }
|
||||
handle_log_path example.js
|
||||
init_begin_time
|
||||
[[ "$log_time" == legacy-log && "$begin_time" == legacy-time && "$begin_timestamp" == 100 ]]
|
||||
handle_task_end
|
||||
`,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const ts = require('typescript');
|
||||
const source = fs.readFileSync('back/app.ts', 'utf8');
|
||||
const entry = source.indexOf('\nconst app = new Application();');
|
||||
assert.ok(entry > 0, 'application entry point exists');
|
||||
const compiled = ts.transpileModule(
|
||||
source.slice(0, entry) + '\nmodule.exports = Application;',
|
||||
{
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
esModuleInterop: true,
|
||||
},
|
||||
},
|
||||
).outputText;
|
||||
function create(env) {
|
||||
const calls = [];
|
||||
const cluster = {
|
||||
fork(options) {
|
||||
const worker = {
|
||||
id: calls.length + 1,
|
||||
process: { pid: 100 + calls.length },
|
||||
};
|
||||
calls.push({ options, inherited: { ...env, ...options }, worker });
|
||||
return worker;
|
||||
},
|
||||
};
|
||||
const module = { exports: {} };
|
||||
const sandbox = {
|
||||
module,
|
||||
exports: module.exports,
|
||||
process: { env },
|
||||
require(name) {
|
||||
if (name === 'cluster') return cluster;
|
||||
if (name === 'express') return () => ({ use() {} });
|
||||
return {};
|
||||
},
|
||||
};
|
||||
vm.runInNewContext(compiled, sandbox);
|
||||
return { app: new module.exports(), calls };
|
||||
}
|
||||
test('PM2-managed primary disables only inherited worker APM and keeps worker metadata', () => {
|
||||
const env = { pm_id: '0', pmx: 'true', CUSTOM_VALUE: 'kept' };
|
||||
const { app, calls } = create(env);
|
||||
for (const role of ['grpc', 'http']) app.forkWorker(role);
|
||||
assert.deepEqual(
|
||||
calls.map((x) => ({ ...x.options })),
|
||||
[
|
||||
{ SERVICE_TYPE: 'grpc', pmx: 'false' },
|
||||
{ SERVICE_TYPE: 'http', pmx: 'false' },
|
||||
],
|
||||
);
|
||||
assert.equal(env.pmx, 'true');
|
||||
assert.equal(calls[0].inherited.CUSTOM_VALUE, 'kept');
|
||||
for (const call of calls) {
|
||||
const entry = app.workerMetadataMap.get(call.worker.id);
|
||||
assert.equal(entry.pid, call.worker.process.pid);
|
||||
assert.equal(entry.serviceType, call.options.SERVICE_TYPE);
|
||||
}
|
||||
});
|
||||
test('worker APM opt-in restores inherited PM2 settings, including explicit disable', () => {
|
||||
for (const pmx of ['true', 'false']) {
|
||||
const { app, calls } = create({ pm_id: '0', pmx, QL_WORKER_APM: 'true' });
|
||||
app.forkWorker('http');
|
||||
assert.equal(Object.hasOwn(calls[0].options, 'pmx'), false);
|
||||
assert.equal(calls[0].inherited.pmx, pmx);
|
||||
}
|
||||
});
|
||||
test('standalone startup does not introduce a PM2-specific override', () => {
|
||||
const { app, calls } = create({ CUSTOM_VALUE: 'kept' });
|
||||
app.forkWorker('grpc');
|
||||
assert.deepEqual({ ...calls[0].options }, { SERVICE_TYPE: 'grpc' });
|
||||
assert.equal(calls[0].inherited.CUSTOM_VALUE, 'kept');
|
||||
});
|
||||
test('replacement workers receive the same monitoring policy', () => {
|
||||
const { app, calls } = create({ pm_id: '0', pmx: 'true' });
|
||||
app.forkWorker('grpc');
|
||||
app.forkWorker('grpc');
|
||||
assert.equal(calls[1].inherited.pmx, 'false');
|
||||
assert.notEqual(calls[0].worker.id, calls[1].worker.id);
|
||||
});
|
||||
Reference in New Issue
Block a user