fix: resolve Debian regression findings (#3087)

* fix: resolve Debian regression findings

* fix: reject failed APT index refreshes and simplify SSH home lookup
This commit is contained in:
whyour
2026-10-09 01:41:29 +08:00
committed by GitHub
parent 9d5f58ef99
commit 68ffa106f8
9 changed files with 441 additions and 16 deletions
+176
View File
@@ -0,0 +1,176 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const { spawn, spawnSync } = require('node:child_process');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const load = require('../helpers/load-security-module.cjs');
for (const scenario of [
{
name: 'stderr warning with exit zero',
code: 'process.stderr.write("sudo: warning\\n")',
saved: true,
},
{
name: 'nonzero exit without stderr',
code: 'process.exitCode = 7',
saved: false,
},
{
name: 'signal termination',
code: 'process.kill(process.pid, "SIGTERM")',
saved: false,
},
{ name: 'spawn failure', shell: '/nonexistent-ql-test-shell', saved: false },
]) {
test(`Linux mirror persistence follows the process result: ${scenario.name}`, async () => {
const logger = { info() {}, error() {} };
const ScheduleService = load('back/services/schedule.ts', {
'../shared/cronScheduler': {},
'../shared/pLimit': { runWithSystemLimit: async (_params, fn) => fn() },
'cross-spawn': {
spawn: () =>
spawn(
process.execPath,
['-e', scenario.code || ''],
scenario.shell ? { shell: scenario.shell } : {},
),
},
}).default;
const schedule = new ScheduleService(logger);
let completion;
const runTask = schedule.runTask.bind(schedule);
schedule.runTask = (...args) => (completion = runTask(...args));
const messages = [],
writes = [];
const SystemService = load('back/services/system.ts', {
os: { platform: () => 'linux' },
'../config': {},
'../config/const': {},
'../config/util': { updateLinuxMirrorFile: async () => 'fixture' },
'../data/dependence': {},
'../data/notify': {},
'../data/system': {},
'../schedule/client': {},
'../shared/pLimit': {},
'../shared/i18n': {},
'../shared/schedulerMutationLock': {},
'./notify': class {},
'./schedule': ScheduleService,
'./sock': class {},
}).default;
const service = new SystemService(logger, schedule, {
sendMessage: (message) => messages.push(message),
});
service.getSystemConfig = async () => ({ id: 1, info: { lang: 'en-US' } });
service.updateAuthDb = async (doc) => writes.push(doc);
let ended = 0;
await service.updateLinuxMirror(
{ linuxMirror: 'https://mirror.example' },
undefined,
() => ended++,
);
const result = await completion;
assert.equal(ended, 1);
assert.equal(writes.length, scenario.saved ? 1 : 0);
assert.ok(messages.some((message) => message.status === 'completed'));
if (scenario.saved) {
assert.equal(result.code, 0);
assert.ok(
messages.some((message) => message.message.includes('sudo: warning')),
);
assert.deepEqual(writes[0].info, {
lang: 'en-US',
linuxMirror: 'https://mirror.example',
});
}
});
}
const hasApt = spawnSync('apt-get', ['--version']).status === 0;
for (const distribution of ['Debian', 'Ubuntu']) {
for (const existingMirror of [false, true]) {
test(
`${distribution} mirror update rejects failed index downloads with ${
existingMirror ? 'an existing' : 'an empty'
} source`,
{ skip: !hasApt },
async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-apt-result-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const parts = path.join(root, 'parts');
await fs.mkdir(parts);
await fs.mkdir(path.join(root, 'lists', 'partial'), {
recursive: true,
});
const source = path.join(root, 'sources.list');
await fs.writeFile(
source,
'deb http://127.0.0.1:1/debian bookworm main\n',
);
// Load only fixture configuration, including all writable APT paths.
const aptConfig = path.join(root, 'apt.conf');
await fs.writeFile(
aptConfig,
`Dir::Etc::main "/dev/null";
Dir::Etc::parts "${parts}";
Dir::Etc::sourcelist "${source}";
Dir::Etc::sourceparts "${parts}";
Dir::State::lists "${path.join(root, 'lists')}";
Dir::Cache "${path.join(root, 'cache')}";
Dir::Log "${path.join(root, 'logs')}";
Acquire::Retries "0";
Acquire::http::Proxy "DIRECT";
Acquire::http::Timeout "1";
`,
);
const util = load('back/config/util.ts', {
'fs/promises': {
readFile: async (file) =>
file === '/etc/os-release'
? distribution
: existingMirror
? 'URIs: http://old.example/debian\n'
: '',
},
os: { platform: () => 'linux' },
'./index': {},
'./const': {},
'./share': {},
'./container': { isInContainer: () => false },
'../loaders/logger': {},
'../shared/utils': {},
'../shared/fileAccess': {},
'../data/dependence': {},
});
const previousOS = process.env.QL_OS_TYPE;
let command;
try {
process.env.QL_OS_TYPE = distribution.toLowerCase();
command = await util.updateLinuxMirrorFile('http://127.0.0.1:1');
} finally {
if (previousOS === undefined) delete process.env.QL_OS_TYPE;
else process.env.QL_OS_TYPE = previousOS;
}
// Exercise the generated updater without modifying host source files.
const [program, ...args] = command
.slice(command.lastIndexOf('&&') + 2)
.trim()
.split(/\s+/);
assert.equal(program, 'apt-get');
const options = {
encoding: 'utf8',
timeout: 10000,
env: { ...process.env, APT_CONFIG: aptConfig },
};
const baseline = spawnSync('apt-get', ['update'], options);
assert.equal(baseline.status, 0, baseline.stderr);
assert.match(baseline.stderr, /Failed to fetch/);
const result = spawnSync(program, args, options);
assert.equal(result.status, 100, result.stderr);
assert.match(result.stderr, /Failed to fetch/);
},
);
}
}
+4 -5
View File
@@ -92,9 +92,8 @@ test('empty or missing refs never count as a synchronized mirror', (t) => {
const result = spawnSync(
'bash',
[script, source, mirror, ref],
options({
QL_MIRROR_WAIT_SECONDS: '1',
}),
// Leave time to read both refs before checking the missing-ref diagnostic.
options(),
);
assert.equal(result.status, 1, result.stderr + result.stdout);
assert.match(result.stdout, /is missing refs\/heads\//);
@@ -110,7 +109,7 @@ test('static releases require both the version branch and annotated tag object',
const args = [script, source, mirror, 'v2.22.0', 'tag'];
const env = {
QL_MIRROR_INCLUDE_VERSION_BRANCH: 'true',
QL_MIRROR_WAIT_SECONDS: '1',
QL_MIRROR_WAIT_SECONDS: '3',
};
const partial = spawnSync('bash', args, options(env));
assert.equal(partial.status, 1, partial.stderr + partial.stdout);
@@ -134,7 +133,7 @@ test('network failures and hung ref queries stop within the configured deadline'
'bash',
[script, source, path.join(tmp, 'absent'), 'develop'],
options({
QL_MIRROR_WAIT_SECONDS: '1',
QL_MIRROR_WAIT_SECONDS: '3',
}),
);
assert.equal(failure.status, 1, failure.stderr + failure.stdout);
@@ -0,0 +1,115 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const { Sequelize, DataTypes } = require('sequelize');
const load = require('../helpers/load-security-module.cjs');
test('startup closes interrupted instances without inventing exit codes or rewriting history', async (t) => {
const db = new Sequelize({
dialect: 'sqlite',
storage: ':memory:',
logging: false,
});
t.after(() => db.close());
const instances = db.define('Instance', {
status: DataTypes.INTEGER,
started_at: DataTypes.INTEGER,
finished_at: DataTypes.INTEGER,
exit_code: DataTypes.INTEGER,
});
await db.sync();
await instances.bulkCreate([
{ id: 1, status: 0, started_at: 100 },
{ id: 2, status: 1, started_at: 100, finished_at: 110, exit_code: 0 },
{ id: 3, status: 2, started_at: 100, finished_at: 120, exit_code: 143 },
{ id: 4, status: 3, started_at: 100, finished_at: 130, exit_code: 7 },
]);
const systemConfig = { info: { lang: 'en-US' } };
const service = {
set_envs: async () => {},
getAuthInfo: async () => ({}),
findApps: async () => [],
};
const initData = load('back/loaders/initData.ts', {
typedi: { Container: { get: () => service } },
'../services/dependence': class {},
'../services/cron': class {},
'../services/env': class {},
'../services/system': class {},
'../services/user': class {},
'../services/open': class {},
'../data/cron': {
CrontabModel: { update: async () => {}, findAll: async () => [] },
CrontabStatus: { idle: 1 },
},
'../data/dependence': {
DependenceModel: { findAll: async () => [], update: async () => {} },
DependenceStatus: { queued: 0 },
},
'../data/cronView': {
CrontabViewModel: { findAll: async () => [{}] },
CronViewType: { 系统: 0 },
},
'../data/env': {},
'../data/system': {
SystemModel: { findOrCreate: async () => [systemConfig] },
AuthDataType: {},
},
'../data/open': {
AppModel: {
findOne: async () => ({ get: () => ({ scopes: ['dashboard'] }) }),
},
},
'../data/runningInstance': {
RunningInstanceModel: instances,
InstanceStatus: { running: 0, stopped: 2 },
},
'../config': {},
'../config/util': { fileExist: async () => true, isDemoEnv: () => false },
'../shared/store': { shareStore: { updateAuthInfo: async () => {} } },
'../shared/i18n': { setLang() {} },
'./logger': {},
'../schedule/client': {
readiness: { configure() {}, recover: async () => {} },
},
}).default;
// Dependency installation is unrelated to startup reconciliation.
const originalTimeout = global.setTimeout;
global.setTimeout = () => 0;
const before = Math.floor(Date.now() / 1000);
try {
await initData();
} finally {
global.setTimeout = originalTimeout;
}
const rows = await instances.findAll({ order: [['id', 'ASC']], raw: true });
assert.equal(rows[0].status, 2);
assert.ok(
rows[0].finished_at >= before &&
rows[0].finished_at <= Math.floor(Date.now() / 1000),
);
assert.equal(rows[0].exit_code, null);
assert.deepEqual(
rows
.slice(1)
.map(({ status, finished_at, exit_code }) => [
status,
finished_at,
exit_code,
]),
[
[1, 110, 0],
[2, 120, 143],
[3, 130, 7],
],
);
global.setTimeout = () => 0;
try {
await initData();
} finally {
global.setTimeout = originalTimeout;
}
assert.deepEqual(
await instances.findAll({ order: [['id', 'ASC']], raw: true }),
rows,
);
});
+113
View File
@@ -0,0 +1,113 @@
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const test = require('node:test');
const load = require('../helpers/load-security-module.cjs');
async function fixture(t, sameHome = false, missingUser = false) {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-ssh-home-'));
t.after(() => fs.rm(directory, { recursive: true, force: true }));
const userHome = path.join(directory, 'user');
const envHome = sameHome ? userHome : path.join(directory, 'env');
const sshdPath = path.join(directory, 'ssh.d');
await fs.mkdir(sshdPath);
const util = {
fileExist: async (file) =>
fs.stat(file).then(
() => true,
() => false,
),
rmPath: async (file) => fs.rm(file, { force: true }),
};
const { writeFileWithLock } = load('back/shared/utils.ts', {
'../config/util': util,
});
let initialized;
const ready = new Promise((resolve) => (initialized = resolve));
const SshKeyService = load('back/services/sshKey.ts', {
os: {
...os,
homedir: () => envHome,
userInfo: () => {
if (missingUser) throw Error('No passwd entry');
return { homedir: userHome };
},
},
'../config': { sshdPath },
'../config/util': util,
'../shared/utils': {
writeFileWithLock: async (file, content, options) => {
await writeFileWithLock(file, content, options);
if (content.includes('Include ')) {
initialized();
}
},
},
}).default;
const service = new SshKeyService({ error: assert.fail, warn: assert.fail });
await ready;
return { service, userHome, envHome, sshdPath };
}
for (const sameHome of [false, true]) {
test(`SSH configuration uses the effective user home with ${
sameHome ? 'matching' : 'overridden'
} HOME`, async (t) => {
const { service, userHome, envHome, sshdPath } = await fixture(t, sameHome);
const configFile = path.join(userHome, '.ssh/config');
assert.equal(
await fs.readFile(configFile, 'utf8'),
`Include ${sshdPath}/*.config\n\n`,
);
assert.equal((await fs.stat(path.dirname(configFile))).mode & 0o777, 0o700);
assert.equal((await fs.stat(configFile)).mode & 0o777, 0o600);
if (!sameHome)
await assert.rejects(fs.stat(path.join(envHome, '.ssh/config')), {
code: 'ENOENT',
});
await fs.appendFile(
configFile,
'Host existing\n Hostname example.com\n',
);
await service.initSshConfigFile();
const content = await fs.readFile(configFile, 'utf8');
assert.equal(content.split('Include ').length, 2);
assert.match(content, /Host existing/);
});
}
test('SSH initialization still works for a custom UID without a passwd entry', async (t) => {
const { envHome, sshdPath } = await fixture(t, true, true);
assert.equal(
await fs.readFile(path.join(envHome, '.ssh/config'), 'utf8'),
`Include ${sshdPath}/*.config\n\n`,
);
});
test('SSH alias changes retain private key permissions and remove old credentials', async (t) => {
const { service, sshdPath } = await fixture(t);
const {
getSubscriptionSshAlias,
} = require('../../back/shared/subscriptionPath');
const original = 'private/中文@v1';
const renamed = 'renamed/中文@v2';
await service.addSSHKey('fixture-private-key', original, '127.0.0.1');
const first = getSubscriptionSshAlias(original);
assert.equal((await fs.stat(path.join(sshdPath, first))).mode & 0o777, 0o400);
assert.match(
await fs.readFile(path.join(sshdPath, `${first}.config`), 'utf8'),
/Hostname 127\.0\.0\.1/,
);
await service.removeSSHKey(original, '127.0.0.1');
await service.addSSHKey('fixture-private-key', renamed, '127.0.0.1');
await assert.rejects(fs.stat(path.join(sshdPath, first)), { code: 'ENOENT' });
await assert.rejects(fs.stat(path.join(sshdPath, `${first}.config`)), {
code: 'ENOENT',
});
assert.equal(
(await fs.stat(path.join(sshdPath, getSubscriptionSshAlias(renamed))))
.mode & 0o777,
0o400,
);
});
+6 -1
View File
@@ -312,7 +312,12 @@ test('static verification still fails when fresh notifications never synchronize
},
);
assert.equal(result.status, 1, result.stderr + result.stdout);
assert.equal(git('-C', source, 'rev-list', '--count', triggerRef), '2');
// The retry contract is checked above. A short polling deadline can expire
// while the REST fixture is still sending its second notification.
const notifications = Number(
git('-C', source, 'rev-list', '--count', triggerRef),
);
assert.ok(notifications >= 1 && notifications <= 2);
assert.match(result.stdout, /::error::Mirror did not match/);
const invalid = spawnSync(
'bash',