mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-02 15:27:58 +08:00
feat: ship verified platform upgrades with channel bundle releases
Package production dependencies per architecture for online and offline upgrades. Validate dependencies and isolated backend startup before readiness, repair once when permitted, and preserve service/data through failed preparation or rollback. Publish release bundles with master images and expose an opt-in develop build sharing the development index across Alpine and Debian images.
This commit is contained in:
@@ -0,0 +1,398 @@
|
||||
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 {
|
||||
sha256,
|
||||
} = require('../../cli/src/internal/maintenance/platformBundle.cjs');
|
||||
const {
|
||||
platforms,
|
||||
publishBundles,
|
||||
} = require('../../scripts/publish-upgrade-bundles.cjs');
|
||||
|
||||
function fixture(t) {
|
||||
const directory = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql-publish-bundles-'),
|
||||
);
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const state = {
|
||||
releases: new Map(),
|
||||
head: 'a'.repeat(40),
|
||||
developHead: 'c'.repeat(40),
|
||||
calls: [],
|
||||
nextId: 1,
|
||||
};
|
||||
const options = {
|
||||
directory,
|
||||
commit: state.head,
|
||||
repository: 'whyour/qinglong',
|
||||
branch: 'master',
|
||||
version: '2.24.0',
|
||||
buildId: '100-1',
|
||||
};
|
||||
const write = (
|
||||
commit = options.commit,
|
||||
content = 'first',
|
||||
version = options.version,
|
||||
) => {
|
||||
for (const target of platforms) {
|
||||
const filename = `qinglong-${commit}-${target}.tar.gz`;
|
||||
const bytes = Buffer.from(`${version}/${content}/${target}`);
|
||||
fs.writeFileSync(path.join(directory, filename), bytes);
|
||||
fs.writeFileSync(
|
||||
path.join(directory, filename + '.json'),
|
||||
JSON.stringify({
|
||||
format: 2,
|
||||
sourceCommit: commit,
|
||||
version,
|
||||
target,
|
||||
filename,
|
||||
size: bytes.length,
|
||||
sha256: sha256(bytes),
|
||||
}),
|
||||
);
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(directory, 'install-bundle.cjs'),
|
||||
`// ${content} installer`,
|
||||
);
|
||||
};
|
||||
const notFound = () => {
|
||||
throw Object.assign(new Error('Not found'), {
|
||||
stderr: 'gh: Not Found (HTTP 404)',
|
||||
});
|
||||
};
|
||||
const gh = (...args) => {
|
||||
state.calls.push(args);
|
||||
if (args[0] === 'api') {
|
||||
const route = args[1];
|
||||
if (route.endsWith('/git/ref/heads/master')) return state.head;
|
||||
if (route.endsWith('/git/ref/heads/develop')) return state.developHead;
|
||||
if (route.includes('/releases/tags/')) {
|
||||
const tag = decodeURIComponent(route.split('/releases/tags/')[1]);
|
||||
if (state.lookupFailure === tag)
|
||||
throw Object.assign(new Error('Unavailable'), { stderr: 'HTTP 503' });
|
||||
const release = state.releases.get(tag);
|
||||
if (!release) return notFound();
|
||||
return JSON.stringify({
|
||||
assets: Array.from(release.values(), (a) => ({
|
||||
name: a.name,
|
||||
id: a.id,
|
||||
size: a.bytes.length,
|
||||
digest: a.digest,
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (route.includes('/releases/assets/')) {
|
||||
const id = Number(route.split('/').at(-1));
|
||||
for (const release of state.releases.values())
|
||||
for (const asset of release.values())
|
||||
if (asset.id === id) return asset.bytes.toString();
|
||||
return notFound();
|
||||
}
|
||||
}
|
||||
if (args[0] === 'release' && args[1] === 'create') {
|
||||
assert.ok(!state.releases.has(args[2]));
|
||||
state.releases.set(args[2], new Map());
|
||||
return '';
|
||||
}
|
||||
if (args[0] === 'release' && args[1] === 'upload') {
|
||||
const tag = args[2],
|
||||
release = state.releases.get(tag);
|
||||
assert.ok(release);
|
||||
const files = args.slice(3, args.indexOf('--repo'));
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
if (state.failBatchUpload && files.length > 1 && i === 4)
|
||||
throw new Error('Upload interrupted');
|
||||
const name = path.basename(files[i]),
|
||||
bytes = fs.readFileSync(files[i]);
|
||||
assert.ok(
|
||||
!release.has(name) || args.includes('--clobber'),
|
||||
'Batch assets must not be overwritten',
|
||||
);
|
||||
release.set(name, {
|
||||
name,
|
||||
id: state.nextId++,
|
||||
bytes: Buffer.from(bytes),
|
||||
digest: `sha256:${sha256(bytes)}`,
|
||||
});
|
||||
}
|
||||
if (files.length > 1) {
|
||||
if (state.corruptUploadedBatch)
|
||||
release.get(path.basename(files[0])).digest = 'sha256:wrong';
|
||||
if (state.changeHeadAfterUpload) state.head = 'b'.repeat(40);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
if (args[0] === 'release' && args[1] === 'edit') return '';
|
||||
throw new Error(`Unexpected gh call: ${JSON.stringify(args)}`);
|
||||
};
|
||||
const publish = (overrides = {}) =>
|
||||
publishBundles({ ...options, gh, ...overrides });
|
||||
const index = (tag = 'upgrade-master') =>
|
||||
JSON.parse(state.releases.get(tag).get('latest.json').bytes);
|
||||
write();
|
||||
return { directory, state, options, write, publish, index };
|
||||
}
|
||||
|
||||
test('publishes every platform and installer before advancing version and legacy master indexes', (t) => {
|
||||
const f = fixture(t);
|
||||
assert.equal(f.publish(), true);
|
||||
const index = f.index();
|
||||
assert.equal(index.version, '2.24.0');
|
||||
assert.equal(index.buildId, '100-1');
|
||||
assert.equal(index.sourceCommit, f.options.commit);
|
||||
assert.equal(Object.keys(index.packages).length, 13);
|
||||
assert.deepEqual(index, f.index('upgrade-v2.24.0'));
|
||||
const tag = 'upgrade-build-v2.24.0-100-1';
|
||||
assert.equal(f.state.releases.get(tag).size, 28);
|
||||
for (const entry of [...Object.values(index.packages), index.installer]) {
|
||||
const url = new URL(entry.url),
|
||||
filename = path.basename(url.pathname);
|
||||
assert.ok(url.pathname.includes(`/releases/download/${tag}/`));
|
||||
const bytes = f.state.releases.get(tag).get(filename).bytes;
|
||||
assert.equal(bytes.length, entry.size);
|
||||
assert.equal(sha256(bytes), entry.sha256);
|
||||
}
|
||||
const writes = f.state.calls.filter(
|
||||
(a) => a[0] === 'release' && a[1] === 'upload',
|
||||
);
|
||||
assert.deepEqual(
|
||||
writes.map((a) => a[2]),
|
||||
[tag, 'upgrade-v2.24.0', 'upgrade-master'],
|
||||
);
|
||||
assert.ok(writes.slice(1).every((a) => a.includes('--clobber')));
|
||||
});
|
||||
|
||||
for (const sameCommit of [false, true])
|
||||
test(`same-version replacement updates both indexes and retains in-flight downloads (${
|
||||
sameCommit ? 'same commit rerun' : 'new commit'
|
||||
})`, (t) => {
|
||||
const f = fixture(t);
|
||||
f.publish();
|
||||
const before = f.index();
|
||||
const oldAssets = new Map(
|
||||
Array.from(
|
||||
f.state.releases.get('upgrade-build-v2.24.0-100-1'),
|
||||
([name, a]) => [name, a.bytes.toString('base64')],
|
||||
),
|
||||
);
|
||||
const commit = sameCommit ? f.options.commit : 'b'.repeat(40);
|
||||
f.state.head = commit;
|
||||
f.write(commit, 'replacement');
|
||||
assert.equal(f.publish({ commit, buildId: '100-2' }), true);
|
||||
const after = f.index();
|
||||
assert.equal(after.version, before.version);
|
||||
assert.equal(after.buildId, '100-2');
|
||||
assert.equal(after.sourceCommit, commit);
|
||||
assert.deepEqual(after, f.index('upgrade-v2.24.0'));
|
||||
for (const target of platforms) {
|
||||
assert.notEqual(after.packages[target].url, before.packages[target].url);
|
||||
assert.notEqual(
|
||||
after.packages[target].sha256,
|
||||
before.packages[target].sha256,
|
||||
);
|
||||
}
|
||||
assert.notEqual(after.installer.url, before.installer.url);
|
||||
assert.notEqual(after.installer.sha256, before.installer.sha256);
|
||||
assert.deepEqual(
|
||||
new Map(
|
||||
Array.from(
|
||||
f.state.releases.get('upgrade-build-v2.24.0-100-1'),
|
||||
([name, a]) => [name, a.bytes.toString('base64')],
|
||||
),
|
||||
),
|
||||
oldAssets,
|
||||
);
|
||||
});
|
||||
|
||||
test('retrying a publication is idempotent, but changed bytes require a new build ID', (t) => {
|
||||
const f = fixture(t);
|
||||
f.publish();
|
||||
const count = () =>
|
||||
f.state.calls.filter(
|
||||
(a) =>
|
||||
a[0] === 'release' &&
|
||||
a[1] === 'upload' &&
|
||||
a[2] === 'upgrade-build-v2.24.0-100-1',
|
||||
).length;
|
||||
assert.equal(count(), 1);
|
||||
f.publish();
|
||||
assert.equal(count(), 1);
|
||||
f.write(f.options.commit, 'changed within the same attempt');
|
||||
assert.throws(() => f.publish(), /Immutable release asset differs/);
|
||||
assert.equal(f.index().buildId, '100-1');
|
||||
});
|
||||
|
||||
for (const fault of ['missing', 'corrupt', 'wrong-version'])
|
||||
test(`incomplete, corrupt or wrong-version packages never publish (${fault})`, (t) => {
|
||||
const f = fixture(t);
|
||||
const archive = path.join(
|
||||
f.directory,
|
||||
`qinglong-${f.options.commit}-linux-armv6-glibc.tar.gz`,
|
||||
);
|
||||
if (fault === 'missing') fs.rmSync(archive);
|
||||
else if (fault === 'corrupt') fs.appendFileSync(archive, 'corrupt');
|
||||
else {
|
||||
const record = JSON.parse(fs.readFileSync(archive + '.json'));
|
||||
record.version = '2.23.0';
|
||||
fs.writeFileSync(archive + '.json', JSON.stringify(record));
|
||||
}
|
||||
assert.throws(() => f.publish(), /ENOENT|Unverified/);
|
||||
assert.equal(f.state.calls.length, 0);
|
||||
});
|
||||
|
||||
for (const fault of ['failBatchUpload', 'corruptUploadedBatch'])
|
||||
test(`failed batch publication keeps both existing indexes (${fault})`, (t) => {
|
||||
const f = fixture(t);
|
||||
f.publish();
|
||||
const previous = f.index();
|
||||
f.write(f.options.commit, 'next');
|
||||
f.state[fault] = true;
|
||||
assert.throws(
|
||||
() => f.publish({ buildId: '100-2' }),
|
||||
/Upload interrupted|Published platform bundle verification failed/,
|
||||
);
|
||||
assert.deepEqual(f.index(), previous);
|
||||
assert.deepEqual(f.index('upgrade-v2.24.0'), previous);
|
||||
});
|
||||
|
||||
test('stale commits do not publish; a source change during upload cannot advance indexes', (t) => {
|
||||
const f = fixture(t);
|
||||
f.publish();
|
||||
const before = f.index();
|
||||
f.state.head = 'b'.repeat(40);
|
||||
assert.equal(f.publish({ buildId: '101-1' }), false);
|
||||
assert.ok(!f.state.releases.has('upgrade-build-v2.24.0-101-1'));
|
||||
f.state.head = f.options.commit;
|
||||
f.state.changeHeadAfterUpload = true;
|
||||
assert.equal(f.publish({ buildId: '102-1' }), false);
|
||||
assert.deepEqual(f.index(), before);
|
||||
assert.deepEqual(f.index('upgrade-v2.24.0'), before);
|
||||
});
|
||||
|
||||
test('an explicitly rerun earlier workflow may replace the same version and commit', (t) => {
|
||||
const f = fixture(t);
|
||||
f.publish({ buildId: '101-1' });
|
||||
const before = f.index();
|
||||
f.write(f.options.commit, 'rebuild from an earlier run');
|
||||
assert.equal(f.publish({ buildId: '100-2' }), true);
|
||||
assert.equal(f.index().buildId, '100-2');
|
||||
assert.deepEqual(f.index(), f.index('upgrade-v2.24.0'));
|
||||
assert.notEqual(
|
||||
f.index().packages[platforms[0]].sha256,
|
||||
before.packages[platforms[0]].sha256,
|
||||
);
|
||||
});
|
||||
|
||||
test('non-master branches, invalid build IDs and API failures cannot overwrite published versions', (t) => {
|
||||
const f = fixture(t);
|
||||
for (const branch of ['develop', 'v2.24.0', 'feature/test'])
|
||||
assert.throws(() => f.publish({ branch }), /Only master/);
|
||||
for (const buildId of [undefined, '100', '100-1/other'])
|
||||
assert.throws(() => f.publish({ buildId }), /Invalid bundle build ID/);
|
||||
assert.throws(
|
||||
() => f.publish({ version: '../bad' }),
|
||||
/Invalid bundle version/,
|
||||
);
|
||||
assert.equal(f.state.calls.length, 0);
|
||||
f.publish();
|
||||
const before = f.index();
|
||||
f.state.lookupFailure = 'upgrade-v2.24.0';
|
||||
assert.throws(() => f.publish({ buildId: '100-2' }), /Unavailable/);
|
||||
assert.deepEqual(f.index(), before);
|
||||
});
|
||||
|
||||
test('existing master clients can migrate from indexes without version/build metadata', (t) => {
|
||||
const f = fixture(t);
|
||||
f.publish();
|
||||
const asset = f.state.releases.get('upgrade-master').get('latest.json');
|
||||
const legacy = f.index();
|
||||
delete legacy.version;
|
||||
delete legacy.buildId;
|
||||
asset.bytes = Buffer.from(JSON.stringify(legacy));
|
||||
assert.equal(f.publish({ buildId: '100-2' }), true);
|
||||
assert.equal(f.index().buildId, '100-2');
|
||||
});
|
||||
|
||||
test('explicit development publication shares musl/glibc packages without changing version or latest production assets', (t) => {
|
||||
const f = fixture(t);
|
||||
f.publish();
|
||||
const production = f.index();
|
||||
f.write(f.state.developHead, 'development');
|
||||
assert.equal(
|
||||
f.publish({
|
||||
branch: 'develop',
|
||||
commit: f.state.developHead,
|
||||
allowDevelopment: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
const development = f.index('upgrade-develop');
|
||||
assert.equal(development.channel, 'develop');
|
||||
assert.equal(development.sourceCommit, f.state.developHead);
|
||||
assert.equal(Object.keys(development.packages).length, 13);
|
||||
assert.ok(development.packages['linux-x64-musl']);
|
||||
assert.ok(development.packages['linux-x64-glibc']);
|
||||
assert.ok(development.packages['linux-armv6-musl']);
|
||||
assert.ok(development.packages['linux-armv6-glibc']);
|
||||
for (const entry of [
|
||||
...Object.values(development.packages),
|
||||
development.installer,
|
||||
])
|
||||
assert.ok(entry.url.includes('/upgrade-build-develop-v2.24.0-100-1/'));
|
||||
assert.deepEqual(f.index(), production);
|
||||
assert.deepEqual(f.index('upgrade-v2.24.0'), production);
|
||||
assert.equal(f.state.releases.get('upgrade-build-v2.24.0-100-1').size, 28);
|
||||
assert.equal(
|
||||
f.state.releases.get('upgrade-build-develop-v2.24.0-100-1').size,
|
||||
28,
|
||||
);
|
||||
});
|
||||
|
||||
test('development builds require explicit boolean opt-in and cannot publish obsolete development source', (t) => {
|
||||
const f = fixture(t);
|
||||
for (const allowDevelopment of [false, undefined, 'true'])
|
||||
assert.throws(
|
||||
() => f.publish({ branch: 'develop', allowDevelopment }),
|
||||
/explicitly requested/,
|
||||
);
|
||||
assert.throws(
|
||||
() => f.publish({ branch: 'feature/test', allowDevelopment: true }),
|
||||
/Only master/,
|
||||
);
|
||||
assert.equal(f.state.calls.length, 0);
|
||||
f.write(f.state.developHead, 'development');
|
||||
const obsolete = f.state.developHead;
|
||||
f.state.developHead = 'd'.repeat(40);
|
||||
assert.equal(
|
||||
f.publish({ branch: 'develop', commit: obsolete, allowDevelopment: true }),
|
||||
false,
|
||||
);
|
||||
assert.equal(f.state.releases.size, 0);
|
||||
});
|
||||
|
||||
for (const fault of ['failBatchUpload', 'corruptUploadedBatch'])
|
||||
test(`failed manual development publication preserves both channels (${fault})`, (t) => {
|
||||
const f = fixture(t);
|
||||
f.publish();
|
||||
const production = f.index();
|
||||
const options = {
|
||||
branch: 'develop',
|
||||
commit: f.state.developHead,
|
||||
allowDevelopment: true,
|
||||
};
|
||||
f.write(options.commit, 'first development');
|
||||
f.publish(options);
|
||||
const previous = f.index('upgrade-develop');
|
||||
f.write(options.commit, 'next development');
|
||||
f.state[fault] = true;
|
||||
assert.throws(
|
||||
() => f.publish({ ...options, buildId: '100-2' }),
|
||||
/Upload interrupted|Published platform bundle verification failed/,
|
||||
);
|
||||
assert.deepEqual(f.index('upgrade-develop'), previous);
|
||||
assert.deepEqual(f.index(), production);
|
||||
assert.deepEqual(f.index('upgrade-v2.24.0'), production);
|
||||
});
|
||||
Reference in New Issue
Block a user