mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 01:32:44 +08:00
601 lines
17 KiB
JavaScript
601 lines
17 KiB
JavaScript
const assert = require('node:assert/strict');
|
|
const { test } = require('node:test');
|
|
|
|
const {
|
|
createPluginPackageResourceGenerationFromReferences,
|
|
} = require('../dist/plugin-package/pluginPackageResourceGeneration');
|
|
const {
|
|
MAX_PLUGIN_PACKAGE_MANIFEST_BYTES,
|
|
PLUGIN_PACKAGE_API_VERSION,
|
|
PLUGIN_PACKAGE_KIND,
|
|
planPluginPackageInstall,
|
|
} = require('../dist/plugin-package/pluginPackage');
|
|
const {
|
|
createPluginPackageLock,
|
|
pluginPackageInstallActionDigest,
|
|
pluginPackageInstallPlanDigest,
|
|
serializePluginPackageManifest,
|
|
} = require('../dist/plugin-package/installation/pluginPackageInstall');
|
|
const {
|
|
pluginPackageContentTreeDigest,
|
|
} = require('../dist/plugin-package/pluginPackageBundle');
|
|
const {
|
|
createBuiltInTaskSpecSemanticRegistry,
|
|
} = require('../dist/task-definition/taskSpecSemantic');
|
|
const { createSecretRef } = require('../dist/secret/secretReference');
|
|
const {
|
|
InvalidPluginPackageResourceMaterializationError,
|
|
MAX_PLUGIN_PACKAGE_MATERIALIZED_RESOURCE_BYTES,
|
|
PLUGIN_PACKAGE_MATERIALIZED_REVISION_SCHEMA,
|
|
PluginPackageResourceMaterializationConflictError,
|
|
materializeActivePluginPackageResources,
|
|
materializePluginPackageResources,
|
|
normalizePluginPackageMaterializedRevision,
|
|
pluginPackageTaskDefinitionDrafts,
|
|
pluginPackageToolDefinitions,
|
|
} = require('../dist/plugin-package/pluginPackageResourceMaterialization');
|
|
|
|
const ARTIFACT_DIGEST = 'a'.repeat(64);
|
|
const OCI_MANIFEST_DIGEST = 'f'.repeat(64);
|
|
|
|
function resourceValues(overrides = {}) {
|
|
return {
|
|
'prompts/report.json': {
|
|
schema: 'qinglong/plugin-package-prompt-resource@v1',
|
|
id: 'report',
|
|
name: 'Report prompt',
|
|
description: 'Creates one bounded report',
|
|
template: 'Hello {{name}}\n',
|
|
parameters: [
|
|
{ name: 'name', description: 'Display name', required: true },
|
|
],
|
|
},
|
|
'tasks/collect.json': {
|
|
schema: 'qinglong/plugin-package-task-resource@v1',
|
|
id: 'collect',
|
|
name: 'Collect',
|
|
labels: { 'plugin.qinglong.io/source': 'example-monitor' },
|
|
enabled: true,
|
|
kind: 'command',
|
|
spec: {
|
|
schema: 'qinglong/command@v1',
|
|
config: {
|
|
command: {
|
|
kind: 'argv',
|
|
file: '/usr/bin/printf',
|
|
args: ['ok'],
|
|
},
|
|
environment: [{ name: 'MODE', kind: 'public', value: 'safe' }],
|
|
timeoutMs: 30_000,
|
|
},
|
|
},
|
|
},
|
|
'tools/query.json': {
|
|
schema: 'qinglong/plugin-package-tool-resource@v1',
|
|
definition: {
|
|
name: 'example-monitor.query',
|
|
version: '1.0.0',
|
|
description: 'Queries one bounded report',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {},
|
|
required: [],
|
|
additionalProperties: false,
|
|
},
|
|
effect: 'read',
|
|
risk: 'low',
|
|
requiredPermissions: ['run.read'],
|
|
timeoutSeconds: 30,
|
|
},
|
|
},
|
|
'workflows/daily.json': {
|
|
schema: 'qinglong/plugin-package-workflow-resource@v1',
|
|
id: 'daily',
|
|
name: 'Daily report',
|
|
enabled: true,
|
|
steps: [{ id: 'collect', task: 'collect', needs: [] }],
|
|
},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function manifest(overrides = {}) {
|
|
const value = {
|
|
apiVersion: PLUGIN_PACKAGE_API_VERSION,
|
|
kind: PLUGIN_PACKAGE_KIND,
|
|
metadata: {
|
|
name: 'example-monitor',
|
|
displayName: 'Example Monitor',
|
|
version: '1.0.0',
|
|
description: 'One bounded example package',
|
|
license: 'Apache-2.0',
|
|
},
|
|
spec: {
|
|
compatibility: {
|
|
qinglong: '>=3.0.0-0 <4.0.0',
|
|
architectures: ['arm64'],
|
|
deploymentProfiles: ['edge', 'standalone'],
|
|
},
|
|
runtimes: [],
|
|
resources: {
|
|
memory: { recommended: '32Mi' },
|
|
disk: { install: '8Mi', working: '16Mi' },
|
|
},
|
|
permissions: {
|
|
network: { allowedHosts: [] },
|
|
secrets: [],
|
|
tools: ['run.read', 'system.command'],
|
|
},
|
|
contents: {
|
|
tasks: ['tasks/collect.json'],
|
|
workflows: ['workflows/daily.json'],
|
|
prompts: ['prompts/report.json'],
|
|
tools: ['tools/query.json'],
|
|
},
|
|
},
|
|
};
|
|
return {
|
|
...value,
|
|
...overrides,
|
|
metadata: { ...value.metadata, ...overrides.metadata },
|
|
spec: {
|
|
...value.spec,
|
|
...overrides.spec,
|
|
compatibility: {
|
|
...value.spec.compatibility,
|
|
...overrides.spec?.compatibility,
|
|
},
|
|
resources: {
|
|
...value.spec.resources,
|
|
...overrides.spec?.resources,
|
|
memory: {
|
|
...value.spec.resources.memory,
|
|
...overrides.spec?.resources?.memory,
|
|
},
|
|
disk: {
|
|
...value.spec.resources.disk,
|
|
...overrides.spec?.resources?.disk,
|
|
},
|
|
},
|
|
permissions: {
|
|
...value.spec.permissions,
|
|
...overrides.spec?.permissions,
|
|
network: {
|
|
...value.spec.permissions.network,
|
|
...overrides.spec?.permissions?.network,
|
|
},
|
|
},
|
|
contents: {
|
|
...value.spec.contents,
|
|
...overrides.spec?.contents,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function environment() {
|
|
return {
|
|
qinglongVersion: '3.0.0-alpha.0',
|
|
architecture: 'arm64',
|
|
deploymentProfile: 'edge',
|
|
runtimes: [],
|
|
availableMemoryBytes: 128 * 1024 * 1024,
|
|
availableDiskBytes: 256 * 1024 * 1024,
|
|
};
|
|
}
|
|
|
|
function fixture(options = {}) {
|
|
const resourceObjects = resourceValues(options.resourceValues);
|
|
const packageManifest = manifest(options.manifest);
|
|
const resourceBytes = Object.fromEntries(
|
|
Object.entries(resourceObjects).map(([path, value]) => [
|
|
path,
|
|
Buffer.from(JSON.stringify(value)),
|
|
]),
|
|
);
|
|
const descriptors = Object.entries(resourceBytes)
|
|
.map(([path, material]) => ({
|
|
path,
|
|
bytes: material.byteLength,
|
|
digest: require('node:crypto')
|
|
.createHash('sha256')
|
|
.update(material)
|
|
.digest('hex'),
|
|
}))
|
|
.sort((left, right) => left.path.localeCompare(right.path));
|
|
const contentDigest = pluginPackageContentTreeDigest(descriptors);
|
|
const installEnvironment = environment();
|
|
const plan = planPluginPackageInstall(packageManifest, installEnvironment);
|
|
const actionInput = {
|
|
lockId: 'lock-001',
|
|
projectId: 'project-001',
|
|
manifest: packageManifest,
|
|
plan,
|
|
environment: installEnvironment,
|
|
source: {
|
|
kind: 'oci',
|
|
locator:
|
|
`oci://registry.example.com/qinglong/example-monitor@sha256:` +
|
|
OCI_MANIFEST_DIGEST,
|
|
artifactDigest: ARTIFACT_DIGEST,
|
|
artifactBytes: 4096,
|
|
contentDigest,
|
|
},
|
|
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: 1, bindingVersion: 1 },
|
|
},
|
|
createdAtMs: 200,
|
|
});
|
|
const generation = createPluginPackageResourceGenerationFromReferences({
|
|
installationId: 'install-001',
|
|
projectId: lock.projectId,
|
|
packageName: lock.packageName,
|
|
lockDigest: lock.lockDigest,
|
|
generation: lock.targetGeneration,
|
|
previousActiveLockDigest: null,
|
|
contentDigest,
|
|
resources: lock.resources,
|
|
});
|
|
const entries = generation.resources.map((reference) => ({
|
|
reference,
|
|
bytes: resourceBytes[reference.path],
|
|
}));
|
|
return {
|
|
manifest: packageManifest,
|
|
manifestBytes: Buffer.from(serializePluginPackageManifest(packageManifest)),
|
|
resourceBytes,
|
|
lock,
|
|
generation,
|
|
entries,
|
|
registry: createBuiltInTaskSpecSemanticRegistry(),
|
|
};
|
|
}
|
|
|
|
test('materializes exact Task, Workflow, Prompt and Tool JSON into one immutable revision', () => {
|
|
const value = fixture();
|
|
const revision = materializePluginPackageResources({
|
|
generation: value.generation,
|
|
lock: value.lock,
|
|
manifestBytes: value.manifestBytes,
|
|
resources: value.entries,
|
|
taskSpecSemanticRegistry: value.registry,
|
|
});
|
|
assert.equal(revision.schema, PLUGIN_PACKAGE_MATERIALIZED_REVISION_SCHEMA);
|
|
assert.deepEqual(
|
|
revision.resources.map(({ kind, path }) => ({ kind, path })),
|
|
value.generation.resources,
|
|
);
|
|
assert.match(revision.revisionDigest, /^[0-9a-f]{64}$/);
|
|
assert.equal(Object.isFrozen(revision), true);
|
|
assert.equal(Object.isFrozen(revision.resources), true);
|
|
assert.deepEqual(
|
|
normalizePluginPackageMaterializedRevision(revision, value.registry),
|
|
revision,
|
|
);
|
|
|
|
const drafts = pluginPackageTaskDefinitionDrafts(
|
|
revision,
|
|
value.registry,
|
|
);
|
|
assert.deepEqual(JSON.parse(JSON.stringify(drafts)), [
|
|
{
|
|
projectId: 'project-001',
|
|
taskId: 'pkg:example-monitor:collect',
|
|
name: 'Collect',
|
|
kind: 'command',
|
|
spec: {
|
|
schema: 'qinglong/command@v1',
|
|
config: {
|
|
command: {
|
|
kind: 'argv',
|
|
file: '/usr/bin/printf',
|
|
args: ['ok'],
|
|
},
|
|
environment: [{ name: 'MODE', kind: 'public', value: 'safe' }],
|
|
timeoutMs: 30_000,
|
|
},
|
|
},
|
|
labels: { 'plugin.qinglong.io/source': 'example-monitor' },
|
|
enabled: true,
|
|
},
|
|
]);
|
|
assert.equal(
|
|
pluginPackageToolDefinitions(revision, value.registry)[0].name,
|
|
'example-monitor.query',
|
|
);
|
|
});
|
|
|
|
test('fails closed on unapproved capabilities, unresolved references and source drift', () => {
|
|
const unapproved = fixture({
|
|
manifest: {
|
|
spec: {
|
|
permissions: {
|
|
tools: ['system.command'],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
assert.throws(
|
|
() =>
|
|
materializePluginPackageResources({
|
|
generation: unapproved.generation,
|
|
lock: unapproved.lock,
|
|
manifestBytes: unapproved.manifestBytes,
|
|
resources: unapproved.entries,
|
|
taskSpecSemanticRegistry: unapproved.registry,
|
|
}),
|
|
/required permission is not present/,
|
|
);
|
|
|
|
const missingTask = fixture({
|
|
resourceValues: {
|
|
'workflows/daily.json': {
|
|
schema: 'qinglong/plugin-package-workflow-resource@v1',
|
|
id: 'daily',
|
|
name: 'Daily report',
|
|
enabled: true,
|
|
steps: [{ id: 'missing', task: 'missing', needs: [] }],
|
|
},
|
|
},
|
|
});
|
|
assert.throws(
|
|
() =>
|
|
materializePluginPackageResources({
|
|
generation: missingTask.generation,
|
|
lock: missingTask.lock,
|
|
manifestBytes: missingTask.manifestBytes,
|
|
resources: missingTask.entries,
|
|
taskSpecSemanticRegistry: missingTask.registry,
|
|
}),
|
|
/unknown package Task/,
|
|
);
|
|
|
|
const drift = fixture();
|
|
const changed = drift.entries.map((entry, index) =>
|
|
index === 0
|
|
? { ...entry, bytes: Buffer.from('{}') }
|
|
: entry,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
materializePluginPackageResources({
|
|
generation: drift.generation,
|
|
lock: drift.lock,
|
|
manifestBytes: drift.manifestBytes,
|
|
resources: changed,
|
|
taskSpecSemanticRegistry: drift.registry,
|
|
}),
|
|
InvalidPluginPackageResourceMaterializationError,
|
|
);
|
|
});
|
|
|
|
test('rejects secret-bearing package Tasks until an approved binding format exists', () => {
|
|
const value = fixture({
|
|
manifest: {
|
|
spec: {
|
|
permissions: {
|
|
secrets: [{ name: 'TOKEN', required: true }],
|
|
tools: ['run.read', 'secret.use', 'system.command'],
|
|
},
|
|
},
|
|
},
|
|
resourceValues: {
|
|
'tasks/collect.json': {
|
|
schema: 'qinglong/plugin-package-task-resource@v1',
|
|
id: 'collect',
|
|
name: 'Collect',
|
|
labels: {},
|
|
enabled: true,
|
|
kind: 'command',
|
|
spec: {
|
|
schema: 'qinglong/command@v1',
|
|
config: {
|
|
command: {
|
|
kind: 'argv',
|
|
file: '/usr/bin/printf',
|
|
args: ['ok'],
|
|
},
|
|
environment: [
|
|
{
|
|
name: 'TOKEN',
|
|
kind: 'secret',
|
|
secretRef: createSecretRef({
|
|
projectId: 'project-001',
|
|
name: 'TOKEN',
|
|
}),
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
assert.throws(
|
|
() =>
|
|
materializePluginPackageResources({
|
|
generation: value.generation,
|
|
lock: value.lock,
|
|
manifestBytes: value.manifestBytes,
|
|
resources: value.entries,
|
|
taskSpecSemanticRegistry: value.registry,
|
|
}),
|
|
/does not support unresolved package Secret bindings/,
|
|
);
|
|
});
|
|
|
|
test('rejects cyclic Workflows, invalid UTF-8 and per-resource byte overflow', () => {
|
|
const cyclic = fixture({
|
|
resourceValues: {
|
|
'workflows/daily.json': {
|
|
schema: 'qinglong/plugin-package-workflow-resource@v1',
|
|
id: 'daily',
|
|
name: 'Daily report',
|
|
enabled: true,
|
|
steps: [
|
|
{ id: 'first', task: 'collect', needs: ['second'] },
|
|
{ id: 'second', task: 'collect', needs: ['first'] },
|
|
],
|
|
},
|
|
},
|
|
});
|
|
assert.throws(
|
|
() =>
|
|
materializePluginPackageResources({
|
|
generation: cyclic.generation,
|
|
lock: cyclic.lock,
|
|
manifestBytes: cyclic.manifestBytes,
|
|
resources: cyclic.entries,
|
|
taskSpecSemanticRegistry: cyclic.registry,
|
|
}),
|
|
/contains a cycle/,
|
|
);
|
|
|
|
const invalidUtf8 = fixture();
|
|
assert.throws(
|
|
() =>
|
|
materializePluginPackageResources({
|
|
generation: invalidUtf8.generation,
|
|
lock: invalidUtf8.lock,
|
|
manifestBytes: invalidUtf8.manifestBytes,
|
|
resources: invalidUtf8.entries.map((entry, index) =>
|
|
index === 0 ? { ...entry, bytes: Buffer.from([0xff]) } : entry,
|
|
),
|
|
taskSpecSemanticRegistry: invalidUtf8.registry,
|
|
}),
|
|
/not strict UTF-8/,
|
|
);
|
|
|
|
const overflow = fixture();
|
|
assert.throws(
|
|
() =>
|
|
materializePluginPackageResources({
|
|
generation: overflow.generation,
|
|
lock: overflow.lock,
|
|
manifestBytes: overflow.manifestBytes,
|
|
resources: overflow.entries.map((entry, index) =>
|
|
index === 0
|
|
? {
|
|
...entry,
|
|
bytes: Buffer.alloc(
|
|
MAX_PLUGIN_PACKAGE_MATERIALIZED_RESOURCE_BYTES + 1,
|
|
),
|
|
}
|
|
: entry,
|
|
),
|
|
taskSpecSemanticRegistry: overflow.registry,
|
|
}),
|
|
/resource bytes is invalid/,
|
|
);
|
|
});
|
|
|
|
test('reads active bytes sequentially with explicit bounds and rejects a generation switch', async () => {
|
|
const value = fixture();
|
|
const reads = [];
|
|
let observations = 0;
|
|
const revision = await materializeActivePluginPackageResources({
|
|
projectId: value.generation.projectId,
|
|
packageName: value.generation.packageName,
|
|
generationSource: {
|
|
async findActiveResourceGeneration() {
|
|
observations += 1;
|
|
return value.generation;
|
|
},
|
|
},
|
|
lockSource: {
|
|
async findLock(lockDigest) {
|
|
assert.equal(lockDigest, value.lock.lockDigest);
|
|
return value.lock;
|
|
},
|
|
},
|
|
byteSource: {
|
|
async open(generation) {
|
|
assert.equal(generation.generationDigest, value.generation.generationDigest);
|
|
return {
|
|
async read(path, maximumBytes) {
|
|
reads.push({ path, maximumBytes });
|
|
return path === 'package.json'
|
|
? value.manifestBytes
|
|
: value.resourceBytes[path];
|
|
},
|
|
async close() {},
|
|
};
|
|
},
|
|
},
|
|
taskSpecSemanticRegistry: value.registry,
|
|
});
|
|
assert.equal(revision.revisionDigest.length, 64);
|
|
assert.equal(observations, 2);
|
|
assert.deepEqual(reads, [
|
|
{ path: 'package.json', maximumBytes: MAX_PLUGIN_PACKAGE_MANIFEST_BYTES },
|
|
...value.generation.resources.map(({ path }) => ({
|
|
path,
|
|
maximumBytes: MAX_PLUGIN_PACKAGE_MATERIALIZED_RESOURCE_BYTES,
|
|
})),
|
|
]);
|
|
|
|
const changed = createPluginPackageResourceGenerationFromReferences({
|
|
installationId: value.generation.installationId,
|
|
projectId: value.generation.projectId,
|
|
packageName: value.generation.packageName,
|
|
lockDigest: value.generation.lockDigest,
|
|
generation: 2,
|
|
previousActiveLockDigest: value.generation.lockDigest,
|
|
contentDigest: value.generation.contentDigest,
|
|
resources: value.generation.resources,
|
|
});
|
|
await assert.rejects(
|
|
materializeActivePluginPackageResources({
|
|
projectId: value.generation.projectId,
|
|
packageName: value.generation.packageName,
|
|
generationSource: {
|
|
calls: 0,
|
|
async findActiveResourceGeneration() {
|
|
this.calls += 1;
|
|
return this.calls === 1 ? value.generation : changed;
|
|
},
|
|
},
|
|
lockSource: { async findLock() { return value.lock; } },
|
|
byteSource: {
|
|
async open() {
|
|
return {
|
|
async read(path) {
|
|
return path === 'package.json'
|
|
? value.manifestBytes
|
|
: value.resourceBytes[path];
|
|
},
|
|
async close() {},
|
|
};
|
|
},
|
|
},
|
|
taskSpecSemanticRegistry: value.registry,
|
|
}),
|
|
PluginPackageResourceMaterializationConflictError,
|
|
);
|
|
});
|
|
|
|
test('publishes materialization only through the explicit runtime-core subpath', () => {
|
|
assert.equal(
|
|
require('../dist').materializePluginPackageResources,
|
|
undefined,
|
|
);
|
|
assert.equal(
|
|
require('@qinglong/runtime-core/plugin-package-resource-materialization')
|
|
.materializePluginPackageResources,
|
|
materializePluginPackageResources,
|
|
);
|
|
});
|