mirror of
https://github.com/whyour/qinglong.git
synced 2026-10-16 22:35:32 +08:00
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:
+6
-4
@@ -846,23 +846,25 @@ async function _updateLinuxMirror(
|
||||
mirrorDomainWithScheme: string,
|
||||
): Promise<string> {
|
||||
const S = isInContainer() ? 'sudo ' : '';
|
||||
// APT otherwise returns zero when index downloads fail with transient errors.
|
||||
const aptUpdate = `${S}apt-get update --error-on=any`;
|
||||
let filePath: string, currentDomainWithScheme: string | null;
|
||||
switch (osType) {
|
||||
case 'Debian':
|
||||
filePath = '/etc/apt/sources.list.d/debian.sources';
|
||||
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
|
||||
if (currentDomainWithScheme) {
|
||||
return `${S}sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://deb.debian.org'}|g' ${filePath} || (${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://deb.debian.org'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg" | ${S}tee ${filePath}) && ${S}apt-get update`;
|
||||
return `${S}sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://deb.debian.org'}|g' ${filePath} || (${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://deb.debian.org'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg" | ${S}tee ${filePath}) && ${aptUpdate}`;
|
||||
} else {
|
||||
return `${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://deb.debian.org'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg" | ${S}tee ${filePath} && ${S}apt-get update`;
|
||||
return `${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://deb.debian.org'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg" | ${S}tee ${filePath} && ${aptUpdate}`;
|
||||
}
|
||||
case 'Ubuntu':
|
||||
filePath = '/etc/apt/sources.list.d/ubuntu.sources';
|
||||
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
|
||||
if (currentDomainWithScheme) {
|
||||
return `${S}sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}|g' ${filePath} || (${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-backports\\nComponents: main restricted universe multiverse\\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" | ${S}tee ${filePath}) && ${S}apt-get update`;
|
||||
return `${S}sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}|g' ${filePath} || (${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-backports\\nComponents: main restricted universe multiverse\\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" | ${S}tee ${filePath}) && ${aptUpdate}`;
|
||||
} else {
|
||||
return `${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-backports\\nComponents: main restricted universe multiverse\\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" | ${S}tee ${filePath} && ${S}apt-get update`;
|
||||
return `${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-backports\\nComponents: main restricted universe multiverse\\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" | ${S}tee ${filePath} && ${aptUpdate}`;
|
||||
}
|
||||
case 'Alpine':
|
||||
filePath = '/etc/apk/repositories';
|
||||
|
||||
@@ -144,7 +144,11 @@ export default async () => {
|
||||
|
||||
// 清空所有运行中的实例记录(服务重启后进程已不存在)
|
||||
await RunningInstanceModel.update(
|
||||
{ status: InstanceStatus.stopped },
|
||||
{
|
||||
status: InstanceStatus.stopped,
|
||||
finished_at: Math.floor(Date.now() / 1000),
|
||||
// The previous process's exit code cannot be recovered after a restart.
|
||||
},
|
||||
{ where: { status: InstanceStatus.running } },
|
||||
);
|
||||
|
||||
|
||||
+13
-1
@@ -15,7 +15,15 @@ import {
|
||||
|
||||
@Service()
|
||||
export default class SshKeyService {
|
||||
private homedir = os.homedir();
|
||||
// OpenSSH uses the effective user's passwd entry, not the HOME override.
|
||||
private get homedir(): string {
|
||||
try {
|
||||
return os.userInfo().homedir;
|
||||
} catch {
|
||||
// Custom container UIDs may have no passwd entry. Keep startup working.
|
||||
return os.homedir();
|
||||
}
|
||||
}
|
||||
private sshPath = config.sshdPath;
|
||||
private sshConfigFilePath = path.resolve(this.homedir, '.ssh', 'config');
|
||||
private sshConfigHeader = `Include ${path.join(this.sshPath, '*.config')}`;
|
||||
@@ -25,6 +33,10 @@ export default class SshKeyService {
|
||||
}
|
||||
|
||||
private async initSshConfigFile() {
|
||||
await fs.mkdir(path.dirname(this.sshConfigFilePath), {
|
||||
recursive: true,
|
||||
mode: 0o700,
|
||||
});
|
||||
let config = '';
|
||||
const _exist = await fileExist(this.sshConfigFilePath);
|
||||
if (_exist) {
|
||||
|
||||
@@ -239,7 +239,6 @@ export default class SystemService {
|
||||
return;
|
||||
}
|
||||
const command = await updateLinuxMirrorFile(info.linuxMirror || '');
|
||||
let hasError = false;
|
||||
this.scheduleService.runTask(
|
||||
command,
|
||||
{
|
||||
@@ -247,14 +246,15 @@ export default class SystemService {
|
||||
res?.setHeader('QL-Task-Pid', `${cp.pid}`);
|
||||
res?.end();
|
||||
},
|
||||
onEnd: async () => {
|
||||
onEnd: async (cp) => {
|
||||
this.sockService.sendMessage({
|
||||
type: 'updateLinuxMirror',
|
||||
message: 'update linux mirror end',
|
||||
status: 'completed',
|
||||
});
|
||||
onEnd?.();
|
||||
if (!hasError) {
|
||||
// Tools such as sudo and apt may write warnings to stderr on success.
|
||||
if (cp?.exitCode === 0 && cp.signalCode === null) {
|
||||
await this.updateAuthDb({
|
||||
...oDoc,
|
||||
info: { ...oDoc.info, ...info },
|
||||
@@ -262,7 +262,6 @@ export default class SystemService {
|
||||
}
|
||||
},
|
||||
onError: async (message: string) => {
|
||||
hasError = true;
|
||||
this.sockService.sendMessage({ type: 'updateLinuxMirror', message });
|
||||
},
|
||||
onLog: async (message: string) => {
|
||||
|
||||
@@ -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/);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user