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:
@@ -92,6 +92,9 @@
|
||||
"plugin-package-secret-binding-transition-plan": [
|
||||
"dist/plugin-package/secret-binding/transitionPlan.d.ts"
|
||||
],
|
||||
"plugin-package-secret-binding-transition-receipt": [
|
||||
"dist/plugin-package/secret-binding/transitionReceipt.d.ts"
|
||||
],
|
||||
"plugin-package-secret-binding-approval-plan": [
|
||||
"dist/plugin-package/secret-binding/approvalPlan.d.ts"
|
||||
],
|
||||
@@ -401,6 +404,11 @@
|
||||
"require": "./dist/plugin-package/secret-binding/transitionPlan.js",
|
||||
"default": "./dist/plugin-package/secret-binding/transitionPlan.js"
|
||||
},
|
||||
"./plugin-package-secret-binding-transition-receipt": {
|
||||
"types": "./dist/plugin-package/secret-binding/transitionReceipt.d.ts",
|
||||
"require": "./dist/plugin-package/secret-binding/transitionReceipt.js",
|
||||
"default": "./dist/plugin-package/secret-binding/transitionReceipt.js"
|
||||
},
|
||||
"./plugin-package-secret-binding-approval-plan": {
|
||||
"types": "./dist/plugin-package/secret-binding/approvalPlan.d.ts",
|
||||
"require": "./dist/plugin-package/secret-binding/approvalPlan.js",
|
||||
|
||||
+40
-2
@@ -5,6 +5,8 @@ import {
|
||||
import {
|
||||
InvalidPluginPackageInstallError,
|
||||
PluginPackageInstallTransitionConflictError,
|
||||
PluginPackageInstallUnavailableError,
|
||||
assertPluginPackageInstallMatchesLock,
|
||||
normalizePluginPackageLock,
|
||||
pluginPackageInstallCommit,
|
||||
transitionPluginPackageInstall,
|
||||
@@ -33,6 +35,20 @@ export interface PluginPackageStageProvider {
|
||||
): Promise<Readonly<PluginPackageStageEvidence>>;
|
||||
}
|
||||
|
||||
export type PluginPackageActivationPrerequisiteObservation =
|
||||
| Readonly<{ status: 'ready' }>
|
||||
| Readonly<{
|
||||
status: 'deferred';
|
||||
reason: 'secret_binding_transition_required';
|
||||
}>;
|
||||
|
||||
export interface PluginPackageActivationPrerequisite {
|
||||
inspect(
|
||||
record: Readonly<PluginPackageInstallRecord>,
|
||||
lock: Readonly<PluginPackageLock>,
|
||||
): Promise<Readonly<PluginPackageActivationPrerequisiteObservation>>;
|
||||
}
|
||||
|
||||
export interface InstallPluginPackageOptions {
|
||||
readonly lock: PluginPackageLock;
|
||||
readonly proposalDigest: string;
|
||||
@@ -139,15 +155,23 @@ export function normalizePluginPackageStageEvidence(
|
||||
export class PluginPackageInstallationCoordinator {
|
||||
readonly #repository: PluginPackageAdmissionRepository;
|
||||
readonly #activation: PluginPackageActivationCoordinator;
|
||||
readonly #activationPrerequisite?: PluginPackageActivationPrerequisite;
|
||||
|
||||
constructor(options: {
|
||||
readonly repository: PluginPackageAdmissionRepository;
|
||||
readonly publisher: PluginPackageActivationPublisher;
|
||||
readonly activationPrerequisite?: PluginPackageActivationPrerequisite;
|
||||
}) {
|
||||
const value = dataRecord(options, 'installation coordinator options');
|
||||
exactKeys(
|
||||
value,
|
||||
['repository', 'publisher'],
|
||||
[
|
||||
'repository',
|
||||
'publisher',
|
||||
...(options.activationPrerequisite === undefined
|
||||
? []
|
||||
: ['activationPrerequisite']),
|
||||
],
|
||||
'installation coordinator options',
|
||||
);
|
||||
if (
|
||||
@@ -157,7 +181,10 @@ export class PluginPackageInstallationCoordinator {
|
||||
typeof options.repository.create !== 'function' ||
|
||||
typeof options.repository.commit !== 'function' ||
|
||||
typeof options.repository.admit !== 'function' ||
|
||||
typeof options.repository.findAdmissionReceipt !== 'function'
|
||||
typeof options.repository.findAdmissionReceipt !== 'function' ||
|
||||
(options.activationPrerequisite !== undefined &&
|
||||
(!options.activationPrerequisite ||
|
||||
typeof options.activationPrerequisite.inspect !== 'function'))
|
||||
) {
|
||||
throw new InvalidPluginPackageInstallError(
|
||||
'installation coordinator authority is invalid',
|
||||
@@ -168,6 +195,9 @@ export class PluginPackageInstallationCoordinator {
|
||||
repository: options.repository,
|
||||
publisher: options.publisher,
|
||||
});
|
||||
if (options.activationPrerequisite !== undefined) {
|
||||
this.#activationPrerequisite = options.activationPrerequisite;
|
||||
}
|
||||
}
|
||||
|
||||
async #convergeActivation(
|
||||
@@ -186,6 +216,14 @@ export class PluginPackageInstallationCoordinator {
|
||||
installationId: record.installationId,
|
||||
};
|
||||
if (record.state === 'staged') {
|
||||
const lock = await this.#repository.findLock(record.lockDigest);
|
||||
if (!lock) throw new PluginPackageInstallUnavailableError();
|
||||
assertPluginPackageInstallMatchesLock(lock, record);
|
||||
const prerequisite = await this.#activationPrerequisite?.inspect(
|
||||
record,
|
||||
lock,
|
||||
);
|
||||
if (prerequisite?.status === 'deferred') return record;
|
||||
return this.#activation.activate({
|
||||
...identity,
|
||||
activationStartedMutationId: options.activationStartedMutationId,
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from './pluginPackageInstall';
|
||||
import {
|
||||
normalizePluginPackageStageEvidence,
|
||||
type PluginPackageActivationPrerequisite,
|
||||
type PluginPackageStageProvider,
|
||||
} from './pluginPackageInstallation';
|
||||
|
||||
@@ -37,6 +38,7 @@ export const PLUGIN_PACKAGE_RECOVERY_ITEM_STATUSES = [
|
||||
'retry',
|
||||
'manual_required',
|
||||
'superseded',
|
||||
'deferred',
|
||||
] as const;
|
||||
|
||||
export type PluginPackageRecoveryItemStatus =
|
||||
@@ -64,6 +66,7 @@ export interface PluginPackageRecoveryCycleResult {
|
||||
readonly retry: number;
|
||||
readonly manualRequired: number;
|
||||
readonly superseded: number;
|
||||
readonly deferred: number;
|
||||
readonly remaining: boolean;
|
||||
readonly safeToAdmit: boolean;
|
||||
}
|
||||
@@ -235,18 +238,20 @@ export class PluginPackageRecoveryCoordinator {
|
||||
readonly #stageProvider: PluginPackageStageProvider;
|
||||
readonly #activation: PluginPackageActivationCoordinator;
|
||||
readonly #now: () => number | Promise<number>;
|
||||
readonly #activationPrerequisite?: PluginPackageActivationPrerequisite;
|
||||
|
||||
constructor(options: {
|
||||
readonly repository: PluginPackageInstallRepository;
|
||||
readonly stageProvider: PluginPackageStageProvider;
|
||||
readonly publisher: PluginPackageActivationPublisher;
|
||||
readonly now: () => number | Promise<number>;
|
||||
readonly activationPrerequisite?: PluginPackageActivationPrerequisite;
|
||||
}) {
|
||||
const value = dataRecord(options, 'recovery coordinator options');
|
||||
exactKeys(
|
||||
value,
|
||||
['repository', 'stageProvider', 'publisher', 'now'],
|
||||
[],
|
||||
['activationPrerequisite'],
|
||||
'recovery coordinator options',
|
||||
);
|
||||
if (
|
||||
@@ -257,7 +262,10 @@ export class PluginPackageRecoveryCoordinator {
|
||||
typeof options.repository.listRecoveryPage !== 'function' ||
|
||||
!options.stageProvider ||
|
||||
typeof options.stageProvider.stage !== 'function' ||
|
||||
typeof options.now !== 'function'
|
||||
typeof options.now !== 'function' ||
|
||||
(options.activationPrerequisite !== undefined &&
|
||||
(!options.activationPrerequisite ||
|
||||
typeof options.activationPrerequisite.inspect !== 'function'))
|
||||
) {
|
||||
throw new InvalidPluginPackageInstallError(
|
||||
'recovery coordinator authority is invalid',
|
||||
@@ -270,6 +278,9 @@ export class PluginPackageRecoveryCoordinator {
|
||||
publisher: options.publisher,
|
||||
});
|
||||
this.#now = options.now;
|
||||
if (options.activationPrerequisite !== undefined) {
|
||||
this.#activationPrerequisite = options.activationPrerequisite;
|
||||
}
|
||||
}
|
||||
|
||||
async #current(
|
||||
@@ -292,6 +303,14 @@ export class PluginPackageRecoveryCoordinator {
|
||||
installationId: record.installationId,
|
||||
};
|
||||
if (record.state === 'staged') {
|
||||
const lock = await this.#repository.findLock(record.lockDigest);
|
||||
if (!lock) throw new PluginPackageInstallUnavailableError();
|
||||
assertPluginPackageInstallMatchesLock(lock, record);
|
||||
const prerequisite = await this.#activationPrerequisite?.inspect(
|
||||
record,
|
||||
lock,
|
||||
);
|
||||
if (prerequisite?.status === 'deferred') return record;
|
||||
return this.#activation.activate({
|
||||
...identity,
|
||||
activationStartedMutationId: mutationId(
|
||||
@@ -374,6 +393,18 @@ export class PluginPackageRecoveryCoordinator {
|
||||
current = committed;
|
||||
}
|
||||
current = await this.#convergeActivation(current, occurredAtMs);
|
||||
if (current.state === 'staged' && this.#activationPrerequisite) {
|
||||
const lock = await this.#repository.findLock(current.lockDigest);
|
||||
if (!lock) throw new PluginPackageInstallUnavailableError();
|
||||
assertPluginPackageInstallMatchesLock(lock, current);
|
||||
const prerequisite = await this.#activationPrerequisite.inspect(
|
||||
current,
|
||||
lock,
|
||||
);
|
||||
if (prerequisite.status === 'deferred') {
|
||||
return result(source, action, 'deferred', current);
|
||||
}
|
||||
}
|
||||
if (current.state === 'active' || current.state === 'failed') {
|
||||
return result(source, action, 'settled', current);
|
||||
}
|
||||
@@ -477,6 +508,7 @@ export class PluginPackageRecoveryCoordinator {
|
||||
retry: 0,
|
||||
manualRequired: 0,
|
||||
superseded: 0,
|
||||
deferred: 0,
|
||||
};
|
||||
let after: Readonly<PluginPackageInstallRecoveryCursor> | undefined;
|
||||
let exhausted = false;
|
||||
@@ -501,6 +533,9 @@ export class PluginPackageRecoveryCoordinator {
|
||||
case 'superseded':
|
||||
counts.superseded += 1;
|
||||
break;
|
||||
case 'deferred':
|
||||
counts.deferred += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!page.truncated) {
|
||||
@@ -517,7 +552,8 @@ export class PluginPackageRecoveryCoordinator {
|
||||
return Object.freeze({
|
||||
...counts,
|
||||
remaining,
|
||||
safeToAdmit: !remaining && counts.manualRequired === 0,
|
||||
safeToAdmit:
|
||||
!remaining && counts.retry === 0 && counts.manualRequired === 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import {
|
||||
createPluginPackageSecretBindingFromEntries,
|
||||
normalizePluginPackageSecretBinding,
|
||||
type PluginPackageSecretBinding,
|
||||
type PluginPackageSecretBindingAuthorityKind,
|
||||
} from './binding';
|
||||
import {
|
||||
normalizePluginPackageSecretBindingTransitionPlan,
|
||||
type PluginPackageSecretBindingTransitionPlan,
|
||||
} from './transitionPlan';
|
||||
|
||||
export const PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_SCHEMA =
|
||||
'qinglong/plugin-package-secret-binding-transition-receipt@v1' as const;
|
||||
export const MAX_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_JSON_BYTES =
|
||||
192 * 1024;
|
||||
|
||||
export interface PluginPackageSecretBindingTransitionReceiptAuthority {
|
||||
readonly kind: PluginPackageSecretBindingAuthorityKind;
|
||||
readonly evidenceDigest: string;
|
||||
}
|
||||
|
||||
export interface PluginPackageSecretBindingTransitionReceipt {
|
||||
readonly schema: typeof PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_SCHEMA;
|
||||
readonly transitionPlan: Readonly<PluginPackageSecretBindingTransitionPlan>;
|
||||
readonly authority: Readonly<PluginPackageSecretBindingTransitionReceiptAuthority>;
|
||||
readonly bindingDigest: string | null;
|
||||
readonly committedAtMs: number;
|
||||
readonly receiptDigest: string;
|
||||
}
|
||||
|
||||
export interface CreatePluginPackageSecretBindingTransitionReceiptInput {
|
||||
readonly transitionPlan: Readonly<PluginPackageSecretBindingTransitionPlan>;
|
||||
readonly authority: Readonly<PluginPackageSecretBindingTransitionReceiptAuthority>;
|
||||
readonly binding: Readonly<PluginPackageSecretBinding> | null;
|
||||
readonly committedAtMs: number;
|
||||
}
|
||||
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
const RECEIPT_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/plugin-package-secret-binding-transition-receipt-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new TypeError(
|
||||
`Plugin Package Secret binding transition receipt is invalid: ${message}`,
|
||||
);
|
||||
}
|
||||
|
||||
function dataRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
(Object.getPrototypeOf(value) !== Object.prototype &&
|
||||
Object.getPrototypeOf(value) !== null)
|
||||
) {
|
||||
return invalid(`${label} must be an object`);
|
||||
}
|
||||
const descriptors = Object.getOwnPropertyDescriptors(value);
|
||||
if (
|
||||
Object.values(descriptors).some(
|
||||
(descriptor) =>
|
||||
descriptor.get !== undefined ||
|
||||
descriptor.set !== undefined ||
|
||||
descriptor.enumerable !== true,
|
||||
)
|
||||
) {
|
||||
return invalid(`${label} must contain enumerable data properties`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: object,
|
||||
expected: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Reflect.ownKeys(value);
|
||||
const strings = actual.filter(
|
||||
(key): key is string => typeof key === 'string',
|
||||
);
|
||||
const canonical = [...expected].sort();
|
||||
if (
|
||||
actual.length !== canonical.length ||
|
||||
strings.length !== canonical.length ||
|
||||
strings.sort().some((key, index) => key !== canonical[index])
|
||||
) {
|
||||
invalid(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function timestamp(value: unknown): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
return invalid('committedAtMs is invalid');
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function authority(
|
||||
value: unknown,
|
||||
): Readonly<PluginPackageSecretBindingTransitionReceiptAuthority> {
|
||||
const candidate = dataRecord(value, 'authority');
|
||||
exactKeys(candidate, ['evidenceDigest', 'kind'], 'authority');
|
||||
if (
|
||||
candidate.kind !== 'approved-action-execution' &&
|
||||
candidate.kind !== 'local-owner-confirmation'
|
||||
) {
|
||||
return invalid('authority kind is invalid');
|
||||
}
|
||||
if (
|
||||
typeof candidate.evidenceDigest !== 'string' ||
|
||||
!DIGEST.test(candidate.evidenceDigest)
|
||||
) {
|
||||
return invalid('authority evidence digest is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
kind: candidate.kind,
|
||||
evidenceDigest: candidate.evidenceDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function unsigned(
|
||||
transitionPlan: Readonly<PluginPackageSecretBindingTransitionPlan>,
|
||||
normalizedAuthority: Readonly<PluginPackageSecretBindingTransitionReceiptAuthority>,
|
||||
bindingDigest: string | null,
|
||||
committedAtMs: number,
|
||||
): Omit<PluginPackageSecretBindingTransitionReceipt, 'receiptDigest'> {
|
||||
return Object.freeze({
|
||||
schema: PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_SCHEMA,
|
||||
transitionPlan,
|
||||
authority: normalizedAuthority,
|
||||
bindingDigest,
|
||||
committedAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function receiptDigest(
|
||||
value: Omit<PluginPackageSecretBindingTransitionReceipt, 'receiptDigest'>,
|
||||
): string {
|
||||
return createHash('sha256')
|
||||
.update(RECEIPT_DIGEST_DOMAIN)
|
||||
.update(JSON.stringify(value), 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function bounded(
|
||||
value: Readonly<PluginPackageSecretBindingTransitionReceipt>,
|
||||
): Readonly<PluginPackageSecretBindingTransitionReceipt> {
|
||||
if (
|
||||
Buffer.byteLength(JSON.stringify(value), 'utf8') >
|
||||
MAX_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_JSON_BYTES
|
||||
) {
|
||||
return invalid('durable JSON byte budget exceeded');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertBinding(
|
||||
transitionPlan: Readonly<PluginPackageSecretBindingTransitionPlan>,
|
||||
bindingValue: Readonly<PluginPackageSecretBinding> | null,
|
||||
normalizedAuthority: Readonly<PluginPackageSecretBindingTransitionReceiptAuthority>,
|
||||
committedAtMs: number,
|
||||
): Readonly<PluginPackageSecretBinding> | null {
|
||||
if (transitionPlan.nextBindingPlan === null) {
|
||||
if (bindingValue !== null) {
|
||||
return invalid(
|
||||
'binding is forbidden when the transition revokes all requirements',
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (bindingValue === null) {
|
||||
return invalid('binding is required by the transition plan');
|
||||
}
|
||||
const binding = normalizePluginPackageSecretBinding(bindingValue);
|
||||
if (
|
||||
JSON.stringify(binding.target) !==
|
||||
JSON.stringify(transitionPlan.nextTarget) ||
|
||||
JSON.stringify(binding.entries) !==
|
||||
JSON.stringify(transitionPlan.nextBindingPlan.entries) ||
|
||||
binding.authority.kind !== normalizedAuthority.kind ||
|
||||
binding.authority.evidenceDigest !== normalizedAuthority.evidenceDigest ||
|
||||
binding.boundAtMs !== committedAtMs
|
||||
) {
|
||||
return invalid(
|
||||
'binding does not match the transition, authority, and commit',
|
||||
);
|
||||
}
|
||||
return binding;
|
||||
}
|
||||
|
||||
export function createPluginPackageSecretBindingFromTransitionPlan(
|
||||
planValue: Readonly<PluginPackageSecretBindingTransitionPlan>,
|
||||
authorityKind: PluginPackageSecretBindingAuthorityKind,
|
||||
evidenceDigest: string,
|
||||
boundAtMs: number,
|
||||
): Readonly<PluginPackageSecretBinding> | null {
|
||||
const plan = normalizePluginPackageSecretBindingTransitionPlan(planValue);
|
||||
if (plan.nextBindingPlan === null) return null;
|
||||
return createPluginPackageSecretBindingFromEntries({
|
||||
target: plan.nextTarget,
|
||||
entries: plan.nextBindingPlan.entries,
|
||||
authority: { kind: authorityKind, evidenceDigest },
|
||||
boundAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
export function createPluginPackageSecretBindingTransitionReceipt(
|
||||
input: CreatePluginPackageSecretBindingTransitionReceiptInput,
|
||||
): Readonly<PluginPackageSecretBindingTransitionReceipt> {
|
||||
const candidate = dataRecord(input, 'receipt input');
|
||||
exactKeys(
|
||||
candidate,
|
||||
['authority', 'binding', 'committedAtMs', 'transitionPlan'],
|
||||
'receipt input',
|
||||
);
|
||||
const transitionPlan = normalizePluginPackageSecretBindingTransitionPlan(
|
||||
input.transitionPlan,
|
||||
);
|
||||
const normalizedAuthority = authority(input.authority);
|
||||
const committedAtMs = timestamp(input.committedAtMs);
|
||||
if (committedAtMs < (transitionPlan.nextBindingPlan?.plannedAtMs ?? 0)) {
|
||||
return invalid('commit precedes the reviewed next binding plan');
|
||||
}
|
||||
const binding = assertBinding(
|
||||
transitionPlan,
|
||||
input.binding,
|
||||
normalizedAuthority,
|
||||
committedAtMs,
|
||||
);
|
||||
const value = unsigned(
|
||||
transitionPlan,
|
||||
normalizedAuthority,
|
||||
binding?.bindingDigest ?? null,
|
||||
committedAtMs,
|
||||
);
|
||||
return bounded(
|
||||
Object.freeze({ ...value, receiptDigest: receiptDigest(value) }),
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizePluginPackageSecretBindingTransitionReceipt(
|
||||
value: unknown,
|
||||
): Readonly<PluginPackageSecretBindingTransitionReceipt> {
|
||||
const candidate = dataRecord(value, 'receipt');
|
||||
exactKeys(
|
||||
candidate,
|
||||
[
|
||||
'authority',
|
||||
'bindingDigest',
|
||||
'committedAtMs',
|
||||
'receiptDigest',
|
||||
'schema',
|
||||
'transitionPlan',
|
||||
],
|
||||
'receipt',
|
||||
);
|
||||
if (
|
||||
candidate.schema !== PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_SCHEMA
|
||||
) {
|
||||
return invalid('schema is unsupported');
|
||||
}
|
||||
const transitionPlan = normalizePluginPackageSecretBindingTransitionPlan(
|
||||
candidate.transitionPlan,
|
||||
);
|
||||
const normalizedAuthority = authority(candidate.authority);
|
||||
const committedAtMs = timestamp(candidate.committedAtMs);
|
||||
if (committedAtMs < (transitionPlan.nextBindingPlan?.plannedAtMs ?? 0)) {
|
||||
return invalid('commit precedes the reviewed next binding plan');
|
||||
}
|
||||
const bindingDigestValue = candidate.bindingDigest;
|
||||
if (
|
||||
(bindingDigestValue !== null &&
|
||||
(typeof bindingDigestValue !== 'string' ||
|
||||
!DIGEST.test(bindingDigestValue))) ||
|
||||
(transitionPlan.nextBindingPlan === null) !== (bindingDigestValue === null)
|
||||
) {
|
||||
return invalid('binding digest presence is inconsistent with transition');
|
||||
}
|
||||
const normalized = unsigned(
|
||||
transitionPlan,
|
||||
normalizedAuthority,
|
||||
bindingDigestValue as string | null,
|
||||
committedAtMs,
|
||||
);
|
||||
if (
|
||||
typeof candidate.receiptDigest !== 'string' ||
|
||||
!DIGEST.test(candidate.receiptDigest) ||
|
||||
candidate.receiptDigest !== receiptDigest(normalized)
|
||||
) {
|
||||
return invalid('receipt digest does not match content');
|
||||
}
|
||||
return bounded(
|
||||
Object.freeze({ ...normalized, receiptDigest: candidate.receiptDigest }),
|
||||
);
|
||||
}
|
||||
|
||||
export interface PluginPackageSecretBindingTransitionReceiptRepository {
|
||||
find(
|
||||
generationDigest: string,
|
||||
): Promise<Readonly<PluginPackageSecretBindingTransitionReceipt> | null>;
|
||||
}
|
||||
@@ -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