mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): gate local secret transitions before activation
This commit is contained in:
@@ -286,6 +286,7 @@ test('recovers queued install through stage and activation without consuming app
|
||||
retry: 0,
|
||||
manualRequired: 0,
|
||||
superseded: 0,
|
||||
deferred: 0,
|
||||
remaining: false,
|
||||
safeToAdmit: true,
|
||||
});
|
||||
@@ -296,6 +297,62 @@ test('recovers queued install through stage and activation without consuming app
|
||||
assert.deepEqual(calls, { stage: 1, publish: 1, inspect: 0 });
|
||||
});
|
||||
|
||||
test('stages but defers activation until the exact prerequisite is ready', async () => {
|
||||
const value = fixture();
|
||||
const repository = new MemoryRepository([value]);
|
||||
const calls = { stage: 0, publish: 0, inspect: 0, prerequisite: 0 };
|
||||
let ready = false;
|
||||
const coordinator = new PluginPackageRecoveryCoordinator({
|
||||
repository,
|
||||
stageProvider: {
|
||||
async stage(lock) {
|
||||
calls.stage += 1;
|
||||
return stageEvidence(lock);
|
||||
},
|
||||
},
|
||||
publisher: publisherFor(repository, calls),
|
||||
activationPrerequisite: {
|
||||
async inspect() {
|
||||
calls.prerequisite += 1;
|
||||
return ready
|
||||
? { status: 'ready' }
|
||||
: {
|
||||
status: 'deferred',
|
||||
reason: 'secret_binding_transition_required',
|
||||
};
|
||||
},
|
||||
},
|
||||
now: () => 250,
|
||||
});
|
||||
|
||||
const deferred = await coordinator.recoverPage({ limit: 1 });
|
||||
assert.equal(deferred.items[0].status, 'deferred');
|
||||
assert.equal(
|
||||
(await repository.find('default', 'example-monitor')).state,
|
||||
'staged',
|
||||
);
|
||||
assert.deepEqual(calls, {
|
||||
stage: 1,
|
||||
publish: 0,
|
||||
inspect: 0,
|
||||
prerequisite: 2,
|
||||
});
|
||||
|
||||
ready = true;
|
||||
const settled = await coordinator.recoverPage({ limit: 1 });
|
||||
assert.equal(settled.items[0].status, 'settled');
|
||||
assert.equal(
|
||||
(await repository.find('default', 'example-monitor')).state,
|
||||
'active',
|
||||
);
|
||||
assert.deepEqual(calls, {
|
||||
stage: 1,
|
||||
publish: 1,
|
||||
inspect: 0,
|
||||
prerequisite: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test('inspects an activating install without republishing it', async () => {
|
||||
const value = activatingFixture();
|
||||
const repository = new MemoryRepository([
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createPluginPackageSecretBinding,
|
||||
} = require('@qinglong/runtime-core/plugin-package-secret-binding');
|
||||
const {
|
||||
createPluginPackageSecretBindingTransitionPlan,
|
||||
} = require('@qinglong/runtime-core/plugin-package-secret-binding-transition-plan');
|
||||
const {
|
||||
createPluginPackageSecretBindingFromTransitionPlan,
|
||||
createPluginPackageSecretBindingTransitionReceipt,
|
||||
normalizePluginPackageSecretBindingTransitionReceipt,
|
||||
} = require('@qinglong/runtime-core/plugin-package-secret-binding-transition-receipt');
|
||||
const {
|
||||
createPluginPackageResourceGeneration,
|
||||
} = require('@qinglong/runtime-core/plugin-package-resource-generation');
|
||||
const { createSecretRef } = require('@qinglong/runtime-core/secret-reference');
|
||||
|
||||
function manifest(secrets) {
|
||||
return {
|
||||
apiVersion: 'qinglong.io/v1alpha1',
|
||||
kind: 'Package',
|
||||
metadata: {
|
||||
name: 'receipt-package',
|
||||
displayName: 'Receipt package',
|
||||
version: '2.0.0',
|
||||
description: 'Transition receipt fixture',
|
||||
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: '8Mi' },
|
||||
},
|
||||
permissions: {
|
||||
network: { allowedHosts: [] },
|
||||
secrets,
|
||||
tools: secrets.length === 0 ? [] : ['secret.use'],
|
||||
},
|
||||
contents: { tasks: [], workflows: [], prompts: [], tools: [] },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const previousManifest = manifest([{ name: 'TOKEN', required: true }]);
|
||||
const previousGeneration = createPluginPackageResourceGeneration({
|
||||
installationId: 'install-1',
|
||||
projectId: 'project-1',
|
||||
packageName: 'receipt-package',
|
||||
lockDigest: 'a'.repeat(64),
|
||||
generation: 1,
|
||||
previousActiveLockDigest: null,
|
||||
contentDigest: 'b'.repeat(64),
|
||||
contents: previousManifest.spec.contents,
|
||||
});
|
||||
const previousBinding = createPluginPackageSecretBinding({
|
||||
generation: previousGeneration,
|
||||
manifest: previousManifest,
|
||||
assignments: [
|
||||
{
|
||||
name: 'TOKEN',
|
||||
secretRef: createSecretRef({
|
||||
projectId: 'project-1',
|
||||
name: 'token',
|
||||
version: 1,
|
||||
}),
|
||||
},
|
||||
],
|
||||
authority: {
|
||||
kind: 'local-owner-confirmation',
|
||||
evidenceDigest: 'c'.repeat(64),
|
||||
},
|
||||
boundAtMs: 10,
|
||||
});
|
||||
|
||||
function transition(nextManifest, assignments) {
|
||||
return createPluginPackageSecretBindingTransitionPlan({
|
||||
previousTarget: previousBinding.target,
|
||||
previousBinding,
|
||||
previousAttemptGeneration: 1,
|
||||
nextGeneration: createPluginPackageResourceGeneration({
|
||||
installationId: 'install-2',
|
||||
projectId: 'project-1',
|
||||
packageName: 'receipt-package',
|
||||
lockDigest: 'd'.repeat(64),
|
||||
generation: 2,
|
||||
previousActiveLockDigest: previousBinding.target.lockDigest,
|
||||
contentDigest: 'e'.repeat(64),
|
||||
contents: nextManifest.spec.contents,
|
||||
}),
|
||||
nextManifest,
|
||||
assignments,
|
||||
plannedAtMs: 20,
|
||||
});
|
||||
}
|
||||
|
||||
test('binds an exact next binding and approval evidence into one receipt', () => {
|
||||
const plan = transition(previousManifest, [
|
||||
{
|
||||
name: 'TOKEN',
|
||||
secretRef: createSecretRef({
|
||||
projectId: 'project-1',
|
||||
name: 'token',
|
||||
version: 2,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
const binding = createPluginPackageSecretBindingFromTransitionPlan(
|
||||
plan,
|
||||
'approved-action-execution',
|
||||
'f'.repeat(64),
|
||||
30,
|
||||
);
|
||||
const receipt = createPluginPackageSecretBindingTransitionReceipt({
|
||||
transitionPlan: plan,
|
||||
authority: {
|
||||
kind: 'approved-action-execution',
|
||||
evidenceDigest: 'f'.repeat(64),
|
||||
},
|
||||
binding,
|
||||
committedAtMs: 30,
|
||||
});
|
||||
assert.equal(receipt.bindingDigest, binding.bindingDigest);
|
||||
assert.equal(receipt.transitionPlan.kind, 'rotate');
|
||||
assert.deepEqual(
|
||||
normalizePluginPackageSecretBindingTransitionReceipt(receipt),
|
||||
receipt,
|
||||
);
|
||||
});
|
||||
|
||||
test('records full revocation without inventing an empty binding', () => {
|
||||
const plan = transition(manifest([]), []);
|
||||
const binding = createPluginPackageSecretBindingFromTransitionPlan(
|
||||
plan,
|
||||
'local-owner-confirmation',
|
||||
plan.transitionDigest,
|
||||
30,
|
||||
);
|
||||
assert.equal(binding, null);
|
||||
const receipt = createPluginPackageSecretBindingTransitionReceipt({
|
||||
transitionPlan: plan,
|
||||
authority: {
|
||||
kind: 'local-owner-confirmation',
|
||||
evidenceDigest: plan.transitionDigest,
|
||||
},
|
||||
binding,
|
||||
committedAtMs: 30,
|
||||
});
|
||||
assert.equal(receipt.bindingDigest, null);
|
||||
assert.equal(receipt.transitionPlan.kind, 'revoke');
|
||||
});
|
||||
|
||||
test('rejects missing, surplus, or authority-detached bindings', () => {
|
||||
const plan = transition(previousManifest, [
|
||||
{
|
||||
name: 'TOKEN',
|
||||
secretRef: createSecretRef({
|
||||
projectId: 'project-1',
|
||||
name: 'token',
|
||||
version: 2,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
assert.throws(() =>
|
||||
createPluginPackageSecretBindingTransitionReceipt({
|
||||
transitionPlan: plan,
|
||||
authority: {
|
||||
kind: 'local-owner-confirmation',
|
||||
evidenceDigest: plan.transitionDigest,
|
||||
},
|
||||
binding: null,
|
||||
committedAtMs: 30,
|
||||
}),
|
||||
);
|
||||
const binding = createPluginPackageSecretBindingFromTransitionPlan(
|
||||
plan,
|
||||
'local-owner-confirmation',
|
||||
plan.transitionDigest,
|
||||
30,
|
||||
);
|
||||
assert.throws(() =>
|
||||
createPluginPackageSecretBindingTransitionReceipt({
|
||||
transitionPlan: plan,
|
||||
authority: {
|
||||
kind: 'approved-action-execution',
|
||||
evidenceDigest: 'f'.repeat(64),
|
||||
},
|
||||
binding,
|
||||
committedAtMs: 30,
|
||||
}),
|
||||
);
|
||||
|
||||
const revoke = transition(manifest([]), []);
|
||||
assert.throws(() =>
|
||||
createPluginPackageSecretBindingTransitionReceipt({
|
||||
transitionPlan: revoke,
|
||||
authority: {
|
||||
kind: 'local-owner-confirmation',
|
||||
evidenceDigest: revoke.transitionDigest,
|
||||
},
|
||||
binding,
|
||||
committedAtMs: 30,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects receipt shape and digest tampering', () => {
|
||||
const plan = transition(manifest([]), []);
|
||||
const receipt = createPluginPackageSecretBindingTransitionReceipt({
|
||||
transitionPlan: plan,
|
||||
authority: {
|
||||
kind: 'local-owner-confirmation',
|
||||
evidenceDigest: plan.transitionDigest,
|
||||
},
|
||||
binding: null,
|
||||
committedAtMs: 30,
|
||||
});
|
||||
assert.throws(() =>
|
||||
normalizePluginPackageSecretBindingTransitionReceipt({
|
||||
...receipt,
|
||||
committedAtMs: 31,
|
||||
}),
|
||||
);
|
||||
assert.throws(() =>
|
||||
normalizePluginPackageSecretBindingTransitionReceipt({
|
||||
...receipt,
|
||||
unexpected: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user