Files
qinglong/packages/ql3-runtime-core/test/pluginPackageActivation.test.cjs
T

371 lines
12 KiB
JavaScript

const assert = require('node:assert/strict');
const { test } = require('node:test');
const {
PLUGIN_PACKAGE_API_VERSION,
PLUGIN_PACKAGE_KIND,
planPluginPackageInstall,
} = require('../dist/plugin-package/pluginPackage');
const {
PluginPackageInstallTransitionConflictError,
createPluginPackageInstall,
createPluginPackageLock,
pluginPackageActivationIntentDigest,
pluginPackageInstallActionDigest,
pluginPackageInstallCommit,
pluginPackageInstallPlanDigest,
transitionPluginPackageInstall,
} = require('../dist/plugin-package/installation/pluginPackageInstall');
const {
PluginPackageActivationConflictError,
PluginPackageActivationCoordinator,
PluginPackageActivationUnavailableError,
createPluginPackageActivationIntent,
normalizePluginPackageActivationIntent,
} = require('../dist/plugin-package/installation/pluginPackageActivation');
const {
createPluginPackageResourceGenerationFromReferences,
} = require('../dist/plugin-package/pluginPackageResourceGeneration');
const ARTIFACT_DIGEST = 'a'.repeat(64);
const CONTENT_DIGEST = 'b'.repeat(64);
function fixture() {
const manifest = {
apiVersion: PLUGIN_PACKAGE_API_VERSION,
kind: PLUGIN_PACKAGE_KIND,
metadata: {
name: 'example-monitor',
displayName: 'Example Monitor',
version: '1.2.0',
description: 'One bounded package',
license: 'Apache-2.0',
},
spec: {
compatibility: {
qinglong: '>=3.0.0-0 <4.0.0',
architectures: ['arm64'],
deploymentProfiles: ['edge'],
},
runtimes: [],
resources: {
memory: { recommended: '16Mi' },
disk: { install: '4Mi', working: '16Mi' },
},
permissions: {
network: { allowedHosts: [] },
secrets: [],
tools: [],
},
contents: { tasks: [], workflows: [], prompts: [], tools: [] },
},
};
const environment = {
qinglongVersion: '3.0.0-alpha.0',
architecture: 'arm64',
deploymentProfile: 'edge',
runtimes: [],
availableMemoryBytes: 128 * 1024 * 1024,
availableDiskBytes: 256 * 1024 * 1024,
};
const plan = planPluginPackageInstall(manifest, environment);
const action = {
lockId: 'lock-001',
projectId: 'default',
manifest,
plan,
environment,
source: {
kind: 'offline',
locator: `offline:sha256:${ARTIFACT_DIGEST}`,
artifactDigest: ARTIFACT_DIGEST,
artifactBytes: 2048,
contentDigest: CONTENT_DIGEST,
},
architecture: 'arm64',
deploymentProfile: 'edge',
targetGeneration: 1,
};
const lock = createPluginPackageLock({
...action,
approval: {
requestId: 'approval-001',
requestVersion: 1,
dispatchId: 'dispatch-001',
actionDigest: pluginPackageInstallActionDigest(action),
previewDigest: pluginPackageInstallPlanDigest(plan),
approvedBy: { type: 'user', id: 'owner-001' },
approvedAtMs: 100,
expiresAtMs: 10_000,
fence: { projectVersion: 1, bindingVersion: 1 },
},
createdAtMs: 200,
});
const queued = createPluginPackageInstall(lock, {
installationId: 'install-001',
mutationId: 'mutation-create',
occurredAtMs: 201,
});
const staged = transitionPluginPackageInstall(lock, queued, {
type: 'stage_completed',
mutationId: 'mutation-stage',
occurredAtMs: 202,
stageRef: `local-stage:${lock.lockDigest}`,
artifactDigest: lock.source.artifactDigest,
manifestDigest: lock.manifestDigest,
contentDigest: lock.source.contentDigest,
evidenceDigest: 'e'.repeat(64),
});
return { lock, staged };
}
class MemoryRepository {
constructor(lock, record) {
this.lock = lock;
this.record = record;
this.commits = [];
}
async find(projectId, packageName) {
return this.record.projectId === projectId &&
this.record.packageName === packageName
? this.record
: null;
}
async findLock(lockDigest) {
return this.lock.lockDigest === lockDigest ? this.lock : null;
}
async commit(command) {
const canonical = pluginPackageInstallCommit(this.record, command.record);
assert.deepEqual(command, canonical);
this.record = command.record;
this.commits.push(command);
return { status: 'committed', record: this.record };
}
}
function publishedReceipt(lock, record, activatedAtMs = 204) {
const intent = createPluginPackageActivationIntent(lock, record);
return transitionPluginPackageInstall(lock, record, {
type: 'activation_committed',
mutationId: 'receipt-preview',
occurredAtMs: activatedAtMs,
activationRef: `active:${lock.lockDigest}`,
intentDigest: intent.intentDigest,
generation: lock.targetGeneration,
contentDigest: lock.source.contentDigest,
}).activationReceipt;
}
function activateOptions() {
return {
projectId: 'default',
packageName: 'example-monitor',
installationId: 'install-001',
activationStartedMutationId: 'mutation-activate',
activationCommittedMutationId: 'mutation-commit',
startedAtMs: 203,
};
}
test('binds one activation intent to the exact durable install and stage', () => {
const { lock, staged } = fixture();
const activating = transitionPluginPackageInstall(lock, staged, {
type: 'activation_started',
mutationId: 'mutation-activate',
occurredAtMs: 203,
});
const intent = createPluginPackageActivationIntent(lock, activating);
assert.equal(
intent.intentDigest,
pluginPackageActivationIntentDigest(lock, activating),
);
assert.deepEqual(normalizePluginPackageActivationIntent(intent), intent);
assert.equal(intent.stageReceiptDigest, staged.stageReceipt.receiptDigest);
assert.deepEqual(intent.resourceGeneration.resources, lock.resources);
assert.throws(
() =>
normalizePluginPackageActivationIntent({
...intent,
resourceGeneration: createPluginPackageResourceGenerationFromReferences(
{
installationId: intent.installationId,
projectId: 'other',
packageName: intent.packageName,
lockDigest: intent.lockDigest,
generation: intent.targetGeneration,
previousActiveLockDigest: intent.previousActiveLockDigest,
contentDigest: intent.contentDigest,
resources: intent.resourceGeneration.resources,
},
),
}),
/activation resource generation does not match/,
);
assert.throws(
() =>
normalizePluginPackageActivationIntent({
...intent,
schema: 'qinglong/plugin-package-activation-intent@v1',
}),
/activation intent schema is invalid/,
);
assert.throws(
() =>
normalizePluginPackageActivationIntent({
...intent,
targetGeneration: 0,
}),
/activation target generation is invalid/,
);
assert.throws(
() =>
transitionPluginPackageInstall(lock, activating, {
type: 'activation_committed',
mutationId: 'mutation-commit',
occurredAtMs: 204,
activationRef: 'active:wrong',
intentDigest: 'f'.repeat(64),
generation: 1,
contentDigest: CONTENT_DIGEST,
}),
PluginPackageInstallTransitionConflictError,
);
});
test('persists activating before publication and commits only an exact receipt', async () => {
const { lock, staged } = fixture();
const repository = new MemoryRepository(lock, staged);
const calls = [];
const coordinator = new PluginPackageActivationCoordinator({
repository,
publisher: {
async inspect() {
throw new Error('inspect must not run on the fresh path');
},
async publish(intent) {
calls.push(intent);
return publishedReceipt(lock, repository.record);
},
},
});
const active = await coordinator.activate(activateOptions());
assert.equal(active.state, 'active');
assert.equal(active.activeLockDigest, lock.lockDigest);
assert.equal(repository.commits.length, 2);
assert.equal(calls.length, 1);
assert.equal(calls[0].intentDigest, active.activationReceipt.intentDigest);
});
test('recovers publish-response loss through inspect without republishing', async () => {
const { lock, staged } = fixture();
const repository = new MemoryRepository(lock, staged);
let durableReceipt;
const publisher = {
async publish() {
durableReceipt = publishedReceipt(lock, repository.record);
throw new Error('response lost');
},
async inspect() {
return { status: 'published', receipt: durableReceipt };
},
};
const coordinator = new PluginPackageActivationCoordinator({
repository,
publisher,
});
await assert.rejects(
coordinator.activate(activateOptions()),
PluginPackageActivationUnavailableError,
);
assert.equal(repository.record.state, 'activating');
const active = await coordinator.inspect({
projectId: 'default',
packageName: 'example-monitor',
installationId: 'install-001',
activationCommittedMutationId: 'mutation-commit-recovery',
activationFailedMutationId: 'mutation-fail-recovery',
observedAtMs: 205,
});
assert.equal(active.state, 'active');
assert.equal(repository.commits.length, 2);
});
test('fails a recovered activating record when exact publication is absent', async () => {
const { lock, staged } = fixture();
const activating = transitionPluginPackageInstall(lock, staged, {
type: 'activation_started',
mutationId: 'mutation-activate',
occurredAtMs: 203,
});
const repository = new MemoryRepository(lock, activating);
const coordinator = new PluginPackageActivationCoordinator({
repository,
publisher: {
async publish() {
throw new Error('publish must not run during recovery');
},
async inspect() {
return { status: 'not_published' };
},
},
});
const failed = await coordinator.inspect({
projectId: 'default',
packageName: 'example-monitor',
installationId: 'install-001',
activationCommittedMutationId: 'mutation-commit-recovery',
activationFailedMutationId: 'mutation-fail-recovery',
observedAtMs: 204,
});
assert.equal(failed.state, 'failed');
assert.equal(failed.failure.reason, 'activation_failed');
assert.equal(failed.activeLockDigest, null);
});
test('records a conflicting external fact without replacing the old pointer', async () => {
const { lock, staged } = fixture();
const activating = transitionPluginPackageInstall(lock, staged, {
type: 'activation_started',
mutationId: 'mutation-activate',
occurredAtMs: 203,
});
const repository = new MemoryRepository(lock, activating);
const coordinator = new PluginPackageActivationCoordinator({
repository,
publisher: {
async publish() {
throw new Error('publish must not run during recovery');
},
async inspect() {
throw new PluginPackageActivationConflictError();
},
},
});
const failed = await coordinator.inspect({
projectId: 'default',
packageName: 'example-monitor',
installationId: 'install-001',
activationCommittedMutationId: 'mutation-commit-recovery',
activationFailedMutationId: 'mutation-fact-conflict',
observedAtMs: 204,
});
assert.equal(failed.state, 'failed');
assert.equal(failed.failure.reason, 'activation_fact_conflict');
assert.equal(failed.activeLockDigest, null);
});
test('publishes the coordinator only through its explicit subpath', () => {
assert.equal(
require('../dist').PluginPackageActivationCoordinator,
undefined,
);
assert.equal(
require('@qinglong/runtime-core/plugin-package-activation')
.PluginPackageActivationCoordinator,
PluginPackageActivationCoordinator,
);
});