mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 19:29:13 +08:00
feat(ql3): qualify plugin upgrades before activation
This commit is contained in:
+48
@@ -40,6 +40,10 @@ export type PluginPackageActivationPrerequisiteObservation =
|
||||
| Readonly<{
|
||||
status: 'deferred';
|
||||
reason: 'secret_binding_transition_required';
|
||||
}>
|
||||
| Readonly<{
|
||||
status: 'rejected';
|
||||
reason: 'activation_fact_conflict';
|
||||
}>;
|
||||
|
||||
export interface PluginPackageActivationPrerequisite {
|
||||
@@ -49,6 +53,37 @@ export interface PluginPackageActivationPrerequisite {
|
||||
): Promise<Readonly<PluginPackageActivationPrerequisiteObservation>>;
|
||||
}
|
||||
|
||||
export function sequencePluginPackageActivationPrerequisites(
|
||||
prerequisites: readonly PluginPackageActivationPrerequisite[],
|
||||
): PluginPackageActivationPrerequisite {
|
||||
if (
|
||||
!Array.isArray(prerequisites) ||
|
||||
prerequisites.length < 1 ||
|
||||
prerequisites.length > 8 ||
|
||||
prerequisites.some(
|
||||
(prerequisite) =>
|
||||
!prerequisite || typeof prerequisite.inspect !== 'function',
|
||||
)
|
||||
) {
|
||||
throw new InvalidPluginPackageInstallError(
|
||||
'activation prerequisite sequence is invalid',
|
||||
);
|
||||
}
|
||||
const sequence = Object.freeze([...prerequisites]);
|
||||
return Object.freeze({
|
||||
async inspect(
|
||||
record: Readonly<PluginPackageInstallRecord>,
|
||||
lock: Readonly<PluginPackageLock>,
|
||||
): Promise<Readonly<PluginPackageActivationPrerequisiteObservation>> {
|
||||
for (const prerequisite of sequence) {
|
||||
const observation = await prerequisite.inspect(record, lock);
|
||||
if (observation.status !== 'ready') return observation;
|
||||
}
|
||||
return Object.freeze({ status: 'ready' as const });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface InstallPluginPackageOptions {
|
||||
readonly lock: PluginPackageLock;
|
||||
readonly proposalDigest: string;
|
||||
@@ -224,6 +259,19 @@ export class PluginPackageInstallationCoordinator {
|
||||
lock,
|
||||
);
|
||||
if (prerequisite?.status === 'deferred') return record;
|
||||
if (prerequisite?.status === 'rejected') {
|
||||
const failed = transitionPluginPackageInstall(lock, record, {
|
||||
type: 'failed',
|
||||
mutationId: options.activationFailedMutationId,
|
||||
occurredAtMs: options.activationObservedAtMs,
|
||||
reason: prerequisite.reason,
|
||||
});
|
||||
return (
|
||||
await this.#repository.commit(
|
||||
pluginPackageInstallCommit(record, failed),
|
||||
)
|
||||
).record;
|
||||
}
|
||||
return this.#activation.activate({
|
||||
...identity,
|
||||
activationStartedMutationId: options.activationStartedMutationId,
|
||||
|
||||
@@ -311,6 +311,25 @@ export class PluginPackageRecoveryCoordinator {
|
||||
lock,
|
||||
);
|
||||
if (prerequisite?.status === 'deferred') return record;
|
||||
if (prerequisite?.status === 'rejected') {
|
||||
const failed = transitionPluginPackageInstall(lock, record, {
|
||||
type: 'failed',
|
||||
mutationId: mutationId(
|
||||
'activation-preparation-failed',
|
||||
record,
|
||||
occurredAtMs,
|
||||
),
|
||||
occurredAtMs,
|
||||
reason: prerequisite.reason,
|
||||
});
|
||||
return normalizePluginPackageInstallRecord(
|
||||
(
|
||||
await this.#repository.commit(
|
||||
pluginPackageInstallCommit(record, failed),
|
||||
)
|
||||
).record,
|
||||
);
|
||||
}
|
||||
return this.#activation.activate({
|
||||
...identity,
|
||||
activationStartedMutationId: mutationId(
|
||||
|
||||
+215
-31
@@ -10,12 +10,21 @@ import {
|
||||
type PluginPackageContentEntryDescriptor,
|
||||
} from './pluginPackageBundle';
|
||||
import {
|
||||
PluginPackageInstallUnavailableError,
|
||||
assertPluginPackageInstallMatchesLock,
|
||||
normalizePluginPackageInstallRecord,
|
||||
normalizePluginPackageLock,
|
||||
pluginPackageManifestDigest,
|
||||
serializePluginPackageManifest,
|
||||
type PluginPackageInstallRecord,
|
||||
type PluginPackageLock,
|
||||
} from './installation/pluginPackageInstall';
|
||||
import type {
|
||||
PluginPackageActivationPrerequisite,
|
||||
PluginPackageActivationPrerequisiteObservation,
|
||||
} from './installation/pluginPackageInstallation';
|
||||
import {
|
||||
createPluginPackageResourceGenerationFromReferences,
|
||||
normalizePluginPackageResourceGeneration,
|
||||
pluginPackageResourceReferencesFromContents,
|
||||
type PluginPackageResourceGeneration,
|
||||
@@ -185,6 +194,17 @@ export interface MaterializeActivePluginPackageResourcesOptions {
|
||||
readonly taskSpecSemanticRegistry: TaskSpecSemanticRegistry;
|
||||
}
|
||||
|
||||
export interface MaterializePluginPackageResourceGenerationOptions {
|
||||
readonly generation: Readonly<PluginPackageResourceGeneration>;
|
||||
readonly lock: Readonly<PluginPackageLock>;
|
||||
readonly byteSource: PluginPackageResourceByteSource;
|
||||
readonly secretBindingSource?: Pick<
|
||||
PluginPackageSecretBindingRepository,
|
||||
'find'
|
||||
>;
|
||||
readonly taskSpecSemanticRegistry: TaskSpecSemanticRegistry;
|
||||
}
|
||||
|
||||
export interface PluginPackageMaterializedRevisionRepository {
|
||||
find(
|
||||
generationDigest: string,
|
||||
@@ -197,6 +217,115 @@ export interface PluginPackageMaterializedRevisionRepository {
|
||||
>;
|
||||
}
|
||||
|
||||
export class PluginPackageResourceActivationPrerequisite
|
||||
implements PluginPackageActivationPrerequisite
|
||||
{
|
||||
constructor(
|
||||
private readonly options: {
|
||||
readonly byteSource: PluginPackageResourceByteSource;
|
||||
readonly materializedRepository: PluginPackageMaterializedRevisionRepository;
|
||||
readonly secretBindingSource?: Pick<
|
||||
PluginPackageSecretBindingRepository,
|
||||
'find'
|
||||
>;
|
||||
readonly taskSpecSemanticRegistry: TaskSpecSemanticRegistry;
|
||||
},
|
||||
) {
|
||||
const authorities = dataRecord(options, 'activation prerequisite options');
|
||||
exactKeys(
|
||||
authorities,
|
||||
['byteSource', 'materializedRepository', 'taskSpecSemanticRegistry'],
|
||||
['secretBindingSource'],
|
||||
'activation prerequisite options',
|
||||
);
|
||||
if (
|
||||
!options.byteSource ||
|
||||
typeof options.byteSource.open !== 'function' ||
|
||||
!options.materializedRepository ||
|
||||
typeof options.materializedRepository.find !== 'function' ||
|
||||
typeof options.materializedRepository.publish !== 'function' ||
|
||||
(options.secretBindingSource !== undefined &&
|
||||
(!options.secretBindingSource ||
|
||||
typeof options.secretBindingSource.find !== 'function')) ||
|
||||
!(options.taskSpecSemanticRegistry instanceof TaskSpecSemanticRegistry)
|
||||
) {
|
||||
invalid('activation prerequisite authority is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
async inspect(
|
||||
recordValue: Readonly<PluginPackageInstallRecord>,
|
||||
lockValue: Readonly<PluginPackageLock>,
|
||||
): Promise<Readonly<PluginPackageActivationPrerequisiteObservation>> {
|
||||
try {
|
||||
const record = normalizePluginPackageInstallRecord(recordValue);
|
||||
const lock = normalizePluginPackageLock(lockValue);
|
||||
assertPluginPackageInstallMatchesLock(lock, record);
|
||||
if (record.state !== 'staged') {
|
||||
invalid('activation prerequisite requires a staged install');
|
||||
}
|
||||
// Generation one may require the post-activation B1 binding ceremony.
|
||||
// There is no healthy previous pointer to preserve in that flow.
|
||||
if (record.previousActiveLockDigest === null) {
|
||||
return Object.freeze({ status: 'ready' as const });
|
||||
}
|
||||
const generation = createPluginPackageResourceGenerationFromReferences({
|
||||
installationId: record.installationId,
|
||||
projectId: record.projectId,
|
||||
packageName: record.packageName,
|
||||
lockDigest: lock.lockDigest,
|
||||
generation: lock.targetGeneration,
|
||||
previousActiveLockDigest: record.previousActiveLockDigest,
|
||||
contentDigest: lock.source.contentDigest,
|
||||
resources: lock.resources,
|
||||
});
|
||||
let revision = await this.options.materializedRepository.find(
|
||||
generation.generationDigest,
|
||||
);
|
||||
if (revision === null) {
|
||||
const candidate = await materializePluginPackageResourceGeneration({
|
||||
generation,
|
||||
lock,
|
||||
byteSource: this.options.byteSource,
|
||||
...(this.options.secretBindingSource === undefined
|
||||
? {}
|
||||
: { secretBindingSource: this.options.secretBindingSource }),
|
||||
taskSpecSemanticRegistry: this.options.taskSpecSemanticRegistry,
|
||||
});
|
||||
revision = (
|
||||
await this.options.materializedRepository.publish(candidate)
|
||||
).revision;
|
||||
}
|
||||
const durable = normalizePluginPackageMaterializedRevision(
|
||||
revision,
|
||||
this.options.taskSpecSemanticRegistry,
|
||||
);
|
||||
if (
|
||||
durable.generation.generationDigest !== generation.generationDigest ||
|
||||
durable.generation.installationId !== record.installationId ||
|
||||
durable.generation.lockDigest !== lock.lockDigest
|
||||
) {
|
||||
throw new PluginPackageResourceMaterializationConflictError(
|
||||
'durable candidate revision does not match the staged generation',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ status: 'ready' as const });
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof InvalidPluginPackageResourceMaterializationError ||
|
||||
error instanceof PluginPackageResourceMaterializationConflictError
|
||||
) {
|
||||
return Object.freeze({
|
||||
status: 'rejected' as const,
|
||||
reason: 'activation_fact_conflict' as const,
|
||||
});
|
||||
}
|
||||
if (error instanceof PluginPackageInstallUnavailableError) throw error;
|
||||
throw new PluginPackageInstallUnavailableError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface PluginPackageTaskDefinitionDraft {
|
||||
readonly projectId: string;
|
||||
readonly taskId: string;
|
||||
@@ -1282,32 +1411,39 @@ function materializationSources(
|
||||
}
|
||||
}
|
||||
|
||||
export async function materializeActivePluginPackageResources(
|
||||
value: MaterializeActivePluginPackageResourcesOptions,
|
||||
): Promise<Readonly<PluginPackageMaterializedRevision> | null> {
|
||||
materializationSources(value);
|
||||
function generationMaterializationSources(
|
||||
value: MaterializePluginPackageResourceGenerationOptions,
|
||||
): void {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
!value.byteSource ||
|
||||
typeof value.byteSource.open !== 'function' ||
|
||||
(value.secretBindingSource !== undefined &&
|
||||
(!value.secretBindingSource ||
|
||||
typeof value.secretBindingSource.find !== 'function')) ||
|
||||
!(value.taskSpecSemanticRegistry instanceof TaskSpecSemanticRegistry)
|
||||
) {
|
||||
invalid('generation materialization sources are invalid');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Qualifies one immutable candidate generation without consulting or moving
|
||||
* the active pointer. Callers may durably publish the returned revision before
|
||||
* activation so deterministic Package errors cannot replace a healthy head.
|
||||
*/
|
||||
export async function materializePluginPackageResourceGeneration(
|
||||
value: MaterializePluginPackageResourceGenerationOptions,
|
||||
): Promise<Readonly<PluginPackageMaterializedRevision>> {
|
||||
generationMaterializationSources(value);
|
||||
try {
|
||||
const first = await value.generationSource.findActiveResourceGeneration(
|
||||
value.projectId,
|
||||
value.packageName,
|
||||
const generation = normalizePluginPackageResourceGeneration(
|
||||
value.generation,
|
||||
);
|
||||
if (first === null) return null;
|
||||
const generation = normalizePluginPackageResourceGeneration(first);
|
||||
if (
|
||||
generation.projectId !== value.projectId ||
|
||||
generation.packageName !== value.packageName
|
||||
) {
|
||||
throw new PluginPackageResourceMaterializationConflictError(
|
||||
'generation source returned another Package identity',
|
||||
);
|
||||
}
|
||||
const lockValue = await value.lockSource.findLock(generation.lockDigest);
|
||||
if (lockValue === null) {
|
||||
throw new PluginPackageResourceMaterializationConflictError(
|
||||
'active generation lock is missing',
|
||||
);
|
||||
}
|
||||
const lock = normalizePluginPackageLock(lockValue);
|
||||
const lock = normalizePluginPackageLock(value.lock);
|
||||
assertGenerationMatchesLock(generation, lock);
|
||||
const reader = await value.byteSource.open(generation);
|
||||
if (
|
||||
!reader ||
|
||||
@@ -1350,22 +1486,70 @@ export async function materializeActivePluginPackageResources(
|
||||
});
|
||||
}
|
||||
}
|
||||
const activeManifest = normalizeManifestBytes(manifestBytes, lock);
|
||||
const activeSecretBindingValue =
|
||||
activeManifest.spec.permissions.secrets.length === 0
|
||||
const manifest = normalizeManifestBytes(manifestBytes, lock);
|
||||
const bindingValue =
|
||||
manifest.spec.permissions.secrets.length === 0
|
||||
? undefined
|
||||
: await value.secretBindingSource?.find(generation.generationDigest);
|
||||
const activeSecretBinding = activeSecretBindingValue ?? undefined;
|
||||
const revision = materializePluginPackageResources({
|
||||
return materializePluginPackageResources({
|
||||
generation,
|
||||
lock,
|
||||
manifestBytes,
|
||||
...(activeSecretBinding === undefined
|
||||
...(bindingValue === undefined || bindingValue === null
|
||||
? {}
|
||||
: { secretBinding: activeSecretBinding }),
|
||||
: { secretBinding: bindingValue }),
|
||||
resources: Object.freeze(resources),
|
||||
taskSpecSemanticRegistry: value.taskSpecSemanticRegistry,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof InvalidPluginPackageResourceMaterializationError ||
|
||||
error instanceof PluginPackageResourceMaterializationConflictError ||
|
||||
error instanceof PluginPackageResourceMaterializationUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new PluginPackageResourceMaterializationUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function materializeActivePluginPackageResources(
|
||||
value: MaterializeActivePluginPackageResourcesOptions,
|
||||
): Promise<Readonly<PluginPackageMaterializedRevision> | null> {
|
||||
materializationSources(value);
|
||||
try {
|
||||
const first = await value.generationSource.findActiveResourceGeneration(
|
||||
value.projectId,
|
||||
value.packageName,
|
||||
);
|
||||
if (first === null) return null;
|
||||
const generation = normalizePluginPackageResourceGeneration(first);
|
||||
if (
|
||||
generation.projectId !== value.projectId ||
|
||||
generation.packageName !== value.packageName
|
||||
) {
|
||||
throw new PluginPackageResourceMaterializationConflictError(
|
||||
'generation source returned another Package identity',
|
||||
);
|
||||
}
|
||||
const lockValue = await value.lockSource.findLock(generation.lockDigest);
|
||||
if (lockValue === null) {
|
||||
throw new PluginPackageResourceMaterializationConflictError(
|
||||
'active generation lock is missing',
|
||||
);
|
||||
}
|
||||
const lock = normalizePluginPackageLock(lockValue);
|
||||
const revision = await materializePluginPackageResourceGeneration({
|
||||
generation,
|
||||
lock,
|
||||
byteSource: value.byteSource,
|
||||
...(value.secretBindingSource === undefined
|
||||
? {}
|
||||
: { secretBindingSource: value.secretBindingSource }),
|
||||
taskSpecSemanticRegistry: value.taskSpecSemanticRegistry,
|
||||
});
|
||||
const secondValue =
|
||||
await value.generationSource.findActiveResourceGeneration(
|
||||
value.projectId,
|
||||
|
||||
@@ -20,13 +20,18 @@ const {
|
||||
const {
|
||||
PluginPackageRecoveryCoordinator,
|
||||
} = require('../dist/plugin-package/installation/pluginPackageRecovery');
|
||||
const {
|
||||
sequencePluginPackageActivationPrerequisites,
|
||||
} = require('../dist/plugin-package/installation/pluginPackageInstallation');
|
||||
|
||||
const ARTIFACT_DIGEST = 'a'.repeat(64);
|
||||
const CONTENT_DIGEST = 'b'.repeat(64);
|
||||
const PREVIOUS_LOCK_DIGEST = 'c'.repeat(64);
|
||||
|
||||
function fixture(
|
||||
packageName = 'example-monitor',
|
||||
installationId = 'install-001',
|
||||
options = {},
|
||||
) {
|
||||
const manifest = {
|
||||
apiVersion: PLUGIN_PACKAGE_API_VERSION,
|
||||
@@ -65,13 +70,24 @@ function fixture(
|
||||
availableMemoryBytes: 128 * 1024 * 1024,
|
||||
availableDiskBytes: 256 * 1024 * 1024,
|
||||
};
|
||||
const plan = planPluginPackageInstall(manifest, environment);
|
||||
const previousManifest = options.upgrade
|
||||
? {
|
||||
...manifest,
|
||||
metadata: { ...manifest.metadata, version: '1.1.0' },
|
||||
}
|
||||
: undefined;
|
||||
const plan = planPluginPackageInstall(
|
||||
manifest,
|
||||
environment,
|
||||
previousManifest,
|
||||
);
|
||||
const action = {
|
||||
lockId: `lock-${packageName}`,
|
||||
projectId: 'default',
|
||||
manifest,
|
||||
plan,
|
||||
environment,
|
||||
...(previousManifest === undefined ? {} : { previousManifest }),
|
||||
source: {
|
||||
kind: 'offline',
|
||||
locator: `offline:sha256:${ARTIFACT_DIGEST}`,
|
||||
@@ -81,7 +97,8 @@ function fixture(
|
||||
},
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
targetGeneration: 1,
|
||||
targetGeneration: options.upgrade ? 2 : 1,
|
||||
...(options.upgrade ? { previousLockDigest: PREVIOUS_LOCK_DIGEST } : {}),
|
||||
};
|
||||
const lock = createPluginPackageLock({
|
||||
...action,
|
||||
@@ -116,8 +133,8 @@ function stageEvidence(lock) {
|
||||
};
|
||||
}
|
||||
|
||||
function stagedFixture(packageName, installationId) {
|
||||
const value = fixture(packageName, installationId);
|
||||
function stagedFixture(packageName, installationId, options) {
|
||||
const value = fixture(packageName, installationId, options);
|
||||
const staged = transitionPluginPackageInstall(value.lock, value.queued, {
|
||||
type: 'stage_completed',
|
||||
mutationId: `mutation-stage-${value.lock.packageName}`,
|
||||
@@ -353,6 +370,80 @@ test('stages but defers activation until the exact prerequisite is ready', async
|
||||
});
|
||||
});
|
||||
|
||||
test('orders activation prerequisites and stops before later authorities', async () => {
|
||||
const value = stagedFixture();
|
||||
const calls = [];
|
||||
let first = {
|
||||
status: 'deferred',
|
||||
reason: 'secret_binding_transition_required',
|
||||
};
|
||||
const sequence = sequencePluginPackageActivationPrerequisites([
|
||||
{
|
||||
async inspect() {
|
||||
calls.push('secret');
|
||||
return first;
|
||||
},
|
||||
},
|
||||
{
|
||||
async inspect() {
|
||||
calls.push('candidate');
|
||||
return {
|
||||
status: 'rejected',
|
||||
reason: 'activation_fact_conflict',
|
||||
};
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(await sequence.inspect(value.staged, value.lock), first);
|
||||
assert.deepEqual(calls, ['secret']);
|
||||
|
||||
first = { status: 'ready' };
|
||||
assert.deepEqual(await sequence.inspect(value.staged, value.lock), {
|
||||
status: 'rejected',
|
||||
reason: 'activation_fact_conflict',
|
||||
});
|
||||
assert.deepEqual(calls, ['secret', 'secret', 'candidate']);
|
||||
});
|
||||
|
||||
test('rejects an invalid upgrade before publication and retains the previous active lock', async () => {
|
||||
const value = stagedFixture('example-monitor', 'install-upgrade', {
|
||||
upgrade: true,
|
||||
});
|
||||
const repository = new MemoryRepository([{ ...value, record: value.staged }]);
|
||||
const calls = { stage: 0, publish: 0, inspect: 0 };
|
||||
const coordinator = new PluginPackageRecoveryCoordinator({
|
||||
repository,
|
||||
stageProvider: {
|
||||
async stage() {
|
||||
calls.stage += 1;
|
||||
throw new Error('stage must not run');
|
||||
},
|
||||
},
|
||||
publisher: publisherFor(repository, calls),
|
||||
activationPrerequisite: {
|
||||
async inspect() {
|
||||
return {
|
||||
status: 'rejected',
|
||||
reason: 'activation_fact_conflict',
|
||||
};
|
||||
},
|
||||
},
|
||||
now: () => 250,
|
||||
});
|
||||
|
||||
const page = await coordinator.recoverPage({ limit: 1 });
|
||||
const durable = await repository.find('default', 'example-monitor');
|
||||
|
||||
assert.equal(page.items[0].status, 'settled');
|
||||
assert.equal(durable.state, 'failed');
|
||||
assert.equal(durable.previousActiveLockDigest, PREVIOUS_LOCK_DIGEST);
|
||||
assert.equal(durable.activeLockDigest, PREVIOUS_LOCK_DIGEST);
|
||||
assert.equal(durable.failure.reason, 'activation_fact_conflict');
|
||||
assert.equal(durable.failure.failedFrom, 'staged');
|
||||
assert.deepEqual(calls, { stage: 0, publish: 0, inspect: 0 });
|
||||
});
|
||||
|
||||
test('inspects an activating install without republishing it', async () => {
|
||||
const value = activatingFixture();
|
||||
const repository = new MemoryRepository([
|
||||
|
||||
@@ -12,9 +12,11 @@ const {
|
||||
} = require('../dist/plugin-package/pluginPackage');
|
||||
const {
|
||||
createPluginPackageLock,
|
||||
createPluginPackageInstall,
|
||||
pluginPackageInstallActionDigest,
|
||||
pluginPackageInstallPlanDigest,
|
||||
serializePluginPackageManifest,
|
||||
transitionPluginPackageInstall,
|
||||
} = require('../dist/plugin-package/installation/pluginPackageInstall');
|
||||
const {
|
||||
pluginPackageContentTreeDigest,
|
||||
@@ -30,6 +32,7 @@ const {
|
||||
InvalidPluginPackageResourceMaterializationError,
|
||||
MAX_PLUGIN_PACKAGE_MATERIALIZED_RESOURCE_BYTES,
|
||||
PLUGIN_PACKAGE_MATERIALIZED_REVISION_SCHEMA,
|
||||
PluginPackageResourceActivationPrerequisite,
|
||||
PluginPackageResourceMaterializationConflictError,
|
||||
materializeActivePluginPackageResources,
|
||||
materializePluginPackageResources,
|
||||
@@ -40,6 +43,7 @@ const {
|
||||
|
||||
const ARTIFACT_DIGEST = 'a'.repeat(64);
|
||||
const OCI_MANIFEST_DIGEST = 'f'.repeat(64);
|
||||
const PREVIOUS_LOCK_DIGEST = 'c'.repeat(64);
|
||||
|
||||
function resourceValues(overrides = {}) {
|
||||
return {
|
||||
@@ -208,13 +212,24 @@ function fixture(options = {}) {
|
||||
.sort((left, right) => left.path.localeCompare(right.path));
|
||||
const contentDigest = pluginPackageContentTreeDigest(descriptors);
|
||||
const installEnvironment = environment();
|
||||
const plan = planPluginPackageInstall(packageManifest, installEnvironment);
|
||||
const previousManifest = options.upgrade
|
||||
? {
|
||||
...packageManifest,
|
||||
metadata: { ...packageManifest.metadata, version: '0.9.0' },
|
||||
}
|
||||
: undefined;
|
||||
const plan = planPluginPackageInstall(
|
||||
packageManifest,
|
||||
installEnvironment,
|
||||
previousManifest,
|
||||
);
|
||||
const actionInput = {
|
||||
lockId: 'lock-001',
|
||||
projectId: 'project-001',
|
||||
manifest: packageManifest,
|
||||
plan,
|
||||
environment: installEnvironment,
|
||||
...(previousManifest === undefined ? {} : { previousManifest }),
|
||||
source: {
|
||||
kind: 'oci',
|
||||
locator:
|
||||
@@ -226,7 +241,10 @@ function fixture(options = {}) {
|
||||
},
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
targetGeneration: 1,
|
||||
targetGeneration: options.upgrade ? 2 : 1,
|
||||
...(options.upgrade
|
||||
? { previousLockDigest: PREVIOUS_LOCK_DIGEST }
|
||||
: {}),
|
||||
};
|
||||
const lock = createPluginPackageLock({
|
||||
...actionInput,
|
||||
@@ -249,7 +267,9 @@ function fixture(options = {}) {
|
||||
packageName: lock.packageName,
|
||||
lockDigest: lock.lockDigest,
|
||||
generation: lock.targetGeneration,
|
||||
previousActiveLockDigest: null,
|
||||
previousActiveLockDigest: options.upgrade
|
||||
? PREVIOUS_LOCK_DIGEST
|
||||
: null,
|
||||
contentDigest,
|
||||
resources: lock.resources,
|
||||
});
|
||||
@@ -268,6 +288,24 @@ function fixture(options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function stagedRecord(value) {
|
||||
const queued = createPluginPackageInstall(value.lock, {
|
||||
installationId: value.generation.installationId,
|
||||
mutationId: 'candidate-created',
|
||||
occurredAtMs: 201,
|
||||
});
|
||||
return transitionPluginPackageInstall(value.lock, queued, {
|
||||
type: 'stage_completed',
|
||||
mutationId: 'candidate-staged',
|
||||
occurredAtMs: 202,
|
||||
stageRef: `stage:${value.lock.lockDigest}`,
|
||||
artifactDigest: value.lock.source.artifactDigest,
|
||||
manifestDigest: value.lock.manifestDigest,
|
||||
contentDigest: value.lock.source.contentDigest,
|
||||
evidenceDigest: 'e'.repeat(64),
|
||||
});
|
||||
}
|
||||
|
||||
test('materializes exact Task, Workflow, Prompt and Tool JSON into one immutable revision', () => {
|
||||
const value = fixture();
|
||||
const revision = materializePluginPackageResources({
|
||||
@@ -707,6 +745,134 @@ test('reads active bytes sequentially with explicit bounds and rejects a generat
|
||||
);
|
||||
});
|
||||
|
||||
test('qualifies and durably publishes a staged candidate before activation', async () => {
|
||||
const value = fixture({ upgrade: true });
|
||||
const record = stagedRecord(value);
|
||||
let durable = null;
|
||||
let publications = 0;
|
||||
const prerequisite = new PluginPackageResourceActivationPrerequisite({
|
||||
byteSource: {
|
||||
async open(generation) {
|
||||
assert.equal(
|
||||
generation.generationDigest,
|
||||
value.generation.generationDigest,
|
||||
);
|
||||
return {
|
||||
async read(path) {
|
||||
return path === 'package.json'
|
||||
? value.manifestBytes
|
||||
: value.resourceBytes[path];
|
||||
},
|
||||
async close() {},
|
||||
};
|
||||
},
|
||||
},
|
||||
materializedRepository: {
|
||||
async find(generationDigest) {
|
||||
return durable?.generation.generationDigest === generationDigest
|
||||
? durable
|
||||
: null;
|
||||
},
|
||||
async publish(revision) {
|
||||
publications += 1;
|
||||
durable = revision;
|
||||
return { status: 'created', revision };
|
||||
},
|
||||
},
|
||||
taskSpecSemanticRegistry: value.registry,
|
||||
});
|
||||
|
||||
assert.deepEqual(await prerequisite.inspect(record, value.lock), {
|
||||
status: 'ready',
|
||||
});
|
||||
assert.equal(publications, 1);
|
||||
assert.equal(
|
||||
durable.generation.generationDigest,
|
||||
value.generation.generationDigest,
|
||||
);
|
||||
assert.deepEqual(await prerequisite.inspect(record, value.lock), {
|
||||
status: 'ready',
|
||||
});
|
||||
assert.equal(publications, 1);
|
||||
});
|
||||
|
||||
test('rejects a semantically invalid staged candidate without publishing it', async () => {
|
||||
const value = fixture({
|
||||
upgrade: true,
|
||||
resourceValues: {
|
||||
'workflows/daily.json': {
|
||||
schema: 'qinglong/plugin-package-workflow-resource@v1',
|
||||
id: 'daily',
|
||||
name: 'Daily report',
|
||||
enabled: true,
|
||||
steps: [{ id: 'missing', task: 'missing', needs: [] }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const record = stagedRecord(value);
|
||||
let publications = 0;
|
||||
const prerequisite = new PluginPackageResourceActivationPrerequisite({
|
||||
byteSource: {
|
||||
async open() {
|
||||
return {
|
||||
async read(path) {
|
||||
return path === 'package.json'
|
||||
? value.manifestBytes
|
||||
: value.resourceBytes[path];
|
||||
},
|
||||
async close() {},
|
||||
};
|
||||
},
|
||||
},
|
||||
materializedRepository: {
|
||||
async find() {
|
||||
return null;
|
||||
},
|
||||
async publish(revision) {
|
||||
publications += 1;
|
||||
return { status: 'created', revision };
|
||||
},
|
||||
},
|
||||
taskSpecSemanticRegistry: value.registry,
|
||||
});
|
||||
|
||||
assert.deepEqual(await prerequisite.inspect(record, value.lock), {
|
||||
status: 'rejected',
|
||||
reason: 'activation_fact_conflict',
|
||||
});
|
||||
assert.equal(publications, 0);
|
||||
});
|
||||
|
||||
test('leaves generation-one qualification to the post-activation B1 binding ceremony', async () => {
|
||||
const value = fixture();
|
||||
const record = stagedRecord(value);
|
||||
let authorityCalls = 0;
|
||||
const prerequisite = new PluginPackageResourceActivationPrerequisite({
|
||||
byteSource: {
|
||||
async open() {
|
||||
authorityCalls += 1;
|
||||
throw new Error('generation one must not read candidate bytes');
|
||||
},
|
||||
},
|
||||
materializedRepository: {
|
||||
async find() {
|
||||
authorityCalls += 1;
|
||||
return null;
|
||||
},
|
||||
async publish(revision) {
|
||||
authorityCalls += 1;
|
||||
return { status: 'created', revision };
|
||||
},
|
||||
},
|
||||
taskSpecSemanticRegistry: value.registry,
|
||||
});
|
||||
|
||||
assert.deepEqual(await prerequisite.inspect(record, value.lock), {
|
||||
status: 'ready',
|
||||
});
|
||||
assert.equal(authorityCalls, 0);
|
||||
});
|
||||
|
||||
test('publishes materialization only through the explicit runtime-core subpath', () => {
|
||||
assert.equal(require('../dist').materializePluginPackageResources, undefined);
|
||||
assert.equal(
|
||||
|
||||
Reference in New Issue
Block a user