test(ql3): gate failed plugin upgrades before activation

This commit is contained in:
whyour
2026-08-14 13:57:16 +08:00
parent 056eebef94
commit 2485d61e8a
6 changed files with 736 additions and 256 deletions
@@ -6,14 +6,9 @@ const fs = require('node:fs');
const https = require('node:https');
const path = require('node:path');
const { createRequire } = require('node:module');
const {
createHash,
generateKeyPairSync,
sign,
} = require('node:crypto');
const { createHash, generateKeyPairSync, sign } = require('node:crypto');
const FIXTURE_SCHEMA =
'qinglong/plugin-package-recovery-e2e-fixture@v1';
const FIXTURE_SCHEMA = 'qinglong/plugin-package-recovery-e2e-fixture@v1';
const REGISTRY_EVENT_SCHEMA =
'qinglong/plugin-package-recovery-e2e-registry-event@v1';
const OCI_MANIFEST = 'application/vnd.oci.image.manifest.v1+json';
@@ -74,10 +69,7 @@ function tarHeader(entryPath, size) {
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,
);
Buffer.from(`${checksum.toString(8).padStart(6, '0')}\0 `).copy(header, 148);
return header;
}
@@ -92,18 +84,17 @@ function canonicalTar(entries) {
return Buffer.concat(parts);
}
function pluginManifest(architecture) {
const {
PLUGIN_PACKAGE_API_VERSION,
PLUGIN_PACKAGE_KIND,
} = ql3Require('@qinglong/runtime-core/plugin-package');
function pluginManifest(architecture, version, invalidUpgrade = false) {
const { PLUGIN_PACKAGE_API_VERSION, PLUGIN_PACKAGE_KIND } = ql3Require(
'@qinglong/runtime-core/plugin-package',
);
return {
apiVersion: PLUGIN_PACKAGE_API_VERSION,
kind: PLUGIN_PACKAGE_KIND,
metadata: {
name: 'e2e-monitor',
displayName: 'E2E Monitor',
version: '1.0.0',
version,
description: 'One bounded end-to-end recovery package',
license: 'Apache-2.0',
},
@@ -121,13 +112,55 @@ function pluginManifest(architecture) {
permissions: {
network: { allowedHosts: [] },
secrets: [],
tools: [],
tools: invalidUpgrade ? ['system.command'] : [],
},
contents: { tasks: [], workflows: [], prompts: [], tools: [] },
contents: invalidUpgrade
? {
tasks: ['tasks/noop.json'],
workflows: ['workflows/cycle.json'],
prompts: [],
tools: [],
}
: { tasks: [], workflows: [], prompts: [], tools: [] },
},
};
}
function invalidUpgradeResources() {
return Object.freeze({
'tasks/noop.json': Object.freeze({
schema: 'qinglong/plugin-package-task-resource@v1',
id: 'noop',
name: 'No-op',
labels: Object.freeze({}),
enabled: true,
kind: 'command',
spec: Object.freeze({
schema: 'qinglong/command@v1',
config: Object.freeze({
command: Object.freeze({
kind: 'argv',
file: '/usr/bin/printf',
args: Object.freeze(['ok']),
}),
environment: Object.freeze([]),
timeoutMs: 30_000,
}),
}),
}),
'workflows/cycle.json': Object.freeze({
schema: 'qinglong/plugin-package-workflow-resource@v1',
id: 'cycle',
name: 'Rejected cyclic workflow',
enabled: true,
steps: Object.freeze([
Object.freeze({ id: 'first', task: 'noop', needs: ['second'] }),
Object.freeze({ id: 'second', task: 'noop', needs: ['first'] }),
]),
}),
});
}
function route(path, mediaType, body) {
return Object.freeze({
path,
@@ -137,44 +170,38 @@ function route(path, mediaType, body) {
});
}
function createFixture({ registry, architecture, createdAtMs = Date.now() }) {
if (
typeof registry !== 'string' ||
!/^[a-z0-9](?:[-a-z0-9.]{0,251}[a-z0-9])?$/.test(registry) ||
!['amd64', 'arm64'].includes(architecture) ||
!Number.isSafeInteger(createdAtMs) ||
createdAtMs < 1
) {
throw new TypeError('Plugin Package E2E fixture options are invalid');
}
const {
PluginPackagePublisherTrustRegistry,
PLUGIN_PACKAGE_SIGNATURE_SCHEMA,
pluginPackageContentTreeDigest,
pluginPackagePublisherSignaturePayload,
} = ql3Require('@qinglong/runtime-core/plugin-package-bundle');
const {
planPluginPackageInstall,
} = ql3Require('@qinglong/runtime-core/plugin-package');
const {
createPluginPackageLock,
pluginPackageInstallActionDigest,
pluginPackageInstallPlanDigest,
serializePluginPackageManifest,
} = ql3Require('@qinglong/runtime-core/plugin-package-install');
function packageMaterial(registry, manifest, resourceValues) {
const { pluginPackageContentTreeDigest } = ql3Require(
'@qinglong/runtime-core/plugin-package-bundle',
);
const { serializePluginPackageManifest } = ql3Require(
'@qinglong/runtime-core/plugin-package-install',
);
const {
PLUGIN_PACKAGE_OCI_ARTIFACT_TYPE,
PLUGIN_PACKAGE_OCI_CONFIG_MEDIA_TYPE,
PLUGIN_PACKAGE_OCI_SIGNATURE_ARTIFACT_TYPE,
PLUGIN_PACKAGE_OCI_SIGNATURE_CONFIG_MEDIA_TYPE,
} = ql3Require('@qinglong/cluster-admin/plugin-package-oci-stage');
const manifest = pluginManifest(architecture);
const resourceEntries = Object.entries(resourceValues)
.map(([entryPath, value]) => ({
path: entryPath,
body: jsonBytes(value),
}))
.sort((left, right) => left.path.localeCompare(right.path));
const contentDigest = pluginPackageContentTreeDigest(
resourceEntries.map((entry) => ({
path: entry.path,
bytes: entry.body.byteLength,
digest: sha256(entry.body),
})),
);
const artifact = canonicalTar([
{
path: 'package.json',
body: Buffer.from(serializePluginPackageManifest(manifest), 'utf8'),
},
...resourceEntries,
]);
const packageConfig = jsonBytes({
schema: 'qinglong/plugin-package-oci-config@v1',
@@ -201,6 +228,142 @@ function createFixture({ registry, architecture, createdAtMs = Date.now() }) {
};
const packageManifestBytes = jsonBytes(packageManifestValue);
const packageManifestDigest = sha256(packageManifestBytes);
return Object.freeze({
manifest,
source: Object.freeze({
kind: 'oci',
locator: `oci://${registry}/${REPOSITORY}@sha256:${packageManifestDigest}`,
artifactDigest,
artifactBytes: artifact.byteLength,
contentDigest,
}),
packageConfig,
packageConfigDigest,
packageManifestBytes,
packageManifestDigest,
artifact,
artifactDigest,
mediaTypes: Object.freeze({
packageConfig: PLUGIN_PACKAGE_OCI_CONFIG_MEDIA_TYPE,
signatureArtifact: PLUGIN_PACKAGE_OCI_SIGNATURE_ARTIFACT_TYPE,
signatureConfig: PLUGIN_PACKAGE_OCI_SIGNATURE_CONFIG_MEDIA_TYPE,
}),
});
}
function signedPackageRoutes(material, lock, privateKey) {
const {
PLUGIN_PACKAGE_SIGNATURE_SCHEMA,
pluginPackagePublisherSignaturePayload,
} = ql3Require('@qinglong/runtime-core/plugin-package-bundle');
const signature = {
schema: PLUGIN_PACKAGE_SIGNATURE_SCHEMA,
publisher: PUBLISHER,
keyId: KEY_ID,
signature: sign(
null,
pluginPackagePublisherSignaturePayload(lock, PUBLISHER, KEY_ID),
privateKey,
).toString('base64url'),
};
const signatureConfig = jsonBytes(signature);
const signatureConfigDigest = sha256(signatureConfig);
const signatureManifestValue = {
schemaVersion: 2,
mediaType: OCI_MANIFEST,
artifactType: material.mediaTypes.signatureArtifact,
config: {
mediaType: material.mediaTypes.signatureConfig,
digest: `sha256:${signatureConfigDigest}`,
size: signatureConfig.byteLength,
},
layers: [],
subject: {
mediaType: OCI_MANIFEST,
digest: `sha256:${material.packageManifestDigest}`,
size: material.packageManifestBytes.byteLength,
},
};
const signatureManifestBytes = jsonBytes(signatureManifestValue);
const signatureManifestDigest = sha256(signatureManifestBytes);
const referrers = jsonBytes({
schemaVersion: 2,
mediaType: OCI_INDEX,
manifests: [
{
mediaType: OCI_MANIFEST,
digest: `sha256:${signatureManifestDigest}`,
size: signatureManifestBytes.byteLength,
artifactType: material.mediaTypes.signatureArtifact,
annotations: {
'qinglong.io/plugin-package-lock-digest': lock.lockDigest,
},
},
],
});
const prefix = `/v2/${REPOSITORY}`;
return Object.freeze([
route(
`${prefix}/manifests/sha256:${material.packageManifestDigest}`,
OCI_MANIFEST,
material.packageManifestBytes,
),
route(
`${prefix}/blobs/sha256:${material.packageConfigDigest}`,
material.mediaTypes.packageConfig,
material.packageConfig,
),
route(
`${prefix}/referrers/sha256:${
material.packageManifestDigest
}?artifactType=${encodeURIComponent(
material.mediaTypes.signatureArtifact,
)}`,
OCI_INDEX,
referrers,
),
route(
`${prefix}/manifests/sha256:${signatureManifestDigest}`,
OCI_MANIFEST,
signatureManifestBytes,
),
route(
`${prefix}/blobs/sha256:${signatureConfigDigest}`,
material.mediaTypes.signatureConfig,
signatureConfig,
),
route(
`${prefix}/blobs/sha256:${material.artifactDigest}`,
BUNDLE,
material.artifact,
),
]);
}
function createFixture({ registry, architecture, createdAtMs = Date.now() }) {
if (
typeof registry !== 'string' ||
!/^[a-z0-9](?:[-a-z0-9.]{0,251}[a-z0-9])?$/.test(registry) ||
!['amd64', 'arm64'].includes(architecture) ||
!Number.isSafeInteger(createdAtMs) ||
createdAtMs < 1
) {
throw new TypeError('Plugin Package E2E fixture options are invalid');
}
const { PluginPackagePublisherTrustRegistry } = ql3Require(
'@qinglong/runtime-core/plugin-package-bundle',
);
const { planPluginPackageInstall } = ql3Require(
'@qinglong/runtime-core/plugin-package',
);
const {
createPluginPackageLock,
pluginPackageInstallActionDigest,
pluginPackageInstallPlanDigest,
} = ql3Require('@qinglong/runtime-core/plugin-package-install');
const { createPluginPackageResourceGenerationFromReferences } = ql3Require(
'@qinglong/runtime-core/plugin-package-resource-generation',
);
const environment = {
qinglongVersion: '3.0.0-alpha.0',
architecture,
@@ -209,33 +372,28 @@ function createFixture({ registry, architecture, createdAtMs = Date.now() }) {
availableMemoryBytes: 128 * 1024 * 1024,
availableDiskBytes: 256 * 1024 * 1024,
};
const plan = planPluginPackageInstall(manifest, environment);
const source = {
kind: 'oci',
locator: `oci://${registry}/${REPOSITORY}@sha256:${packageManifestDigest}`,
artifactDigest,
artifactBytes: artifact.byteLength,
contentDigest: pluginPackageContentTreeDigest([]),
};
const action = {
lockId: 'lock-plugin-recovery-e2e',
const initialManifest = pluginManifest(architecture, '1.0.0');
const initialMaterial = packageMaterial(registry, initialManifest, {});
const initialPlan = planPluginPackageInstall(initialManifest, environment);
const initialAction = {
lockId: 'lock-plugin-recovery-e2e-initial',
projectId: 'default',
manifest,
plan,
manifest: initialManifest,
plan: initialPlan,
environment,
source,
source: initialMaterial.source,
architecture,
deploymentProfile: 'cluster-control',
targetGeneration: 1,
};
const lock = createPluginPackageLock({
...action,
const initialLock = createPluginPackageLock({
...initialAction,
approval: {
requestId: 'approval-plugin-recovery-e2e',
requestId: 'approval-plugin-recovery-e2e-initial',
requestVersion: 1,
dispatchId: 'dispatch-plugin-recovery-e2e',
actionDigest: pluginPackageInstallActionDigest(action),
previewDigest: pluginPackageInstallPlanDigest(plan),
dispatchId: 'dispatch-plugin-recovery-e2e-initial',
actionDigest: pluginPackageInstallActionDigest(initialAction),
previewDigest: pluginPackageInstallPlanDigest(initialPlan),
approvedBy: { type: 'user', id: 'e2e-owner' },
approvedAtMs: createdAtMs - 1,
expiresAtMs: createdAtMs + 60 * 60 * 1000,
@@ -243,6 +401,46 @@ function createFixture({ registry, architecture, createdAtMs = Date.now() }) {
},
createdAtMs,
});
const upgradeCreatedAtMs = createdAtMs + 10;
const upgradeManifest = pluginManifest(architecture, '2.0.0', true);
const upgradeMaterial = packageMaterial(
registry,
upgradeManifest,
invalidUpgradeResources(),
);
const upgradePlan = planPluginPackageInstall(
upgradeManifest,
environment,
initialManifest,
);
const upgradeAction = {
lockId: 'lock-plugin-recovery-e2e-upgrade',
projectId: 'default',
manifest: upgradeManifest,
plan: upgradePlan,
environment,
previousManifest: initialManifest,
source: upgradeMaterial.source,
architecture,
deploymentProfile: 'cluster-control',
targetGeneration: 2,
previousLockDigest: initialLock.lockDigest,
};
const upgradeLock = createPluginPackageLock({
...upgradeAction,
approval: {
requestId: 'approval-plugin-recovery-e2e-upgrade',
requestVersion: 1,
dispatchId: 'dispatch-plugin-recovery-e2e-upgrade',
actionDigest: pluginPackageInstallActionDigest(upgradeAction),
previewDigest: pluginPackageInstallPlanDigest(upgradePlan),
approvedBy: { type: 'user', id: 'e2e-owner' },
approvedAtMs: upgradeCreatedAtMs - 1,
expiresAtMs: upgradeCreatedAtMs + 60 * 60 * 1000,
fence: { projectVersion: 1, bindingVersion: 1 },
},
createdAtMs: upgradeCreatedAtMs,
});
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
const publicKeyPem = publicKey.export({ format: 'pem', type: 'spki' });
const trust = {
@@ -258,94 +456,58 @@ function createFixture({ registry, architecture, createdAtMs = Date.now() }) {
],
};
new PluginPackagePublisherTrustRegistry(trust.keys);
const signature = {
schema: PLUGIN_PACKAGE_SIGNATURE_SCHEMA,
publisher: PUBLISHER,
keyId: KEY_ID,
signature: sign(
null,
pluginPackagePublisherSignaturePayload(lock, PUBLISHER, KEY_ID),
privateKey,
).toString('base64url'),
};
const signatureConfig = jsonBytes(signature);
const signatureConfigDigest = sha256(signatureConfig);
const signatureManifestValue = {
schemaVersion: 2,
mediaType: OCI_MANIFEST,
artifactType: PLUGIN_PACKAGE_OCI_SIGNATURE_ARTIFACT_TYPE,
config: {
mediaType: PLUGIN_PACKAGE_OCI_SIGNATURE_CONFIG_MEDIA_TYPE,
digest: `sha256:${signatureConfigDigest}`,
size: signatureConfig.byteLength,
},
layers: [],
subject: {
mediaType: OCI_MANIFEST,
digest: `sha256:${packageManifestDigest}`,
size: packageManifestBytes.byteLength,
},
};
const signatureManifestBytes = jsonBytes(signatureManifestValue);
const signatureManifestDigest = sha256(signatureManifestBytes);
const referrers = jsonBytes({
schemaVersion: 2,
mediaType: OCI_INDEX,
manifests: [
{
mediaType: OCI_MANIFEST,
digest: `sha256:${signatureManifestDigest}`,
size: signatureManifestBytes.byteLength,
artifactType: PLUGIN_PACKAGE_OCI_SIGNATURE_ARTIFACT_TYPE,
annotations: {
'qinglong.io/plugin-package-lock-digest': lock.lockDigest,
},
},
],
const initialRoutes = signedPackageRoutes(
initialMaterial,
initialLock,
privateKey,
);
const upgradeRoutes = signedPackageRoutes(
upgradeMaterial,
upgradeLock,
privateKey,
);
const initial = Object.freeze({
installationId: 'install-plugin-recovery-e2e-initial',
manifest: initialManifest,
lock: initialLock,
generation: createPluginPackageResourceGenerationFromReferences({
installationId: 'install-plugin-recovery-e2e-initial',
projectId: initialLock.projectId,
packageName: initialLock.packageName,
lockDigest: initialLock.lockDigest,
generation: initialLock.targetGeneration,
previousActiveLockDigest: null,
contentDigest: initialLock.source.contentDigest,
resources: initialLock.resources,
}),
routes: initialRoutes,
});
const upgrade = Object.freeze({
installationId: 'install-plugin-recovery-e2e-upgrade',
manifest: upgradeManifest,
lock: upgradeLock,
generation: createPluginPackageResourceGenerationFromReferences({
installationId: 'install-plugin-recovery-e2e-upgrade',
projectId: upgradeLock.projectId,
packageName: upgradeLock.packageName,
lockDigest: upgradeLock.lockDigest,
generation: upgradeLock.targetGeneration,
previousActiveLockDigest: initialLock.lockDigest,
contentDigest: upgradeLock.source.contentDigest,
resources: upgradeLock.resources,
}),
routes: upgradeRoutes,
});
const prefix = `/v2/${REPOSITORY}`;
const routes = [
route(
`${prefix}/manifests/sha256:${packageManifestDigest}`,
OCI_MANIFEST,
packageManifestBytes,
),
route(
`${prefix}/blobs/sha256:${packageConfigDigest}`,
PLUGIN_PACKAGE_OCI_CONFIG_MEDIA_TYPE,
packageConfig,
),
route(
`${prefix}/referrers/sha256:${packageManifestDigest}?artifactType=${encodeURIComponent(
PLUGIN_PACKAGE_OCI_SIGNATURE_ARTIFACT_TYPE,
)}`,
OCI_INDEX,
referrers,
),
route(
`${prefix}/manifests/sha256:${signatureManifestDigest}`,
OCI_MANIFEST,
signatureManifestBytes,
),
route(
`${prefix}/blobs/sha256:${signatureConfigDigest}`,
PLUGIN_PACKAGE_OCI_SIGNATURE_CONFIG_MEDIA_TYPE,
signatureConfig,
),
route(
`${prefix}/blobs/sha256:${artifactDigest}`,
BUNDLE,
artifact,
),
];
return Object.freeze({
schema: FIXTURE_SCHEMA,
registry,
repository: REPOSITORY,
architecture,
lock,
lock: initialLock,
initial,
upgrade,
trust,
routes,
routes: Object.freeze([...initialRoutes, ...upgradeRoutes]),
});
}
@@ -355,7 +517,10 @@ function readFixture(filePath) {
!value ||
value.schema !== FIXTURE_SCHEMA ||
!Array.isArray(value.routes) ||
!value.lock ||
!value.initial?.lock ||
!value.initial?.generation ||
!value.upgrade?.lock ||
!value.upgrade?.generation ||
!value.trust
) {
throw new Error('Plugin Package E2E fixture is invalid');
@@ -459,17 +624,27 @@ async function runSeed() {
assertPostgresPackageExecutorSchemaReady,
createPostgresDatabaseOpener,
loadPostgresConnectionEnvironment,
PostgresPluginPackageSecretBindingTransitionRepository,
} = ql3Require('@qinglong/cluster-postgres/package-executor');
const {
PostgresPluginPackageInstallRepository,
} = ql3Require('@qinglong/cluster-postgres/plugin-package-install');
const { PostgresPluginPackageInstallRepository } = ql3Require(
'@qinglong/cluster-postgres/plugin-package-install',
);
const {
createPluginPackageInstall,
normalizePluginPackageLock,
pluginPackageInstallCreate,
} = ql3Require('@qinglong/runtime-core/plugin-package-install');
const { createPluginPackageSecretBindingTarget } = ql3Require(
'@qinglong/runtime-core/plugin-package-secret-binding',
);
const { createPluginPackageSecretBindingTransitionPlan } = ql3Require(
'@qinglong/runtime-core/plugin-package-secret-binding-transition-plan',
);
const fixture = readFixture(process.env.QL3_E2E_FIXTURE_FILE);
const lock = normalizePluginPackageLock(fixture.lock);
const mode = process.env.QL3_E2E_MODE;
if (!['seed-initial', 'seed-upgrade', 'commit-transition'].includes(mode)) {
throw new Error('Plugin Package E2E seed mode is invalid');
}
const connection = loadPostgresConnectionEnvironment(process.env, {
host: 'QL3_E2E_POSTGRES_HOST',
port: 'QL3_E2E_POSTGRES_PORT',
@@ -489,21 +664,72 @@ async function runSeed() {
})();
try {
await assertPostgresPackageExecutorSchemaReady(database.pool);
if (mode === 'commit-transition') {
const plannedAtMs = Date.now();
const transitionPlan = createPluginPackageSecretBindingTransitionPlan({
previousTarget: createPluginPackageSecretBindingTarget(
fixture.initial.generation,
fixture.initial.manifest,
),
previousBinding: null,
previousAttemptGeneration: 1,
nextGeneration: fixture.upgrade.generation,
nextManifest: fixture.upgrade.manifest,
assignments: [],
plannedAtMs,
});
const result =
await new PostgresPluginPackageSecretBindingTransitionRepository(
database.pool,
).apply({
transitionPlan,
evidenceDigest: transitionPlan.transitionDigest,
committedAtMs: plannedAtMs + 1,
});
process.stdout.write(
`${JSON.stringify({
schema: 'qinglong/plugin-package-recovery-e2e-transition-result@v1',
event: 'transition_completed',
status: result.status,
generationDigest: transitionPlan.nextTarget.generationDigest,
transitionDigest: transitionPlan.transitionDigest,
bindingDigest: result.receipt.bindingDigest,
receiptDigest: result.receipt.receiptDigest,
})}\n`,
);
return;
}
const selected =
mode === 'seed-initial' ? fixture.initial : fixture.upgrade;
const lock = normalizePluginPackageLock(selected.lock);
const repository = new PostgresPluginPackageInstallRepository(
database.pool,
);
const previous = await repository.find(lock.projectId, lock.packageName);
if (
(mode === 'seed-initial' && previous !== null) ||
(mode === 'seed-upgrade' &&
(previous?.state !== 'active' ||
previous.installationId !== fixture.initial.installationId ||
previous.lockDigest !== fixture.initial.lock.lockDigest))
) {
throw new Error('Plugin Package E2E previous install head is invalid');
}
const record = createPluginPackageInstall(lock, {
installationId: 'install-plugin-recovery-e2e',
mutationId: 'mutation-plugin-recovery-e2e-create',
installationId: selected.installationId,
mutationId: `mutation-plugin-recovery-e2e-${
mode === 'seed-initial' ? 'initial' : 'upgrade'
}-create`,
occurredAtMs: lock.createdAtMs + 1,
});
const result = await repository.create(
pluginPackageInstallCreate(lock, record, null),
pluginPackageInstallCreate(lock, record, previous),
);
process.stdout.write(
`${JSON.stringify({
schema: 'qinglong/plugin-package-recovery-e2e-seed-result@v1',
event: 'seed_completed',
phase: mode === 'seed-initial' ? 'initial' : 'upgrade',
status: result.status,
state: result.record.state,
installationId: result.record.installationId,
@@ -521,11 +747,17 @@ async function main() {
await runRegistry();
return;
}
if (process.env.QL3_E2E_MODE === 'seed') {
if (
['seed-initial', 'seed-upgrade', 'commit-transition'].includes(
process.env.QL3_E2E_MODE,
)
) {
await runSeed();
return;
}
throw new Error('QL3_E2E_MODE must be registry or seed');
throw new Error(
'QL3_E2E_MODE must be registry, seed-initial, seed-upgrade or commit-transition',
);
}
if (require.main === module) {