mirror of
https://github.com/whyour/qinglong.git
synced 2026-10-16 22:35:32 +08:00
* fix: resolve Debian regression findings * fix: reject failed APT index refreshes and simplify SSH home lookup
442 lines
17 KiB
JavaScript
442 lines
17 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const os = require('node:os');
|
|
const { execFileSync, spawn, spawnSync } = require('node:child_process');
|
|
const notify = path.resolve('scripts/wait-for-mirror.sh');
|
|
const verify = notify;
|
|
const triggerRef = 'refs/heads/gitee-sync-trigger';
|
|
const git = (...args) =>
|
|
execFileSync('git', args, { encoding: 'utf8', stdio: 'pipe' }).trim();
|
|
|
|
function fixture(t) {
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-mirror-trigger-'));
|
|
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
|
const work = path.join(tmp, 'work');
|
|
const source = path.join(tmp, 'source.git');
|
|
const mirror = path.join(tmp, 'mirror.git');
|
|
for (const repo of [source, mirror]) git('init', '--bare', repo);
|
|
git('init', work);
|
|
for (const repo of [source, work]) {
|
|
git('-C', repo, 'config', 'user.name', 'Fixture');
|
|
git('-C', repo, 'config', 'user.email', 'fixture@example.invalid');
|
|
}
|
|
git('-C', work, 'checkout', '-b', 'develop');
|
|
fs.writeFileSync(path.join(work, 'snapshot'), 'static artifact');
|
|
git('-C', work, 'add', '.');
|
|
git('-C', work, 'commit', '-m', 'snapshot');
|
|
git('-C', work, 'push', source, 'develop');
|
|
const bin = path.join(tmp, 'bin');
|
|
fs.mkdirSync(bin);
|
|
// Model GitHub's Git Data API with real Git objects and non-forced ref updates.
|
|
// The fixture's post-receive hook simulates Gitee receiving the push event.
|
|
fs.writeFileSync(
|
|
path.join(bin, 'gh'),
|
|
`#!${process.execPath}
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { execFileSync, spawnSync } = require('node:child_process');
|
|
const args = process.argv.slice(2);
|
|
const endpoint = args.find((arg) => arg.startsWith('repos/'));
|
|
const method = args.includes('--method') ? args[args.indexOf('--method') + 1] : 'GET';
|
|
const fields = {};
|
|
for (let i = 0; i < args.length; i++) {
|
|
if (args[i] === '-f' || args[i] === '-F') {
|
|
const field = args[++i];
|
|
const separator = field.indexOf('=');
|
|
fields[field.slice(0, separator)] = field.slice(separator + 1);
|
|
}
|
|
}
|
|
fs.appendFileSync(process.env.QL_TEST_API_LOG, JSON.stringify({method, endpoint, fields, time: Date.now()}) + '\\n');
|
|
const source = process.env.QL_TEST_SOURCE;
|
|
const git = (...args) => execFileSync('git', ['-C', source, ...args], {encoding: 'utf8', stdio: 'pipe'}).trim();
|
|
const fail = (status) => { console.error('gh: API failure (HTTP ' + status + ')'); process.exit(1); };
|
|
const ref = 'refs/heads/gitee-sync-trigger';
|
|
const isUpdate = method === 'PATCH' || endpoint.endsWith('/git/refs');
|
|
if (process.env.QL_TEST_API_HANG) spawnSync('sleep', ['30'], {stdio: 'ignore'});
|
|
if (process.env.QL_TEST_API_REJECT) fail(500);
|
|
if (isUpdate && process.env.QL_TEST_COLLISION && !fs.existsSync(process.env.QL_TEST_COLLISION)) {
|
|
const current = git('rev-parse', ref);
|
|
const tree = git('rev-parse', current + '^{tree}');
|
|
const peer = git('commit-tree', tree, '-p', current, '-m', 'Concurrent notifier');
|
|
git('update-ref', ref, peer);
|
|
fs.writeFileSync(process.env.QL_TEST_COLLISION, peer);
|
|
}
|
|
if (isUpdate && process.env.QL_TEST_UPDATE_FAILURE && !fs.existsSync(process.env.QL_TEST_UPDATE_FAILURE)) {
|
|
fs.writeFileSync(process.env.QL_TEST_UPDATE_FAILURE, 'failed');
|
|
fail(500);
|
|
}
|
|
try {
|
|
if (method === 'GET' && endpoint.includes('/git/ref/')) {
|
|
console.log(git('rev-parse', '--verify', 'refs/' + endpoint.split('/git/ref/')[1]));
|
|
} else if (method === 'GET' && endpoint.includes('/git/commits/')) {
|
|
console.log(git('rev-parse', endpoint.split('/git/commits/')[1] + '^{tree}'));
|
|
} else if (endpoint.endsWith('/git/trees')) {
|
|
const body = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
const entries = body.tree.map((entry) => {
|
|
const blob = execFileSync('git', ['-C', source, 'hash-object', '-w', '--stdin'], {encoding: 'utf8', input: entry.content}).trim();
|
|
return entry.mode + ' blob ' + blob + '\\t' + entry.path + '\\n';
|
|
}).join('');
|
|
console.log(execFileSync('git', ['-C', source, 'mktree'], {encoding: 'utf8', input: entries}).trim());
|
|
} else if (endpoint.endsWith('/git/commits')) {
|
|
const parents = fields['parents[]'] ? ['-p', fields['parents[]']] : [];
|
|
console.log(git('commit-tree', fields.tree, ...parents, '-m', fields.message));
|
|
} else if (isUpdate) {
|
|
const current = spawnSync('git', ['-C', source, 'rev-parse', '--verify', ref], {encoding: 'utf8'});
|
|
const old = current.status === 0 ? current.stdout.trim() : '0'.repeat(40);
|
|
if (method === 'PATCH') {
|
|
if (fields.force !== 'false' || current.status !== 0 || spawnSync('git', ['-C', source, 'merge-base', '--is-ancestor', old, fields.sha]).status !== 0) fail(422);
|
|
} else if (current.status === 0 || fields.ref !== ref) fail(422);
|
|
git('update-ref', ref, fields.sha, old);
|
|
const hook = path.join(source, 'hooks/post-receive');
|
|
if (fs.existsSync(hook)) execFileSync(hook, {cwd: source, input: old + ' ' + fields.sha + ' ' + ref + '\\n', env: {...process.env, GIT_DIR: source}, stdio: ['pipe', 'pipe', 'pipe']});
|
|
} else fail(400);
|
|
} catch (error) { fail(method === 'GET' ? 404 : 422); }
|
|
`,
|
|
{ mode: 0o755 },
|
|
);
|
|
const env = {
|
|
...process.env,
|
|
PATH: `${bin}${path.delimiter}${process.env.PATH}`,
|
|
QL_TEST_SOURCE: source,
|
|
QL_TEST_API_LOG: path.join(tmp, 'api.log'),
|
|
QL_MIRROR_NOTIFY_REPO: 'fixture/source',
|
|
QL_MIRROR_INCLUDE_VERSION_BRANCH: 'true',
|
|
QL_MIRROR_WAIT_SECONDS: '4',
|
|
QL_MIRROR_RETRY_AFTER_SECONDS: '1',
|
|
QL_MIRROR_POLL_INTERVAL: '1',
|
|
QL_MIRROR_QUERY_TIMEOUT: '1',
|
|
};
|
|
return { tmp, work, source, mirror, env };
|
|
}
|
|
|
|
test('static notifications use a tiny REST branch with unchanged trees and preserve published refs', (t) => {
|
|
const { source, env } = fixture(t);
|
|
const snapshot = git('-C', source, 'rev-parse', 'develop');
|
|
execFileSync('bash', [notify, source, source, 'develop', 'branch'], {
|
|
stdio: 'pipe',
|
|
env,
|
|
});
|
|
const first = git('-C', source, 'rev-parse', triggerRef);
|
|
execFileSync('bash', [notify, source, source, 'develop', 'branch'], {
|
|
stdio: 'pipe',
|
|
env,
|
|
});
|
|
const second = git('-C', source, 'rev-parse', triggerRef);
|
|
assert.notEqual(first, second);
|
|
assert.equal(git('-C', source, 'rev-parse', `${second}^`), first);
|
|
assert.equal(
|
|
git('-C', source, 'ls-tree', '-r', '--name-only', second),
|
|
'.gitee-sync-trigger',
|
|
);
|
|
assert.equal(git('-C', source, 'diff', '--name-only', first, second), '');
|
|
assert.equal(git('-C', source, 'rev-parse', 'develop'), snapshot);
|
|
assert.match(
|
|
git('-C', source, 'log', '-1', '--format=%s', second),
|
|
new RegExp(snapshot),
|
|
);
|
|
});
|
|
|
|
test('REST notifications retry a newer parent and GitHub 500s with backoff', (t) => {
|
|
const { tmp, source, env } = fixture(t);
|
|
execFileSync('bash', [notify, source, source, 'develop'], {
|
|
stdio: 'pipe',
|
|
env,
|
|
});
|
|
const parent = git('-C', source, 'rev-parse', triggerRef);
|
|
const flag = path.join(tmp, 'collision');
|
|
const collision = spawnSync('bash', [notify, source, source, 'develop'], {
|
|
encoding: 'utf8',
|
|
env: { ...env, QL_TEST_COLLISION: flag },
|
|
});
|
|
assert.equal(collision.status, 0, collision.stderr + collision.stdout);
|
|
assert.match(collision.stdout, /notification update failed \(attempt 1\)/);
|
|
const peer = fs.readFileSync(flag, 'utf8');
|
|
assert.equal(git('-C', source, 'rev-parse', `${peer}^`), parent);
|
|
assert.equal(git('-C', source, 'rev-parse', `${triggerRef}^`), peer);
|
|
const failed = path.join(tmp, 'failed');
|
|
const recovery = spawnSync('bash', [notify, source, source, 'develop'], {
|
|
encoding: 'utf8',
|
|
env: { ...env, QL_TEST_UPDATE_FAILURE: failed },
|
|
});
|
|
assert.equal(recovery.status, 0, recovery.stderr + recovery.stdout);
|
|
assert.match(recovery.stderr, /HTTP 500/);
|
|
const calls = fs
|
|
.readFileSync(env.QL_TEST_API_LOG, 'utf8')
|
|
.trim()
|
|
.split('\n')
|
|
.map(JSON.parse);
|
|
const updates = calls.filter((call) => call.method === 'PATCH');
|
|
assert.ok(updates.every((call) => call.fields.force === 'false'));
|
|
const last = updates.slice(-2);
|
|
assert.ok(
|
|
last[1].time - last[0].time >= 1900,
|
|
'failed API updates must back off',
|
|
);
|
|
});
|
|
|
|
test('missing snapshots fail before sending any notification', (t) => {
|
|
const { source, env } = fixture(t);
|
|
const missing = spawnSync('bash', [notify, source, source, 'missing'], {
|
|
encoding: 'utf8',
|
|
env,
|
|
});
|
|
assert.equal(missing.status, 1, missing.stderr + missing.stdout);
|
|
assert.match(missing.stdout, /Published source ref.*does not exist/);
|
|
assert.equal(fs.existsSync(env.QL_TEST_API_LOG), false);
|
|
});
|
|
|
|
test('notification failure does not prevent an already synchronized mirror from passing', (t) => {
|
|
const { source, mirror, env } = fixture(t);
|
|
git('-C', source, 'push', mirror, 'develop');
|
|
const result = spawnSync('bash', [verify, source, mirror, 'develop'], {
|
|
encoding: 'utf8',
|
|
env: { ...env, QL_TEST_API_REJECT: 'true' },
|
|
});
|
|
assert.equal(result.status, 0, result.stderr + result.stdout);
|
|
assert.match(
|
|
result.stdout,
|
|
/Could not send the mirror notification; continuing/,
|
|
);
|
|
assert.match(result.stdout, /Mirror matches the latest source refs/);
|
|
});
|
|
|
|
test('a failed delayed notification keeps polling until the mirror updates', async (t) => {
|
|
const { source, mirror, env } = fixture(t);
|
|
const child = spawn('bash', [verify, source, mirror, 'develop'], {
|
|
env: {
|
|
...env,
|
|
QL_MIRROR_NOTIFY_ON_START: 'false',
|
|
QL_MIRROR_WAIT_SECONDS: '10',
|
|
QL_TEST_API_REJECT: 'true',
|
|
},
|
|
});
|
|
let output = '';
|
|
let error = '';
|
|
let synchronized = false;
|
|
child.stderr.on('data', (data) => {
|
|
error += data;
|
|
});
|
|
child.stdout.on('data', (data) => {
|
|
output += data;
|
|
if (
|
|
!synchronized &&
|
|
output.includes('Could not read notification ref (attempt 1)')
|
|
) {
|
|
synchronized = true;
|
|
git('-C', source, 'push', mirror, 'develop');
|
|
}
|
|
});
|
|
const status = await new Promise((resolve, reject) => {
|
|
child.on('error', reject);
|
|
child.on('close', resolve);
|
|
});
|
|
assert.ok(synchronized);
|
|
assert.equal(status, 0, error + output);
|
|
assert.match(output, /Mirror matches the latest source refs/);
|
|
});
|
|
|
|
test('failed or hung notifications preserve the mirror polling deadline', (t) => {
|
|
const { source, mirror, env } = fixture(t);
|
|
for (const failure of ['QL_TEST_API_REJECT', 'QL_TEST_API_HANG']) {
|
|
const started = Date.now();
|
|
const result = spawnSync('bash', [verify, source, mirror, 'develop'], {
|
|
encoding: 'utf8',
|
|
timeout: 6000,
|
|
env: {
|
|
...env,
|
|
QL_MIRROR_NOTIFY_ON_START: 'false',
|
|
QL_MIRROR_WAIT_SECONDS: '3',
|
|
[failure]: 'true',
|
|
},
|
|
});
|
|
assert.equal(result.status, 1, result.stderr + result.stdout);
|
|
assert.ok(Date.now() - started < 5000);
|
|
assert.match(result.stdout, /::error::Mirror did not match.*within 3s/);
|
|
}
|
|
});
|
|
|
|
test('static verification retries a small GitHub notification after a missed sync interval', (t) => {
|
|
const { tmp, source, mirror, env } = fixture(t);
|
|
const snapshot = git('-C', source, 'rev-parse', 'develop');
|
|
git('-C', source, 'update-ref', 'refs/heads/v2.22.0', snapshot);
|
|
git('-C', source, 'update-ref', 'refs/tags/v2.22.0', snapshot);
|
|
const countFile = path.join(tmp, 'requests');
|
|
fs.writeFileSync(
|
|
path.join(source, 'hooks/post-receive'),
|
|
`#!/bin/sh
|
|
while read before after ref; do
|
|
if [ "$ref" = refs/heads/gitee-sync-trigger ]; then
|
|
count=0
|
|
if [ -f "$QL_TEST_COUNT" ]; then count=$(cat "$QL_TEST_COUNT"); fi
|
|
count=$((count + 1))
|
|
printf '%s' "$count" > "$QL_TEST_COUNT"
|
|
if [ "$count" -ge 2 ]; then
|
|
git push --quiet "$QL_TEST_MIRROR" refs/heads/v2.22.0:refs/heads/v2.22.0 refs/tags/v2.22.0:refs/tags/v2.22.0
|
|
fi
|
|
fi
|
|
done
|
|
`,
|
|
{ mode: 0o755 },
|
|
);
|
|
const result = spawnSync('bash', [verify, source, mirror, 'v2.22.0', 'tag'], {
|
|
encoding: 'utf8',
|
|
env: {
|
|
...env,
|
|
QL_TEST_COUNT: countFile,
|
|
QL_TEST_MIRROR: mirror,
|
|
},
|
|
});
|
|
assert.equal(result.status, 0, result.stderr + result.stdout);
|
|
assert.equal(fs.readFileSync(countFile, 'utf8'), '2');
|
|
assert.match(result.stdout, /Retrying the GitHub notification/);
|
|
assert.match(result.stdout, /Mirror matches the latest source refs/);
|
|
assert.doesNotMatch(result.stdout, /::error::/);
|
|
for (const ref of ['refs/heads/v2.22.0', 'refs/tags/v2.22.0']) {
|
|
assert.equal(git('-C', mirror, 'rev-parse', ref), snapshot);
|
|
}
|
|
});
|
|
|
|
test('static verification still fails when fresh notifications never synchronize the mirror', (t) => {
|
|
const { source, mirror, env } = fixture(t);
|
|
const result = spawnSync(
|
|
'bash',
|
|
[verify, source, mirror, 'develop', 'branch'],
|
|
{
|
|
encoding: 'utf8',
|
|
env: {
|
|
...env,
|
|
QL_MIRROR_WAIT_SECONDS: '2',
|
|
},
|
|
},
|
|
);
|
|
assert.equal(result.status, 1, result.stderr + result.stdout);
|
|
// 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',
|
|
[verify, source, mirror, 'develop', 'branch'],
|
|
{
|
|
encoding: 'utf8',
|
|
env: { ...env, QL_MIRROR_WAIT_SECONDS: '0' },
|
|
},
|
|
);
|
|
assert.equal(invalid.status, 2);
|
|
});
|
|
|
|
test('code mirrors that already match do not send another notification', (t) => {
|
|
const { source, mirror, env } = fixture(t);
|
|
git('-C', source, 'push', mirror, 'develop');
|
|
const codeEnv = {
|
|
...env,
|
|
QL_MIRROR_NOTIFY_REPO: 'fixture/source',
|
|
QL_MIRROR_NOTIFY_ON_START: 'false',
|
|
QL_MIRROR_INCLUDE_VERSION_BRANCH: 'false',
|
|
};
|
|
const result = spawnSync(
|
|
'bash',
|
|
[verify, source, mirror, 'develop', 'branch'],
|
|
{
|
|
encoding: 'utf8',
|
|
env: codeEnv,
|
|
},
|
|
);
|
|
assert.equal(result.status, 0, result.stderr + result.stdout);
|
|
assert.match(result.stdout, /Mirror matches the latest source refs/);
|
|
assert.doesNotMatch(result.stdout, /Sent a small GitHub push event via REST/);
|
|
assert.notEqual(
|
|
spawnSync('git', ['-C', source, 'rev-parse', '--verify', triggerRef])
|
|
.status,
|
|
0,
|
|
);
|
|
const invalid = spawnSync(
|
|
'bash',
|
|
[verify, source, mirror, 'develop', 'branch'],
|
|
{
|
|
encoding: 'utf8',
|
|
env: { ...codeEnv, QL_MIRROR_NOTIFY_ON_START: 'later' },
|
|
},
|
|
);
|
|
assert.equal(invalid.status, 2);
|
|
});
|
|
|
|
test('code mirrors notify after the sync interval and verify tags without version branches', (t) => {
|
|
const { tmp, work, source, mirror, env } = fixture(t);
|
|
const snapshot = git('-C', source, 'rev-parse', 'develop');
|
|
git('-C', work, 'tag', '-a', 'v2.22.0', '-m', 'release');
|
|
git('-C', work, 'push', source, 'refs/tags/v2.22.0');
|
|
const tag = git('-C', source, 'rev-parse', 'refs/tags/v2.22.0');
|
|
const countFile = path.join(tmp, 'requests');
|
|
fs.writeFileSync(
|
|
path.join(source, 'hooks/post-receive'),
|
|
`#!/bin/sh
|
|
while read before after ref; do
|
|
if [ "$ref" = refs/heads/gitee-sync-trigger ]; then
|
|
printf 'notification\n' >> "$QL_TEST_COUNT"
|
|
git push --quiet "$QL_TEST_MIRROR" refs/tags/v2.22.0:refs/tags/v2.22.0
|
|
fi
|
|
done
|
|
`,
|
|
{ mode: 0o755 },
|
|
);
|
|
const started = Date.now();
|
|
const result = spawnSync('bash', [verify, source, mirror, 'v2.22.0', 'tag'], {
|
|
encoding: 'utf8',
|
|
env: {
|
|
...env,
|
|
QL_MIRROR_NOTIFY_REPO: 'fixture/source',
|
|
QL_MIRROR_NOTIFY_ON_START: 'false',
|
|
QL_MIRROR_INCLUDE_VERSION_BRANCH: 'false',
|
|
QL_MIRROR_RETRY_AFTER_SECONDS: '1',
|
|
QL_TEST_COUNT: countFile,
|
|
QL_TEST_MIRROR: mirror,
|
|
},
|
|
});
|
|
assert.equal(result.status, 0, result.stderr + result.stdout);
|
|
assert.ok(
|
|
Date.now() - started >= 900,
|
|
'notification should wait for the sync interval',
|
|
);
|
|
assert.equal(fs.readFileSync(countFile, 'utf8'), 'notification\n');
|
|
assert.equal(git('-C', source, 'rev-list', '--count', triggerRef), '1');
|
|
assert.equal(git('-C', source, 'rev-parse', 'develop'), snapshot);
|
|
assert.equal(git('-C', mirror, 'rev-parse', 'refs/tags/v2.22.0'), tag);
|
|
assert.notEqual(
|
|
spawnSync('git', [
|
|
'-C',
|
|
mirror,
|
|
'rev-parse',
|
|
'--verify',
|
|
'refs/heads/v2.22.0',
|
|
]).status,
|
|
0,
|
|
);
|
|
});
|
|
|
|
test('code mirrors that never synchronize fail after one delayed notification', (t) => {
|
|
const { source, mirror, env } = fixture(t);
|
|
const result = spawnSync(
|
|
'bash',
|
|
[verify, source, mirror, 'develop', 'branch'],
|
|
{
|
|
encoding: 'utf8',
|
|
env: {
|
|
...env,
|
|
QL_MIRROR_NOTIFY_REPO: 'fixture/source',
|
|
QL_MIRROR_NOTIFY_ON_START: 'false',
|
|
QL_MIRROR_WAIT_SECONDS: '3',
|
|
QL_MIRROR_RETRY_AFTER_SECONDS: '1',
|
|
},
|
|
},
|
|
);
|
|
assert.equal(result.status, 1, result.stderr + result.stdout);
|
|
assert.equal(git('-C', source, 'rev-list', '--count', triggerRef), '1');
|
|
assert.match(result.stdout, /::error::Mirror did not match/);
|
|
});
|