mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,467 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { createHash, generateKeyPairSync, sign } = require('node:crypto');
|
||||
const { readFileSync } = require('node:fs');
|
||||
const { join } = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
InvalidPluginPackageBundleError,
|
||||
InvalidPluginPackagePublisherTrustError,
|
||||
PLUGIN_PACKAGE_BUNDLE_MEDIA_TYPE,
|
||||
PLUGIN_PACKAGE_SIGNATURE_SCHEMA,
|
||||
PluginPackageBundleUnavailableError,
|
||||
PluginPackagePublisherTrustRegistry,
|
||||
UntrustedPluginPackagePublisherError,
|
||||
inspectPluginPackageBundle,
|
||||
pluginPackageContentTreeDigest,
|
||||
pluginPackagePublisherSignaturePayload,
|
||||
} = require('../dist/plugin-package/pluginPackageBundle');
|
||||
const {
|
||||
createPluginPackageLock,
|
||||
pluginPackageInstallActionDigest,
|
||||
pluginPackageInstallPlanDigest,
|
||||
pluginPackageManifestDigest,
|
||||
serializePluginPackageManifest,
|
||||
} = require('../dist/plugin-package/installation/pluginPackageInstall');
|
||||
const {
|
||||
PLUGIN_PACKAGE_API_VERSION,
|
||||
PLUGIN_PACKAGE_KIND,
|
||||
planPluginPackageInstall,
|
||||
} = require('../dist/plugin-package/pluginPackage');
|
||||
|
||||
const PUBLISHER = 'packages.example.com';
|
||||
const KEY_ID = 'release-2026';
|
||||
const OBSERVED_AT_MS = 500;
|
||||
|
||||
function manifest() {
|
||||
return {
|
||||
apiVersion: PLUGIN_PACKAGE_API_VERSION,
|
||||
kind: PLUGIN_PACKAGE_KIND,
|
||||
metadata: {
|
||||
name: 'example-monitor',
|
||||
displayName: 'Example Monitor',
|
||||
version: '1.2.0',
|
||||
description: 'Collects one bounded report',
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
spec: {
|
||||
compatibility: {
|
||||
qinglong: '>=3.0.0-0 <4.0.0',
|
||||
architectures: ['arm64'],
|
||||
deploymentProfiles: ['edge'],
|
||||
},
|
||||
runtimes: [{ name: 'python', version: '>=3.10.0 <4.0.0' }],
|
||||
resources: {
|
||||
memory: { recommended: '32Mi' },
|
||||
disk: { install: '8Mi', working: '32Mi' },
|
||||
},
|
||||
permissions: {
|
||||
network: { allowedHosts: ['api.example.com'] },
|
||||
secrets: [{ name: 'EXAMPLE_TOKEN', required: true }],
|
||||
tools: ['notification.send'],
|
||||
},
|
||||
contents: {
|
||||
tasks: ['tasks/collect.yaml'],
|
||||
workflows: ['workflows/daily.yaml'],
|
||||
prompts: [],
|
||||
tools: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function environment() {
|
||||
return {
|
||||
qinglongVersion: '3.0.0-alpha.0',
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
runtimes: [{ name: 'python', version: '3.12.4' }],
|
||||
availableMemoryBytes: 256 * 1024 * 1024,
|
||||
availableDiskBytes: 512 * 1024 * 1024,
|
||||
};
|
||||
}
|
||||
|
||||
function octal(value, bytes) {
|
||||
return Buffer.from(
|
||||
`${value.toString(8).padStart(bytes - 1, '0')}\0`,
|
||||
'ascii',
|
||||
);
|
||||
}
|
||||
|
||||
function tarHeader(path, bytes) {
|
||||
const header = Buffer.alloc(512);
|
||||
const pathBytes = Buffer.from(path);
|
||||
if (pathBytes.byteLength > 100) throw new Error('test path is too long');
|
||||
pathBytes.copy(header, 0);
|
||||
Buffer.from('0000644\0').copy(header, 100);
|
||||
Buffer.from('0000000\0').copy(header, 108);
|
||||
Buffer.from('0000000\0').copy(header, 116);
|
||||
octal(bytes, 12).copy(header, 124);
|
||||
Buffer.from('00000000000\0').copy(header, 136);
|
||||
header.fill(0x20, 148, 156);
|
||||
Buffer.from('0').copy(header, 156);
|
||||
Buffer.from('ustar\0').copy(header, 257);
|
||||
Buffer.from('00').copy(header, 263);
|
||||
const checksum = header.reduce((total, byte) => total + byte, 0);
|
||||
Buffer.from(`${checksum.toString(8).padStart(6, '0')}\0 `).copy(header, 148);
|
||||
return header;
|
||||
}
|
||||
|
||||
function tar(entries) {
|
||||
const parts = [];
|
||||
for (const entry of entries) {
|
||||
parts.push(tarHeader(entry.path, entry.body.byteLength), entry.body);
|
||||
const padding = (512 - (entry.body.byteLength % 512)) % 512;
|
||||
if (padding > 0) parts.push(Buffer.alloc(padding));
|
||||
}
|
||||
parts.push(Buffer.alloc(1024));
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
|
||||
function sha256(value) {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const packageManifest = manifest();
|
||||
const canonicalManifest = Buffer.from(
|
||||
serializePluginPackageManifest(packageManifest),
|
||||
);
|
||||
const contents = [
|
||||
{
|
||||
path: 'tasks/collect.yaml',
|
||||
body: Buffer.from('apiVersion: qinglong.io/v1\\nkind: Task\\n'),
|
||||
},
|
||||
{
|
||||
path: 'workflows/daily.yaml',
|
||||
body: Buffer.from('apiVersion: qinglong.io/v1\\nkind: Workflow\\n'),
|
||||
},
|
||||
];
|
||||
const artifact = tar([
|
||||
{ path: 'package.json', body: canonicalManifest },
|
||||
...contents,
|
||||
]);
|
||||
const contentDescriptors = contents.map(({ path, body }) => ({
|
||||
path,
|
||||
bytes: body.byteLength,
|
||||
digest: sha256(body),
|
||||
}));
|
||||
const installEnvironment = environment();
|
||||
const plan = planPluginPackageInstall(packageManifest, installEnvironment);
|
||||
const source = {
|
||||
kind: 'offline',
|
||||
locator: `offline:sha256:${sha256(artifact)}`,
|
||||
artifactDigest: sha256(artifact),
|
||||
artifactBytes: artifact.byteLength,
|
||||
contentDigest: pluginPackageContentTreeDigest(contentDescriptors),
|
||||
};
|
||||
const actionInput = {
|
||||
lockId: 'lock-bundle-001',
|
||||
projectId: 'project-001',
|
||||
manifest: packageManifest,
|
||||
plan,
|
||||
environment: installEnvironment,
|
||||
source,
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
targetGeneration: 1,
|
||||
};
|
||||
const lock = createPluginPackageLock({
|
||||
...actionInput,
|
||||
approval: {
|
||||
requestId: 'approval-001',
|
||||
requestVersion: 1,
|
||||
dispatchId: 'dispatch-001',
|
||||
actionDigest: pluginPackageInstallActionDigest(actionInput),
|
||||
previewDigest: pluginPackageInstallPlanDigest(plan),
|
||||
approvedBy: { type: 'user', id: 'owner-001' },
|
||||
approvedAtMs: 100,
|
||||
expiresAtMs: 1_000,
|
||||
fence: { projectVersion: 3, bindingVersion: 4 },
|
||||
},
|
||||
createdAtMs: 200,
|
||||
});
|
||||
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
||||
const trust = new PluginPackagePublisherTrustRegistry([
|
||||
{
|
||||
publisher: PUBLISHER,
|
||||
keyId: KEY_ID,
|
||||
publicKeyPem: publicKey.export({ format: 'pem', type: 'spki' }),
|
||||
notBeforeMs: 100,
|
||||
notAfterMs: 1_000,
|
||||
},
|
||||
]);
|
||||
const signature = {
|
||||
schema: PLUGIN_PACKAGE_SIGNATURE_SCHEMA,
|
||||
publisher: PUBLISHER,
|
||||
keyId: KEY_ID,
|
||||
signature: sign(
|
||||
null,
|
||||
pluginPackagePublisherSignaturePayload(lock, PUBLISHER, KEY_ID),
|
||||
privateKey,
|
||||
).toString('base64url'),
|
||||
};
|
||||
return {
|
||||
artifact,
|
||||
canonicalManifest,
|
||||
contentDescriptors,
|
||||
lock,
|
||||
packageManifest,
|
||||
privateKey,
|
||||
signature,
|
||||
trust,
|
||||
};
|
||||
}
|
||||
|
||||
async function* chunks(value, widths = [1, 17, 509, 3, 1024]) {
|
||||
let offset = 0;
|
||||
let index = 0;
|
||||
while (offset < value.byteLength) {
|
||||
const end = Math.min(
|
||||
value.byteLength,
|
||||
offset + widths[index % widths.length],
|
||||
);
|
||||
yield value.subarray(offset, end);
|
||||
offset = end;
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function inspect(value, overrides = {}) {
|
||||
return inspectPluginPackageBundle({
|
||||
lock: value.lock,
|
||||
manifest: value.packageManifest,
|
||||
signature: value.signature,
|
||||
trust: value.trust,
|
||||
observedAtMs: OBSERVED_AT_MS,
|
||||
chunks: chunks(value.artifact),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test('inspects one canonical streaming USTAR bundle and publisher signature', async () => {
|
||||
const value = fixture();
|
||||
const inspection = await inspect(value);
|
||||
assert.equal(inspection.mediaType, PLUGIN_PACKAGE_BUNDLE_MEDIA_TYPE);
|
||||
assert.equal(inspection.lockDigest, value.lock.lockDigest);
|
||||
assert.equal(inspection.artifactDigest, value.lock.source.artifactDigest);
|
||||
assert.equal(
|
||||
inspection.manifestDigest,
|
||||
pluginPackageManifestDigest(manifest()),
|
||||
);
|
||||
assert.equal(inspection.contentDigest, value.lock.source.contentDigest);
|
||||
assert.deepEqual(
|
||||
inspection.entries.map(({ path }) => path),
|
||||
['package.json', 'tasks/collect.yaml', 'workflows/daily.yaml'],
|
||||
);
|
||||
assert.equal(inspection.signature.publisher, PUBLISHER);
|
||||
assert.equal(Object.isFrozen(inspection), true);
|
||||
});
|
||||
|
||||
test('streams exact entry bytes through a transactional sink', async () => {
|
||||
const value = fixture();
|
||||
const events = [];
|
||||
const bodies = [];
|
||||
let current = [];
|
||||
await inspect(value, {
|
||||
chunks: chunks(value.artifact, [7]),
|
||||
sink: {
|
||||
begin(entry) {
|
||||
events.push(`begin:${entry.path}`);
|
||||
current = [];
|
||||
},
|
||||
write(chunk) {
|
||||
current.push(Buffer.from(chunk));
|
||||
},
|
||||
end(entry) {
|
||||
events.push(`end:${entry.path}`);
|
||||
bodies.push(Buffer.concat(current));
|
||||
},
|
||||
commit(inspection) {
|
||||
events.push(`commit:${inspection.lockDigest}`);
|
||||
},
|
||||
abort() {
|
||||
events.push('abort');
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.deepEqual(bodies[0], value.canonicalManifest);
|
||||
assert.equal(
|
||||
bodies[1].toString(),
|
||||
'apiVersion: qinglong.io/v1\\nkind: Task\\n',
|
||||
);
|
||||
assert.equal(events.at(-1), `commit:${value.lock.lockDigest}`);
|
||||
assert.equal(events.includes('abort'), false);
|
||||
});
|
||||
|
||||
test('rejects missing, extra, reordered and non-canonical archive entries', async () => {
|
||||
const value = fixture();
|
||||
const taskBody = Buffer.from('apiVersion: qinglong.io/v1\\nkind: Task\\n');
|
||||
const workflowBody = Buffer.from(
|
||||
'apiVersion: qinglong.io/v1\\nkind: Workflow\\n',
|
||||
);
|
||||
const variants = [
|
||||
tar([
|
||||
{ path: 'package.json', body: value.canonicalManifest },
|
||||
{ path: 'workflows/daily.yaml', body: workflowBody },
|
||||
{ path: 'tasks/collect.yaml', body: taskBody },
|
||||
]),
|
||||
tar([
|
||||
{ path: 'package.json', body: value.canonicalManifest },
|
||||
{ path: 'tasks/collect.yaml', body: taskBody },
|
||||
]),
|
||||
];
|
||||
for (const artifact of variants) {
|
||||
await assert.rejects(
|
||||
inspect(value, { chunks: chunks(artifact) }),
|
||||
InvalidPluginPackageBundleError,
|
||||
);
|
||||
}
|
||||
const invalidMode = Buffer.from(value.artifact);
|
||||
Buffer.from('0000755\0').copy(invalidMode, 100);
|
||||
await assert.rejects(
|
||||
inspect(value, { chunks: chunks(invalidMode) }),
|
||||
InvalidPluginPackageBundleError,
|
||||
);
|
||||
const splitPath = Buffer.from(value.artifact);
|
||||
const splitHeaderOffset =
|
||||
512 + Math.ceil(value.canonicalManifest.byteLength / 512) * 512;
|
||||
assert.equal(
|
||||
splitPath
|
||||
.subarray(splitHeaderOffset, splitHeaderOffset + 100)
|
||||
.toString('utf8')
|
||||
.replace(/\0+$/, ''),
|
||||
'tasks/collect.yaml',
|
||||
);
|
||||
splitPath.fill(0, splitHeaderOffset, splitHeaderOffset + 100);
|
||||
Buffer.from('collect.yaml').copy(splitPath, splitHeaderOffset);
|
||||
Buffer.from('tasks').copy(splitPath, splitHeaderOffset + 345);
|
||||
splitPath.fill(0x20, splitHeaderOffset + 148, splitHeaderOffset + 156);
|
||||
const splitChecksum = splitPath
|
||||
.subarray(splitHeaderOffset, splitHeaderOffset + 512)
|
||||
.reduce((total, byte) => total + byte, 0);
|
||||
Buffer.from(`${splitChecksum.toString(8).padStart(6, '0')}\0 `).copy(
|
||||
splitPath,
|
||||
splitHeaderOffset + 148,
|
||||
);
|
||||
await assert.rejects(
|
||||
inspect(value, { chunks: chunks(splitPath) }),
|
||||
InvalidPluginPackageBundleError,
|
||||
);
|
||||
await assert.rejects(
|
||||
inspect(value, {
|
||||
chunks: chunks(Buffer.concat([value.artifact, Buffer.alloc(512)])),
|
||||
}),
|
||||
InvalidPluginPackageBundleError,
|
||||
);
|
||||
});
|
||||
|
||||
test('binds artifact, manifest and content digests to the immutable PackageLock', async () => {
|
||||
const value = fixture();
|
||||
const tampered = Buffer.from(value.artifact);
|
||||
tampered[512] ^= 1;
|
||||
await assert.rejects(
|
||||
inspect(value, { chunks: chunks(tampered) }),
|
||||
InvalidPluginPackageBundleError,
|
||||
);
|
||||
await assert.rejects(
|
||||
inspect(value, {
|
||||
manifest: {
|
||||
...value.packageManifest,
|
||||
metadata: {
|
||||
...value.packageManifest.metadata,
|
||||
description: 'changed after approval',
|
||||
},
|
||||
},
|
||||
}),
|
||||
InvalidPluginPackageBundleError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects unknown, expired and invalid Ed25519 publisher signatures', async () => {
|
||||
const value = fixture();
|
||||
const invalidSignature = Buffer.from(
|
||||
value.signature.signature,
|
||||
'base64url',
|
||||
);
|
||||
invalidSignature[0] ^= 1;
|
||||
await assert.rejects(
|
||||
inspect(value, { observedAtMs: 1_000 }),
|
||||
UntrustedPluginPackagePublisherError,
|
||||
);
|
||||
await assert.rejects(
|
||||
inspect(value, {
|
||||
signature: {
|
||||
...value.signature,
|
||||
signature: invalidSignature.toString('base64url'),
|
||||
},
|
||||
}),
|
||||
UntrustedPluginPackagePublisherError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
new PluginPackagePublisherTrustRegistry([
|
||||
{
|
||||
publisher: PUBLISHER,
|
||||
keyId: KEY_ID,
|
||||
publicKeyPem: 'not a public key',
|
||||
notBeforeMs: 0,
|
||||
notAfterMs: 1,
|
||||
},
|
||||
]),
|
||||
InvalidPluginPackagePublisherTrustError,
|
||||
);
|
||||
});
|
||||
|
||||
test('aborts a sink exactly once and hides infrastructure failure details', async () => {
|
||||
const value = fixture();
|
||||
let aborts = 0;
|
||||
await assert.rejects(
|
||||
inspect(value, {
|
||||
sink: {
|
||||
begin() {},
|
||||
write() {
|
||||
throw new Error('private filesystem detail');
|
||||
},
|
||||
end() {},
|
||||
commit() {},
|
||||
abort() {
|
||||
aborts += 1;
|
||||
},
|
||||
},
|
||||
}),
|
||||
PluginPackageBundleUnavailableError,
|
||||
);
|
||||
assert.equal(aborts, 1);
|
||||
});
|
||||
|
||||
test('rejects unsafe public content descriptors and oversized chunks', async () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
pluginPackageContentTreeDigest([
|
||||
{ path: '../escape', bytes: 1, digest: 'a'.repeat(64) },
|
||||
]),
|
||||
InvalidPluginPackageBundleError,
|
||||
);
|
||||
const value = fixture();
|
||||
async function* oversized() {
|
||||
yield Buffer.alloc(1024 * 1024 + 1);
|
||||
}
|
||||
await assert.rejects(
|
||||
inspect(value, { chunks: oversized() }),
|
||||
InvalidPluginPackageBundleError,
|
||||
);
|
||||
});
|
||||
|
||||
test('publishes bundle authority only through its explicit subpath', () => {
|
||||
const root = require('../dist');
|
||||
const subpath = require('@qinglong/runtime-core/plugin-package-bundle');
|
||||
assert.equal(root.inspectPluginPackageBundle, undefined);
|
||||
assert.equal(subpath.inspectPluginPackageBundle, inspectPluginPackageBundle);
|
||||
const source = readFileSync(
|
||||
join(__dirname, '..', 'src', 'plugin-package', 'pluginPackageBundle.ts'),
|
||||
'utf8',
|
||||
);
|
||||
assert.doesNotMatch(source, /node:(?:fs|net|http|https|tls|dgram)/);
|
||||
assert.doesNotMatch(source, /\b(?:setTimeout|setInterval|process)\b/);
|
||||
});
|
||||
Reference in New Issue
Block a user