mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): define secret binding generation transitions
This commit is contained in:
@@ -89,6 +89,9 @@
|
||||
"plugin-package-secret-binding-plan": [
|
||||
"dist/plugin-package/secret-binding/plan.d.ts"
|
||||
],
|
||||
"plugin-package-secret-binding-transition-plan": [
|
||||
"dist/plugin-package/secret-binding/transitionPlan.d.ts"
|
||||
],
|
||||
"plugin-package-secret-binding-approval-plan": [
|
||||
"dist/plugin-package/secret-binding/approvalPlan.d.ts"
|
||||
],
|
||||
@@ -393,6 +396,11 @@
|
||||
"require": "./dist/plugin-package/secret-binding/plan.js",
|
||||
"default": "./dist/plugin-package/secret-binding/plan.js"
|
||||
},
|
||||
"./plugin-package-secret-binding-transition-plan": {
|
||||
"types": "./dist/plugin-package/secret-binding/transitionPlan.d.ts",
|
||||
"require": "./dist/plugin-package/secret-binding/transitionPlan.js",
|
||||
"default": "./dist/plugin-package/secret-binding/transitionPlan.js"
|
||||
},
|
||||
"./plugin-package-secret-binding-approval-plan": {
|
||||
"types": "./dist/plugin-package/secret-binding/approvalPlan.d.ts",
|
||||
"require": "./dist/plugin-package/secret-binding/approvalPlan.js",
|
||||
|
||||
@@ -329,6 +329,21 @@ function targetFromGeneration(
|
||||
});
|
||||
}
|
||||
|
||||
export function createPluginPackageSecretBindingTarget(
|
||||
generationValue: Readonly<PluginPackageResourceGeneration>,
|
||||
manifestValue: Readonly<PluginPackageManifest>,
|
||||
): Readonly<PluginPackageSecretBindingTarget> {
|
||||
const generation = normalizePluginPackageResourceGeneration(generationValue);
|
||||
const manifest = normalizePluginPackageManifest(manifestValue);
|
||||
if (manifest.metadata.name !== generation.packageName) {
|
||||
return invalid('Manifest Package does not match generation');
|
||||
}
|
||||
return targetFromGeneration(
|
||||
generation,
|
||||
pluginPackageManifestDigest(manifest),
|
||||
);
|
||||
}
|
||||
|
||||
function unsignedBinding(
|
||||
target: Readonly<PluginPackageSecretBindingTarget>,
|
||||
entries: readonly Readonly<PluginPackageSecretBindingEntry>[],
|
||||
@@ -457,6 +472,12 @@ function normalizeTarget(
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizePluginPackageSecretBindingTarget(
|
||||
value: unknown,
|
||||
): Readonly<PluginPackageSecretBindingTarget> {
|
||||
return normalizeTarget(value);
|
||||
}
|
||||
|
||||
export function normalizePluginPackageSecretBinding(
|
||||
value: unknown,
|
||||
): Readonly<PluginPackageSecretBinding> {
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { normalizePluginPackageResourceGeneration } from '../pluginPackageResourceGeneration';
|
||||
import {
|
||||
normalizePluginPackageManifest,
|
||||
type PluginPackageManifest,
|
||||
} from '../pluginPackage';
|
||||
import { parseSecretRef } from '../../secret/secretReference';
|
||||
import {
|
||||
createPluginPackageSecretBindingTarget,
|
||||
normalizePluginPackageSecretBinding,
|
||||
normalizePluginPackageSecretBindingTarget,
|
||||
type PluginPackageSecretBinding,
|
||||
type PluginPackageSecretBindingAssignment,
|
||||
type PluginPackageSecretBindingEntry,
|
||||
} from './binding';
|
||||
import {
|
||||
createPluginPackageSecretBindingPlan,
|
||||
normalizePluginPackageSecretBindingPlan,
|
||||
type PluginPackageSecretBindingPlan,
|
||||
} from './plan';
|
||||
|
||||
export const PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_PLAN_SCHEMA =
|
||||
'qinglong/plugin-package-secret-binding-transition-plan@v1' as const;
|
||||
export const PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_KINDS = [
|
||||
'carry-forward',
|
||||
'rotate',
|
||||
'rebind',
|
||||
'revoke',
|
||||
] as const;
|
||||
export const PLUGIN_PACKAGE_SECRET_REQUIREMENT_CHANGE_KINDS = [
|
||||
'added',
|
||||
'removed',
|
||||
'tightened',
|
||||
'relaxed',
|
||||
'unchanged',
|
||||
] as const;
|
||||
export const PLUGIN_PACKAGE_SECRET_REFERENCE_CHANGE_KINDS = [
|
||||
'bound',
|
||||
'revoked',
|
||||
'rotated',
|
||||
'rebound',
|
||||
'unchanged',
|
||||
] as const;
|
||||
export const MAX_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_PLAN_JSON_BYTES =
|
||||
160 * 1024;
|
||||
|
||||
export type PluginPackageSecretBindingTransitionKind =
|
||||
(typeof PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_KINDS)[number];
|
||||
export type PluginPackageSecretRequirementChangeKind =
|
||||
(typeof PLUGIN_PACKAGE_SECRET_REQUIREMENT_CHANGE_KINDS)[number];
|
||||
export type PluginPackageSecretReferenceChangeKind =
|
||||
(typeof PLUGIN_PACKAGE_SECRET_REFERENCE_CHANGE_KINDS)[number];
|
||||
|
||||
export interface PluginPackageSecretBindingTransitionEntryState {
|
||||
readonly required: boolean;
|
||||
readonly secretRef: string | null;
|
||||
}
|
||||
|
||||
export interface PluginPackageSecretBindingTransitionChange {
|
||||
readonly name: string;
|
||||
readonly requirement: PluginPackageSecretRequirementChangeKind;
|
||||
readonly reference: PluginPackageSecretReferenceChangeKind;
|
||||
readonly previous: Readonly<PluginPackageSecretBindingTransitionEntryState> | null;
|
||||
readonly next: Readonly<PluginPackageSecretBindingTransitionEntryState> | null;
|
||||
}
|
||||
|
||||
export interface PluginPackageSecretBindingTransitionPlan {
|
||||
readonly schema: typeof PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_PLAN_SCHEMA;
|
||||
readonly kind: PluginPackageSecretBindingTransitionKind;
|
||||
readonly previousBinding: Readonly<PluginPackageSecretBinding>;
|
||||
readonly previousActiveLockDigest: string;
|
||||
readonly previousAttemptGeneration: number;
|
||||
readonly nextTarget: Readonly<PluginPackageSecretBinding['target']>;
|
||||
readonly nextBindingPlan: Readonly<PluginPackageSecretBindingPlan> | null;
|
||||
readonly changes: readonly Readonly<PluginPackageSecretBindingTransitionChange>[];
|
||||
readonly transitionDigest: string;
|
||||
}
|
||||
|
||||
export interface CreatePluginPackageSecretBindingTransitionPlanInput {
|
||||
readonly previousBinding: Readonly<PluginPackageSecretBinding>;
|
||||
readonly previousAttemptGeneration: number;
|
||||
readonly nextGeneration: Parameters<
|
||||
typeof normalizePluginPackageResourceGeneration
|
||||
>[0];
|
||||
readonly nextManifest: Readonly<PluginPackageManifest>;
|
||||
readonly assignments: readonly Readonly<PluginPackageSecretBindingAssignment>[];
|
||||
readonly plannedAtMs: number;
|
||||
}
|
||||
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
const TRANSITION_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/plugin-package-secret-binding-transition-plan-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new TypeError(
|
||||
`Plugin Package Secret binding transition plan 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 entryState(
|
||||
entry: Readonly<PluginPackageSecretBindingEntry>,
|
||||
): Readonly<PluginPackageSecretBindingTransitionEntryState> {
|
||||
return Object.freeze({
|
||||
required: entry.required,
|
||||
secretRef: entry.secretRef,
|
||||
});
|
||||
}
|
||||
|
||||
function requirementChange(
|
||||
previous: Readonly<PluginPackageSecretBindingEntry> | undefined,
|
||||
next: Readonly<PluginPackageSecretBindingEntry> | undefined,
|
||||
): PluginPackageSecretRequirementChangeKind {
|
||||
if (!previous) return 'added';
|
||||
if (!next) return 'removed';
|
||||
if (previous.required === next.required) return 'unchanged';
|
||||
return next.required ? 'tightened' : 'relaxed';
|
||||
}
|
||||
|
||||
function referenceChange(
|
||||
previous: Readonly<PluginPackageSecretBindingEntry> | undefined,
|
||||
next: Readonly<PluginPackageSecretBindingEntry> | undefined,
|
||||
): PluginPackageSecretReferenceChangeKind {
|
||||
const before = previous?.secretRef ?? null;
|
||||
const after = next?.secretRef ?? null;
|
||||
if (before === after) return 'unchanged';
|
||||
if (before === null) return 'bound';
|
||||
if (after === null) return 'revoked';
|
||||
const previousReference = parseSecretRef(before);
|
||||
const nextReference = parseSecretRef(after);
|
||||
if (
|
||||
previousReference.projectId === nextReference.projectId &&
|
||||
previousReference.name === nextReference.name &&
|
||||
previousReference.version !== undefined &&
|
||||
nextReference.version !== undefined &&
|
||||
nextReference.version > previousReference.version
|
||||
) {
|
||||
return 'rotated';
|
||||
}
|
||||
return 'rebound';
|
||||
}
|
||||
|
||||
function deriveChanges(
|
||||
previousEntries: readonly Readonly<PluginPackageSecretBindingEntry>[],
|
||||
nextEntries: readonly Readonly<PluginPackageSecretBindingEntry>[],
|
||||
): readonly Readonly<PluginPackageSecretBindingTransitionChange>[] {
|
||||
const previous = new Map(previousEntries.map((entry) => [entry.name, entry]));
|
||||
const next = new Map(nextEntries.map((entry) => [entry.name, entry]));
|
||||
const names = [...new Set([...previous.keys(), ...next.keys()])].sort();
|
||||
return Object.freeze(
|
||||
names.map((name) => {
|
||||
const before = previous.get(name);
|
||||
const after = next.get(name);
|
||||
return Object.freeze({
|
||||
name,
|
||||
requirement: requirementChange(before, after),
|
||||
reference: referenceChange(before, after),
|
||||
previous: before ? entryState(before) : null,
|
||||
next: after ? entryState(after) : null,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function deriveKind(
|
||||
changes: readonly Readonly<PluginPackageSecretBindingTransitionChange>[],
|
||||
): PluginPackageSecretBindingTransitionKind {
|
||||
if (
|
||||
changes.some(
|
||||
(change) =>
|
||||
change.requirement === 'removed' || change.reference === 'revoked',
|
||||
)
|
||||
) {
|
||||
return 'revoke';
|
||||
}
|
||||
if (
|
||||
changes.some(
|
||||
(change) =>
|
||||
change.requirement !== 'unchanged' ||
|
||||
change.reference === 'bound' ||
|
||||
change.reference === 'rebound',
|
||||
)
|
||||
) {
|
||||
return 'rebind';
|
||||
}
|
||||
if (changes.some((change) => change.reference === 'rotated')) {
|
||||
return 'rotate';
|
||||
}
|
||||
return 'carry-forward';
|
||||
}
|
||||
|
||||
function assertLineage(
|
||||
previous: Readonly<PluginPackageSecretBinding>,
|
||||
nextTarget: Readonly<PluginPackageSecretBinding['target']>,
|
||||
previousActiveLockDigest: unknown,
|
||||
previousAttemptGeneration: unknown,
|
||||
): Readonly<{
|
||||
previousActiveLockDigest: string;
|
||||
previousAttemptGeneration: number;
|
||||
}> {
|
||||
if (
|
||||
typeof previousActiveLockDigest !== 'string' ||
|
||||
!DIGEST.test(previousActiveLockDigest) ||
|
||||
previousActiveLockDigest !== previous.target.lockDigest
|
||||
) {
|
||||
return invalid('previous active lock digest is invalid');
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(previousAttemptGeneration) ||
|
||||
(previousAttemptGeneration as number) < previous.target.generation ||
|
||||
(previousAttemptGeneration as number) >= 2_147_483_647
|
||||
) {
|
||||
return invalid('previous attempt generation is invalid');
|
||||
}
|
||||
if (
|
||||
nextTarget.projectId !== previous.target.projectId ||
|
||||
nextTarget.packageName !== previous.target.packageName ||
|
||||
nextTarget.generation !== (previousAttemptGeneration as number) + 1 ||
|
||||
nextTarget.installationId === previous.target.installationId ||
|
||||
nextTarget.lockDigest === previous.target.lockDigest
|
||||
) {
|
||||
return invalid(
|
||||
'next target is not the immediate durable attempt generation',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
previousActiveLockDigest,
|
||||
previousAttemptGeneration: previousAttemptGeneration as number,
|
||||
});
|
||||
}
|
||||
|
||||
function unsignedPlan(
|
||||
previousBinding: Readonly<PluginPackageSecretBinding>,
|
||||
previousActiveLockDigest: string,
|
||||
previousAttemptGeneration: number,
|
||||
nextTarget: Readonly<PluginPackageSecretBinding['target']>,
|
||||
nextBindingPlan: Readonly<PluginPackageSecretBindingPlan> | null,
|
||||
): Omit<PluginPackageSecretBindingTransitionPlan, 'transitionDigest'> {
|
||||
const changes = deriveChanges(
|
||||
previousBinding.entries,
|
||||
nextBindingPlan?.entries ?? [],
|
||||
);
|
||||
return Object.freeze({
|
||||
schema: PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_PLAN_SCHEMA,
|
||||
kind: deriveKind(changes),
|
||||
previousBinding,
|
||||
previousActiveLockDigest,
|
||||
previousAttemptGeneration,
|
||||
nextTarget,
|
||||
nextBindingPlan,
|
||||
changes,
|
||||
});
|
||||
}
|
||||
|
||||
function transitionDigest(
|
||||
value: Omit<PluginPackageSecretBindingTransitionPlan, 'transitionDigest'>,
|
||||
): string {
|
||||
return createHash('sha256')
|
||||
.update(TRANSITION_DIGEST_DOMAIN)
|
||||
.update(JSON.stringify(value), 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function withDigest(
|
||||
value: Omit<PluginPackageSecretBindingTransitionPlan, 'transitionDigest'>,
|
||||
): Readonly<PluginPackageSecretBindingTransitionPlan> {
|
||||
const result = Object.freeze({
|
||||
...value,
|
||||
transitionDigest: transitionDigest(value),
|
||||
});
|
||||
if (
|
||||
Buffer.byteLength(JSON.stringify(result), 'utf8') >
|
||||
MAX_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_PLAN_JSON_BYTES
|
||||
) {
|
||||
return invalid('durable JSON byte budget exceeded');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function createPluginPackageSecretBindingTransitionPlan(
|
||||
input: CreatePluginPackageSecretBindingTransitionPlanInput,
|
||||
): Readonly<PluginPackageSecretBindingTransitionPlan> {
|
||||
const inputValue = dataRecord(input, 'transition plan input');
|
||||
exactKeys(
|
||||
inputValue,
|
||||
[
|
||||
'assignments',
|
||||
'nextGeneration',
|
||||
'nextManifest',
|
||||
'plannedAtMs',
|
||||
'previousAttemptGeneration',
|
||||
'previousBinding',
|
||||
],
|
||||
'transition plan input',
|
||||
);
|
||||
const previousBinding = normalizePluginPackageSecretBinding(
|
||||
input.previousBinding,
|
||||
);
|
||||
const nextGeneration = normalizePluginPackageResourceGeneration(
|
||||
input.nextGeneration,
|
||||
);
|
||||
const nextManifest = normalizePluginPackageManifest(input.nextManifest);
|
||||
if (
|
||||
nextGeneration.previousActiveLockDigest !==
|
||||
previousBinding.target.lockDigest
|
||||
) {
|
||||
return invalid('next generation does not name the previous active lock');
|
||||
}
|
||||
const nextTarget = createPluginPackageSecretBindingTarget(
|
||||
nextGeneration,
|
||||
nextManifest,
|
||||
);
|
||||
const requirements = nextManifest.spec.permissions.secrets;
|
||||
if (requirements.length === 0 && input.assignments.length !== 0) {
|
||||
return invalid(
|
||||
'assignments must be empty when the next Manifest has no Secrets',
|
||||
);
|
||||
}
|
||||
const nextBindingPlan =
|
||||
requirements.length === 0
|
||||
? null
|
||||
: createPluginPackageSecretBindingPlan({
|
||||
generation: nextGeneration,
|
||||
manifest: nextManifest,
|
||||
assignments: input.assignments,
|
||||
plannedAtMs: input.plannedAtMs,
|
||||
});
|
||||
const lineage = assertLineage(
|
||||
previousBinding,
|
||||
nextTarget,
|
||||
nextGeneration.previousActiveLockDigest,
|
||||
input.previousAttemptGeneration,
|
||||
);
|
||||
return withDigest(
|
||||
unsignedPlan(
|
||||
previousBinding,
|
||||
lineage.previousActiveLockDigest,
|
||||
lineage.previousAttemptGeneration,
|
||||
nextTarget,
|
||||
nextBindingPlan,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizePluginPackageSecretBindingTransitionPlan(
|
||||
value: unknown,
|
||||
): Readonly<PluginPackageSecretBindingTransitionPlan> {
|
||||
const plan = dataRecord(value, 'transition plan');
|
||||
exactKeys(
|
||||
plan,
|
||||
[
|
||||
'changes',
|
||||
'kind',
|
||||
'nextBindingPlan',
|
||||
'nextTarget',
|
||||
'previousActiveLockDigest',
|
||||
'previousAttemptGeneration',
|
||||
'previousBinding',
|
||||
'schema',
|
||||
'transitionDigest',
|
||||
],
|
||||
'transition plan',
|
||||
);
|
||||
if (plan.schema !== PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_PLAN_SCHEMA) {
|
||||
return invalid('schema is unsupported');
|
||||
}
|
||||
const previousBinding = normalizePluginPackageSecretBinding(
|
||||
plan.previousBinding,
|
||||
);
|
||||
const nextTarget = normalizePluginPackageSecretBindingTarget(plan.nextTarget);
|
||||
const nextBindingPlan =
|
||||
plan.nextBindingPlan === null
|
||||
? null
|
||||
: normalizePluginPackageSecretBindingPlan(plan.nextBindingPlan);
|
||||
if (
|
||||
nextBindingPlan !== null &&
|
||||
JSON.stringify(nextBindingPlan.target) !== JSON.stringify(nextTarget)
|
||||
) {
|
||||
return invalid('next binding plan does not match the next target');
|
||||
}
|
||||
const lineage = assertLineage(
|
||||
previousBinding,
|
||||
nextTarget,
|
||||
plan.previousActiveLockDigest,
|
||||
plan.previousAttemptGeneration,
|
||||
);
|
||||
const unsigned = unsignedPlan(
|
||||
previousBinding,
|
||||
lineage.previousActiveLockDigest,
|
||||
lineage.previousAttemptGeneration,
|
||||
nextTarget,
|
||||
nextBindingPlan,
|
||||
);
|
||||
if (
|
||||
JSON.stringify(plan.changes) !== JSON.stringify(unsigned.changes) ||
|
||||
plan.kind !== unsigned.kind
|
||||
) {
|
||||
return invalid('derived transition classification does not match content');
|
||||
}
|
||||
if (
|
||||
typeof plan.transitionDigest !== 'string' ||
|
||||
!DIGEST.test(plan.transitionDigest) ||
|
||||
plan.transitionDigest !== transitionDigest(unsigned)
|
||||
) {
|
||||
return invalid('transition digest does not match content');
|
||||
}
|
||||
return withDigest(unsigned);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createPluginPackageSecretBinding,
|
||||
} = require('@qinglong/runtime-core/plugin-package-secret-binding');
|
||||
const {
|
||||
createPluginPackageSecretBindingTransitionPlan,
|
||||
normalizePluginPackageSecretBindingTransitionPlan,
|
||||
} = require('@qinglong/runtime-core/plugin-package-secret-binding-transition-plan');
|
||||
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: 'example-monitor',
|
||||
displayName: 'Example Monitor',
|
||||
version: '1.0.0',
|
||||
description: 'Secret transition fixture',
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
spec: {
|
||||
compatibility: {
|
||||
qinglong: '>=3.0.0-0 <4.0.0',
|
||||
architectures: ['arm64'],
|
||||
deploymentProfiles: ['edge'],
|
||||
},
|
||||
runtimes: [],
|
||||
resources: {
|
||||
memory: { recommended: '32Mi' },
|
||||
disk: { install: '4Mi', working: '8Mi' },
|
||||
},
|
||||
permissions: {
|
||||
network: { allowedHosts: [] },
|
||||
secrets,
|
||||
tools: ['secret.use'],
|
||||
},
|
||||
contents: { tasks: [], workflows: [], prompts: [], tools: [] },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function secret(name, version) {
|
||||
return createSecretRef({ projectId: 'project-1', name, version });
|
||||
}
|
||||
|
||||
const previousManifest = manifest([
|
||||
{ name: 'OPTIONAL_TOKEN', required: false },
|
||||
{ name: 'TOKEN', required: true },
|
||||
]);
|
||||
const previousGeneration = createPluginPackageResourceGeneration({
|
||||
installationId: 'install-1',
|
||||
projectId: 'project-1',
|
||||
packageName: 'example-monitor',
|
||||
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: 'OPTIONAL_TOKEN', secretRef: null },
|
||||
{ name: 'TOKEN', secretRef: secret('runtime-token', 2) },
|
||||
],
|
||||
authority: {
|
||||
kind: 'local-owner-confirmation',
|
||||
evidenceDigest: 'c'.repeat(64),
|
||||
},
|
||||
boundAtMs: 90,
|
||||
});
|
||||
|
||||
function nextGeneration(overrides = {}) {
|
||||
return createPluginPackageResourceGeneration({
|
||||
installationId: 'install-2',
|
||||
projectId: 'project-1',
|
||||
packageName: 'example-monitor',
|
||||
lockDigest: 'd'.repeat(64),
|
||||
generation: 2,
|
||||
previousActiveLockDigest: previousBinding.target.lockDigest,
|
||||
contentDigest: 'e'.repeat(64),
|
||||
contents: previousManifest.spec.contents,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function transition(assignments, overrides = {}) {
|
||||
return createPluginPackageSecretBindingTransitionPlan({
|
||||
previousBinding,
|
||||
previousAttemptGeneration: overrides.previousAttemptGeneration ?? 1,
|
||||
nextGeneration: nextGeneration(overrides.generation),
|
||||
nextManifest: overrides.manifest ?? previousManifest,
|
||||
assignments,
|
||||
plannedAtMs: 100,
|
||||
});
|
||||
}
|
||||
|
||||
test('derives an exact carry-forward for an unchanged next generation', () => {
|
||||
const value = transition([
|
||||
{ name: 'OPTIONAL_TOKEN', secretRef: null },
|
||||
{ name: 'TOKEN', secretRef: secret('runtime-token', 2) },
|
||||
]);
|
||||
assert.equal(value.kind, 'carry-forward');
|
||||
assert.equal(value.previousActiveLockDigest, 'a'.repeat(64));
|
||||
assert.deepEqual(
|
||||
value.changes.map(({ name, requirement, reference }) => ({
|
||||
name,
|
||||
requirement,
|
||||
reference,
|
||||
})),
|
||||
[
|
||||
{
|
||||
name: 'OPTIONAL_TOKEN',
|
||||
requirement: 'unchanged',
|
||||
reference: 'unchanged',
|
||||
},
|
||||
{
|
||||
name: 'TOKEN',
|
||||
requirement: 'unchanged',
|
||||
reference: 'unchanged',
|
||||
},
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizePluginPackageSecretBindingTransitionPlan(value),
|
||||
value,
|
||||
);
|
||||
});
|
||||
|
||||
test('distinguishes forward rotation from rebind and version rollback', () => {
|
||||
const rotated = transition([
|
||||
{ name: 'OPTIONAL_TOKEN', secretRef: null },
|
||||
{ name: 'TOKEN', secretRef: secret('runtime-token', 3) },
|
||||
]);
|
||||
assert.equal(rotated.kind, 'rotate');
|
||||
assert.equal(rotated.changes[1].reference, 'rotated');
|
||||
|
||||
const rebound = transition([
|
||||
{ name: 'OPTIONAL_TOKEN', secretRef: null },
|
||||
{ name: 'TOKEN', secretRef: secret('replacement-token', 1) },
|
||||
]);
|
||||
assert.equal(rebound.kind, 'rebind');
|
||||
assert.equal(rebound.changes[1].reference, 'rebound');
|
||||
|
||||
const rolledBack = transition([
|
||||
{ name: 'OPTIONAL_TOKEN', secretRef: null },
|
||||
{ name: 'TOKEN', secretRef: secret('runtime-token', 1) },
|
||||
]);
|
||||
assert.equal(rolledBack.kind, 'rebind');
|
||||
assert.equal(rolledBack.changes[1].reference, 'rebound');
|
||||
});
|
||||
|
||||
test('classifies removal or unbinding as revocation', () => {
|
||||
const nextManifest = manifest([{ name: 'OPTIONAL_TOKEN', required: false }]);
|
||||
const value = transition([{ name: 'OPTIONAL_TOKEN', secretRef: null }], {
|
||||
manifest: nextManifest,
|
||||
});
|
||||
assert.equal(value.kind, 'revoke');
|
||||
assert.deepEqual(value.changes[1], {
|
||||
name: 'TOKEN',
|
||||
requirement: 'removed',
|
||||
reference: 'revoked',
|
||||
previous: { required: true, secretRef: secret('runtime-token', 2) },
|
||||
next: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('represents final requirement removal without inventing an empty binding', () => {
|
||||
const value = transition([], { manifest: manifest([]) });
|
||||
assert.equal(value.kind, 'revoke');
|
||||
assert.equal(value.nextBindingPlan, null);
|
||||
assert.equal(value.nextTarget.generation, 2);
|
||||
assert.equal(value.changes.length, 2);
|
||||
});
|
||||
|
||||
test('treats requirement additions and optional binding as rebind', () => {
|
||||
const nextManifest = manifest([
|
||||
{ name: 'EXTRA_TOKEN', required: false },
|
||||
{ name: 'OPTIONAL_TOKEN', required: false },
|
||||
{ name: 'TOKEN', required: true },
|
||||
]);
|
||||
const value = transition(
|
||||
[
|
||||
{ name: 'EXTRA_TOKEN', secretRef: secret('extra-token', 1) },
|
||||
{ name: 'OPTIONAL_TOKEN', secretRef: null },
|
||||
{ name: 'TOKEN', secretRef: secret('runtime-token', 2) },
|
||||
],
|
||||
{ manifest: nextManifest },
|
||||
);
|
||||
assert.equal(value.kind, 'rebind');
|
||||
assert.equal(value.changes[0].requirement, 'added');
|
||||
assert.equal(value.changes[0].reference, 'bound');
|
||||
});
|
||||
|
||||
test('uses durable attempt generations and rejects skipped or detached targets', () => {
|
||||
const assignments = [
|
||||
{ name: 'OPTIONAL_TOKEN', secretRef: null },
|
||||
{ name: 'TOKEN', secretRef: secret('runtime-token', 2) },
|
||||
];
|
||||
assert.throws(
|
||||
() => transition(assignments, { generation: { generation: 3 } }),
|
||||
/immediate durable attempt generation/,
|
||||
);
|
||||
const retried = transition(assignments, {
|
||||
generation: { generation: 3 },
|
||||
previousAttemptGeneration: 2,
|
||||
});
|
||||
assert.equal(retried.nextTarget.generation, 3);
|
||||
assert.equal(retried.previousAttemptGeneration, 2);
|
||||
assert.throws(
|
||||
() =>
|
||||
transition(assignments, {
|
||||
generation: { previousActiveLockDigest: 'f'.repeat(64) },
|
||||
}),
|
||||
/does not name the previous active lock/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
transition(assignments, { generation: { installationId: 'install-1' } }),
|
||||
/immediate durable attempt generation/,
|
||||
);
|
||||
});
|
||||
|
||||
test('fails closed when classification, content or digest is rewritten', () => {
|
||||
const value = transition([
|
||||
{ name: 'OPTIONAL_TOKEN', secretRef: null },
|
||||
{ name: 'TOKEN', secretRef: secret('runtime-token', 3) },
|
||||
]);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePluginPackageSecretBindingTransitionPlan({
|
||||
...value,
|
||||
kind: 'carry-forward',
|
||||
}),
|
||||
/classification/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePluginPackageSecretBindingTransitionPlan({
|
||||
...value,
|
||||
changes: value.changes.slice(1),
|
||||
}),
|
||||
/classification/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePluginPackageSecretBindingTransitionPlan({
|
||||
...value,
|
||||
transitionDigest: 'f'.repeat(64),
|
||||
}),
|
||||
/digest/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects extensible or accessor-bearing creation input', () => {
|
||||
const assignments = [
|
||||
{ name: 'OPTIONAL_TOKEN', secretRef: null },
|
||||
{ name: 'TOKEN', secretRef: secret('runtime-token', 2) },
|
||||
];
|
||||
const input = {
|
||||
previousBinding,
|
||||
previousAttemptGeneration: 1,
|
||||
nextGeneration: nextGeneration(),
|
||||
nextManifest: previousManifest,
|
||||
assignments,
|
||||
plannedAtMs: 100,
|
||||
};
|
||||
assert.throws(
|
||||
() =>
|
||||
createPluginPackageSecretBindingTransitionPlan({ ...input, extra: true }),
|
||||
/shape is invalid/,
|
||||
);
|
||||
const accessor = { ...input };
|
||||
Object.defineProperty(accessor, 'plannedAtMs', {
|
||||
enumerable: true,
|
||||
get() {
|
||||
throw new Error('must not execute');
|
||||
},
|
||||
});
|
||||
assert.throws(
|
||||
() => createPluginPackageSecretBindingTransitionPlan(accessor),
|
||||
/data properties/,
|
||||
);
|
||||
});
|
||||
|
||||
test('exports the transition contract only through its explicit subpath', () => {
|
||||
assert.equal(
|
||||
require('../dist').createPluginPackageSecretBindingTransitionPlan,
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
require('@qinglong/runtime-core/plugin-package-secret-binding-transition-plan')
|
||||
.createPluginPackageSecretBindingTransitionPlan,
|
||||
createPluginPackageSecretBindingTransitionPlan,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user