mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): bind package secrets to generations
This commit is contained in:
@@ -83,6 +83,9 @@
|
||||
"plugin-package-resource-materialization": [
|
||||
"dist/plugin-package/pluginPackageResourceMaterialization.d.ts"
|
||||
],
|
||||
"plugin-package-secret-binding": [
|
||||
"dist/plugin-package/pluginPackageSecretBinding.d.ts"
|
||||
],
|
||||
"plugin-package-task-reconciliation": [
|
||||
"dist/plugin-package/pluginPackageTaskReconciliation.d.ts"
|
||||
],
|
||||
@@ -371,6 +374,11 @@
|
||||
"require": "./dist/plugin-package/pluginPackageResourceMaterialization.js",
|
||||
"default": "./dist/plugin-package/pluginPackageResourceMaterialization.js"
|
||||
},
|
||||
"./plugin-package-secret-binding": {
|
||||
"types": "./dist/plugin-package/pluginPackageSecretBinding.d.ts",
|
||||
"require": "./dist/plugin-package/pluginPackageSecretBinding.js",
|
||||
"default": "./dist/plugin-package/pluginPackageSecretBinding.js"
|
||||
},
|
||||
"./plugin-package-task-reconciliation": {
|
||||
"types": "./dist/plugin-package/pluginPackageTaskReconciliation.d.ts",
|
||||
"require": "./dist/plugin-package/pluginPackageTaskReconciliation.js",
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import {
|
||||
MAX_PLUGIN_PACKAGE_SECRETS,
|
||||
normalizePluginPackageManifest,
|
||||
type PluginPackageManifest,
|
||||
type PluginPackageSecretRequirement,
|
||||
} from './pluginPackage';
|
||||
import { pluginPackageManifestDigest } from './installation/pluginPackageInstall';
|
||||
import {
|
||||
normalizePluginPackageResourceGeneration,
|
||||
type PluginPackageResourceGeneration,
|
||||
} from './pluginPackageResourceGeneration';
|
||||
import { parseSecretRef } from '../secret/secretReference';
|
||||
|
||||
export const PLUGIN_PACKAGE_SECRET_BINDING_SCHEMA =
|
||||
'qinglong/plugin-package-secret-binding@v1' as const;
|
||||
export const PLUGIN_PACKAGE_SECRET_BINDING_AUTHORITY_KINDS = [
|
||||
'approved-action-execution',
|
||||
'local-owner-confirmation',
|
||||
] as const;
|
||||
export const MAX_PLUGIN_PACKAGE_SECRET_BINDING_JSON_BYTES = 64 * 1024;
|
||||
|
||||
export type PluginPackageSecretBindingAuthorityKind =
|
||||
(typeof PLUGIN_PACKAGE_SECRET_BINDING_AUTHORITY_KINDS)[number];
|
||||
|
||||
export interface PluginPackageSecretBindingTarget {
|
||||
readonly installationId: string;
|
||||
readonly projectId: string;
|
||||
readonly packageName: string;
|
||||
readonly lockDigest: string;
|
||||
readonly generation: number;
|
||||
readonly generationDigest: string;
|
||||
readonly manifestDigest: string;
|
||||
}
|
||||
|
||||
export interface PluginPackageSecretBindingEntry {
|
||||
readonly name: string;
|
||||
readonly required: boolean;
|
||||
readonly secretRef: string | null;
|
||||
}
|
||||
|
||||
export interface PluginPackageSecretBindingAuthority {
|
||||
readonly kind: PluginPackageSecretBindingAuthorityKind;
|
||||
readonly evidenceDigest: string;
|
||||
}
|
||||
|
||||
export interface PluginPackageSecretBinding {
|
||||
readonly schema: typeof PLUGIN_PACKAGE_SECRET_BINDING_SCHEMA;
|
||||
readonly target: Readonly<PluginPackageSecretBindingTarget>;
|
||||
readonly entries: readonly Readonly<PluginPackageSecretBindingEntry>[];
|
||||
readonly authority: Readonly<PluginPackageSecretBindingAuthority>;
|
||||
readonly boundAtMs: number;
|
||||
readonly bindingDigest: string;
|
||||
}
|
||||
|
||||
export interface PluginPackageSecretBindingAssignment {
|
||||
readonly name: string;
|
||||
readonly secretRef: string | null;
|
||||
}
|
||||
|
||||
export interface CreatePluginPackageSecretBindingInput {
|
||||
readonly generation: Readonly<PluginPackageResourceGeneration>;
|
||||
readonly manifest: Readonly<PluginPackageManifest>;
|
||||
readonly assignments: readonly Readonly<PluginPackageSecretBindingAssignment>[];
|
||||
readonly authority: Readonly<PluginPackageSecretBindingAuthority>;
|
||||
readonly boundAtMs: number;
|
||||
}
|
||||
|
||||
export interface PluginPackageSecretBindingRepository {
|
||||
find(
|
||||
generationDigest: string,
|
||||
): Promise<Readonly<PluginPackageSecretBinding> | null>;
|
||||
publish(binding: Readonly<PluginPackageSecretBinding>): Promise<
|
||||
Readonly<{
|
||||
status: 'created' | 'existing';
|
||||
binding: Readonly<PluginPackageSecretBinding>;
|
||||
}>
|
||||
>;
|
||||
}
|
||||
|
||||
export class InvalidPluginPackageSecretBindingError extends TypeError {
|
||||
readonly code = 'PLUGIN_PACKAGE_SECRET_BINDING_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Plugin Package Secret binding is invalid: ${message}`);
|
||||
this.name = 'InvalidPluginPackageSecretBindingError';
|
||||
}
|
||||
}
|
||||
|
||||
export class PluginPackageSecretBindingConflictError extends Error {
|
||||
readonly code = 'PLUGIN_PACKAGE_SECRET_BINDING_CONFLICT';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Plugin Package Secret binding conflicts with state: ${message}`);
|
||||
this.name = 'PluginPackageSecretBindingConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class PluginPackageSecretBindingUnavailableError extends Error {
|
||||
readonly code = 'PLUGIN_PACKAGE_SECRET_BINDING_UNAVAILABLE';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super('Plugin Package Secret binding is unavailable', options);
|
||||
this.name = 'PluginPackageSecretBindingUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const PACKAGE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
const SECRET_NAME = /^[A-Z_][A-Z0-9_]{0,127}$/;
|
||||
const BINDING_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/plugin-package-secret-binding-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new InvalidPluginPackageSecretBindingError(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 stringKeys = actual.filter(
|
||||
(key): key is string => typeof key === 'string',
|
||||
);
|
||||
const canonical = [...expected].sort();
|
||||
if (
|
||||
actual.length !== canonical.length ||
|
||||
stringKeys.length !== canonical.length ||
|
||||
stringKeys.sort().some((key, index) => key !== canonical[index])
|
||||
) {
|
||||
invalid(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function denseArray(value: unknown, label: string): readonly unknown[] {
|
||||
if (!Array.isArray(value) || value.length > MAX_PLUGIN_PACKAGE_SECRETS) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
const keys = Object.keys(value);
|
||||
const ownKeys = Reflect.ownKeys(value);
|
||||
if (
|
||||
ownKeys.length !== value.length + 1 ||
|
||||
!ownKeys.includes('length') ||
|
||||
keys.length !== value.length ||
|
||||
keys.some((key, index) => key !== String(index))
|
||||
) {
|
||||
return invalid(`${label} must be a dense data array`);
|
||||
}
|
||||
const descriptors = Object.getOwnPropertyDescriptors(value);
|
||||
if (
|
||||
keys.some((key) => {
|
||||
const descriptor = descriptors[key];
|
||||
return (
|
||||
descriptor === undefined ||
|
||||
descriptor.get !== undefined ||
|
||||
descriptor.set !== undefined ||
|
||||
descriptor.enumerable !== true
|
||||
);
|
||||
})
|
||||
) {
|
||||
return invalid(`${label} must be a dense data array`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function digest(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !DIGEST.test(value)) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function identifier(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTIFIER.test(value)) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function packageName(value: unknown): string {
|
||||
if (typeof value !== 'string' || !PACKAGE_NAME.test(value)) {
|
||||
return invalid('Package name is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function generation(value: unknown): number {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
(value as number) < 1 ||
|
||||
(value as number) > 2_147_483_647
|
||||
) {
|
||||
return invalid('generation is invalid');
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function timestamp(value: unknown): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
return invalid('boundAtMs is invalid');
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function secretName(value: unknown): string {
|
||||
if (typeof value !== 'string' || !SECRET_NAME.test(value)) {
|
||||
return invalid('Secret requirement name is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeAuthority(
|
||||
value: unknown,
|
||||
): Readonly<PluginPackageSecretBindingAuthority> {
|
||||
const authority = dataRecord(value, 'authority');
|
||||
exactKeys(authority, ['evidenceDigest', 'kind'], 'authority');
|
||||
if (
|
||||
!PLUGIN_PACKAGE_SECRET_BINDING_AUTHORITY_KINDS.includes(
|
||||
authority.kind as PluginPackageSecretBindingAuthorityKind,
|
||||
)
|
||||
) {
|
||||
return invalid('authority kind is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
kind: authority.kind as PluginPackageSecretBindingAuthorityKind,
|
||||
evidenceDigest: digest(
|
||||
authority.evidenceDigest,
|
||||
'authority evidence digest',
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeEntries(
|
||||
value: unknown,
|
||||
projectId: string,
|
||||
): readonly Readonly<PluginPackageSecretBindingEntry>[] {
|
||||
const values = denseArray(value, 'entries');
|
||||
if (values.length === 0) return invalid('entries must not be empty');
|
||||
const seen = new Set<string>();
|
||||
const entries = values.map((entryValue) => {
|
||||
const entry = dataRecord(entryValue, 'entry');
|
||||
exactKeys(entry, ['name', 'required', 'secretRef'], 'entry');
|
||||
const name = secretName(entry.name);
|
||||
if (seen.has(name)) return invalid('Secret requirement is duplicated');
|
||||
seen.add(name);
|
||||
if (typeof entry.required !== 'boolean') {
|
||||
return invalid('Secret requirement required flag is invalid');
|
||||
}
|
||||
if (entry.secretRef === null) {
|
||||
if (entry.required) return invalid(`required Secret ${name} is unbound`);
|
||||
return Object.freeze({ name, required: false, secretRef: null });
|
||||
}
|
||||
let reference;
|
||||
try {
|
||||
reference = parseSecretRef(entry.secretRef);
|
||||
} catch {
|
||||
return invalid(`Secret ${name} reference is invalid`);
|
||||
}
|
||||
if (reference.projectId !== projectId) {
|
||||
return invalid(`Secret ${name} reference crosses Project boundary`);
|
||||
}
|
||||
if (reference.version === undefined) {
|
||||
return invalid(`Secret ${name} reference must pin an explicit version`);
|
||||
}
|
||||
return Object.freeze({
|
||||
name,
|
||||
required: entry.required,
|
||||
secretRef: entry.secretRef as string,
|
||||
});
|
||||
});
|
||||
const sorted = [...entries].sort((left, right) =>
|
||||
left.name.localeCompare(right.name),
|
||||
);
|
||||
if (entries.some((entry, index) => entry !== sorted[index])) {
|
||||
return invalid('entries are not in canonical order');
|
||||
}
|
||||
return Object.freeze(entries);
|
||||
}
|
||||
|
||||
function targetFromGeneration(
|
||||
generation: Readonly<PluginPackageResourceGeneration>,
|
||||
manifestDigest: string,
|
||||
): Readonly<PluginPackageSecretBindingTarget> {
|
||||
return Object.freeze({
|
||||
installationId: generation.installationId,
|
||||
projectId: generation.projectId,
|
||||
packageName: generation.packageName,
|
||||
lockDigest: generation.lockDigest,
|
||||
generation: generation.generation,
|
||||
generationDigest: generation.generationDigest,
|
||||
manifestDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function unsignedBinding(
|
||||
target: Readonly<PluginPackageSecretBindingTarget>,
|
||||
entries: readonly Readonly<PluginPackageSecretBindingEntry>[],
|
||||
authority: Readonly<PluginPackageSecretBindingAuthority>,
|
||||
boundAtMs: number,
|
||||
): Omit<PluginPackageSecretBinding, 'bindingDigest'> {
|
||||
return {
|
||||
schema: PLUGIN_PACKAGE_SECRET_BINDING_SCHEMA,
|
||||
target,
|
||||
entries,
|
||||
authority,
|
||||
boundAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
function bindingDigest(
|
||||
value: Omit<PluginPackageSecretBinding, 'bindingDigest'>,
|
||||
): string {
|
||||
return createHash('sha256')
|
||||
.update(BINDING_DIGEST_DOMAIN)
|
||||
.update(JSON.stringify(value), 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function entriesFromAssignments(
|
||||
requirements: readonly Readonly<PluginPackageSecretRequirement>[],
|
||||
assignmentsValue: unknown,
|
||||
projectId: string,
|
||||
): readonly Readonly<PluginPackageSecretBindingEntry>[] {
|
||||
if (requirements.length === 0) {
|
||||
return invalid('Manifest does not declare Secret requirements');
|
||||
}
|
||||
const assignments = denseArray(assignmentsValue, 'assignments');
|
||||
const mapped = new Map<string, string | null>();
|
||||
for (const assignmentValue of assignments) {
|
||||
const assignment = dataRecord(assignmentValue, 'assignment');
|
||||
exactKeys(assignment, ['name', 'secretRef'], 'assignment');
|
||||
const name = secretName(assignment.name);
|
||||
if (mapped.has(name)) return invalid('Secret assignment is duplicated');
|
||||
if (
|
||||
assignment.secretRef !== null &&
|
||||
typeof assignment.secretRef !== 'string'
|
||||
) {
|
||||
return invalid(`Secret ${name} assignment is invalid`);
|
||||
}
|
||||
mapped.set(name, assignment.secretRef as string | null);
|
||||
}
|
||||
if (
|
||||
mapped.size !== requirements.length ||
|
||||
requirements.some((requirement) => !mapped.has(requirement.name))
|
||||
) {
|
||||
return invalid('assignments do not exactly match Manifest requirements');
|
||||
}
|
||||
return normalizeEntries(
|
||||
requirements.map((requirement) => ({
|
||||
name: requirement.name,
|
||||
required: requirement.required,
|
||||
secretRef: mapped.get(requirement.name) ?? null,
|
||||
})),
|
||||
projectId,
|
||||
);
|
||||
}
|
||||
|
||||
export function createPluginPackageSecretBinding(
|
||||
input: CreatePluginPackageSecretBindingInput,
|
||||
): Readonly<PluginPackageSecretBinding> {
|
||||
const generation = normalizePluginPackageResourceGeneration(input.generation);
|
||||
const manifest = normalizePluginPackageManifest(input.manifest);
|
||||
if (manifest.metadata.name !== generation.packageName) {
|
||||
return invalid('Manifest Package does not match generation');
|
||||
}
|
||||
const target = targetFromGeneration(
|
||||
generation,
|
||||
pluginPackageManifestDigest(manifest),
|
||||
);
|
||||
const entries = entriesFromAssignments(
|
||||
manifest.spec.permissions.secrets,
|
||||
input.assignments,
|
||||
generation.projectId,
|
||||
);
|
||||
const authority = normalizeAuthority(input.authority);
|
||||
const boundAtMs = timestamp(input.boundAtMs);
|
||||
const unsigned = unsignedBinding(target, entries, authority, boundAtMs);
|
||||
return Object.freeze({ ...unsigned, bindingDigest: bindingDigest(unsigned) });
|
||||
}
|
||||
|
||||
export function normalizePluginPackageSecretBinding(
|
||||
value: unknown,
|
||||
): Readonly<PluginPackageSecretBinding> {
|
||||
const binding = dataRecord(value, 'binding');
|
||||
exactKeys(
|
||||
binding,
|
||||
['authority', 'bindingDigest', 'boundAtMs', 'entries', 'schema', 'target'],
|
||||
'binding',
|
||||
);
|
||||
if (binding.schema !== PLUGIN_PACKAGE_SECRET_BINDING_SCHEMA) {
|
||||
return invalid('schema is unsupported');
|
||||
}
|
||||
const targetValue = dataRecord(binding.target, 'target');
|
||||
exactKeys(
|
||||
targetValue,
|
||||
[
|
||||
'generation',
|
||||
'generationDigest',
|
||||
'installationId',
|
||||
'lockDigest',
|
||||
'manifestDigest',
|
||||
'packageName',
|
||||
'projectId',
|
||||
],
|
||||
'target',
|
||||
);
|
||||
const target = Object.freeze({
|
||||
installationId: identifier(targetValue.installationId, 'installation ID'),
|
||||
projectId: identifier(targetValue.projectId, 'Project ID'),
|
||||
packageName: packageName(targetValue.packageName),
|
||||
lockDigest: digest(targetValue.lockDigest, 'lock digest'),
|
||||
generation: generation(targetValue.generation),
|
||||
generationDigest: digest(targetValue.generationDigest, 'generation digest'),
|
||||
manifestDigest: digest(targetValue.manifestDigest, 'Manifest digest'),
|
||||
});
|
||||
const entries = normalizeEntries(binding.entries, target.projectId);
|
||||
const authority = normalizeAuthority(binding.authority);
|
||||
const boundAtMs = timestamp(binding.boundAtMs);
|
||||
const unsigned = unsignedBinding(target, entries, authority, boundAtMs);
|
||||
if (
|
||||
digest(binding.bindingDigest, 'binding digest') !== bindingDigest(unsigned)
|
||||
) {
|
||||
return invalid('binding digest does not match content');
|
||||
}
|
||||
const normalized = Object.freeze({
|
||||
...unsigned,
|
||||
bindingDigest: binding.bindingDigest as string,
|
||||
});
|
||||
if (
|
||||
Buffer.byteLength(JSON.stringify(normalized), 'utf8') >
|
||||
MAX_PLUGIN_PACKAGE_SECRET_BINDING_JSON_BYTES
|
||||
) {
|
||||
return invalid('durable JSON byte budget exceeded');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function assertPluginPackageSecretBindingMatches(
|
||||
value: unknown,
|
||||
generationValue: Readonly<PluginPackageResourceGeneration>,
|
||||
manifestValue: Readonly<PluginPackageManifest>,
|
||||
): Readonly<PluginPackageSecretBinding> {
|
||||
const binding = normalizePluginPackageSecretBinding(value);
|
||||
const generation = normalizePluginPackageResourceGeneration(generationValue);
|
||||
const manifest = normalizePluginPackageManifest(manifestValue);
|
||||
const expectedTarget = targetFromGeneration(
|
||||
generation,
|
||||
pluginPackageManifestDigest(manifest),
|
||||
);
|
||||
if (JSON.stringify(binding.target) !== JSON.stringify(expectedTarget)) {
|
||||
return invalid('target does not match generation and Manifest');
|
||||
}
|
||||
if (
|
||||
binding.entries.length !== manifest.spec.permissions.secrets.length ||
|
||||
binding.entries.some((entry, index) => {
|
||||
const requirement = manifest.spec.permissions.secrets[index];
|
||||
return (
|
||||
requirement === undefined ||
|
||||
entry.name !== requirement.name ||
|
||||
entry.required !== requirement.required
|
||||
);
|
||||
})
|
||||
) {
|
||||
return invalid('entries do not exactly match Manifest requirements');
|
||||
}
|
||||
return binding;
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
InvalidPluginPackageSecretBindingError,
|
||||
PLUGIN_PACKAGE_SECRET_BINDING_SCHEMA,
|
||||
assertPluginPackageSecretBindingMatches,
|
||||
createPluginPackageSecretBinding,
|
||||
normalizePluginPackageSecretBinding,
|
||||
} = require('../dist/plugin-package/pluginPackageSecretBinding');
|
||||
const {
|
||||
createPluginPackageResourceGeneration,
|
||||
} = require('../dist/plugin-package/pluginPackageResourceGeneration');
|
||||
const { createSecretRef } = require('../dist/secret/secretReference');
|
||||
const {
|
||||
PLUGIN_PACKAGE_API_VERSION,
|
||||
PLUGIN_PACKAGE_KIND,
|
||||
} = require('../dist/plugin-package/pluginPackage');
|
||||
|
||||
const LOCK_DIGEST = 'a'.repeat(64);
|
||||
const EVIDENCE_DIGEST = 'b'.repeat(64);
|
||||
|
||||
function manifest(
|
||||
secretRequirements = [
|
||||
{ name: 'OPTIONAL_TOKEN', required: false },
|
||||
{ name: 'REQUIRED_TOKEN', required: true },
|
||||
],
|
||||
) {
|
||||
return {
|
||||
apiVersion: PLUGIN_PACKAGE_API_VERSION,
|
||||
kind: PLUGIN_PACKAGE_KIND,
|
||||
metadata: {
|
||||
name: 'example-monitor',
|
||||
displayName: 'Example Monitor',
|
||||
version: '1.2.0',
|
||||
description: 'Tests durable Secret binding',
|
||||
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: secretRequirements,
|
||||
tools: [],
|
||||
},
|
||||
contents: { tasks: [], workflows: [], prompts: [], tools: [] },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function generation(projectId = 'project-1') {
|
||||
return createPluginPackageResourceGeneration({
|
||||
installationId: 'install-1',
|
||||
projectId,
|
||||
packageName: 'example-monitor',
|
||||
lockDigest: LOCK_DIGEST,
|
||||
generation: 1,
|
||||
previousActiveLockDigest: null,
|
||||
contentDigest: 'c'.repeat(64),
|
||||
contents: { tasks: [], workflows: [], prompts: [], tools: [] },
|
||||
});
|
||||
}
|
||||
|
||||
function versionedRef(projectId, name, version = 3) {
|
||||
return createSecretRef({ projectId, name, version });
|
||||
}
|
||||
|
||||
function binding(overrides = {}) {
|
||||
return createPluginPackageSecretBinding({
|
||||
generation: generation(),
|
||||
manifest: manifest(),
|
||||
assignments: [
|
||||
{ name: 'OPTIONAL_TOKEN', secretRef: null },
|
||||
{
|
||||
name: 'REQUIRED_TOKEN',
|
||||
secretRef: versionedRef('project-1', 'runtime-token'),
|
||||
},
|
||||
],
|
||||
authority: {
|
||||
kind: 'local-owner-confirmation',
|
||||
evidenceDigest: EVIDENCE_DIGEST,
|
||||
},
|
||||
boundAtMs: 1_700_000_000_000,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test('creates one canonical generation-bound Secret binding without plaintext', () => {
|
||||
const value = binding();
|
||||
assert.equal(value.schema, PLUGIN_PACKAGE_SECRET_BINDING_SCHEMA);
|
||||
assert.deepEqual(value.entries, [
|
||||
{ name: 'OPTIONAL_TOKEN', required: false, secretRef: null },
|
||||
{
|
||||
name: 'REQUIRED_TOKEN',
|
||||
required: true,
|
||||
secretRef: versionedRef('project-1', 'runtime-token'),
|
||||
},
|
||||
]);
|
||||
assert.match(value.bindingDigest, /^[0-9a-f]{64}$/);
|
||||
assert.equal(JSON.stringify(value).includes('secret-value'), false);
|
||||
assert.deepEqual(normalizePluginPackageSecretBinding(value), value);
|
||||
assert.equal(Object.isFrozen(value), true);
|
||||
assert.equal(Object.isFrozen(value.entries), true);
|
||||
});
|
||||
|
||||
test('requires assignments to exactly cover the Manifest contract', () => {
|
||||
assert.throws(
|
||||
() => binding({ assignments: [] }),
|
||||
/exactly match Manifest requirements/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
binding({
|
||||
assignments: [
|
||||
{ name: 'OPTIONAL_TOKEN', secretRef: null },
|
||||
{ name: 'UNDECLARED_TOKEN', secretRef: null },
|
||||
],
|
||||
}),
|
||||
InvalidPluginPackageSecretBindingError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
binding({
|
||||
assignments: [
|
||||
{ name: 'OPTIONAL_TOKEN', secretRef: null },
|
||||
{ name: 'REQUIRED_TOKEN', secretRef: null },
|
||||
],
|
||||
}),
|
||||
/required Secret REQUIRED_TOKEN is unbound/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects floating and cross-Project Secret references', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
binding({
|
||||
assignments: [
|
||||
{ name: 'OPTIONAL_TOKEN', secretRef: null },
|
||||
{
|
||||
name: 'REQUIRED_TOKEN',
|
||||
secretRef: createSecretRef({
|
||||
projectId: 'project-1',
|
||||
name: 'runtime-token',
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
/must pin an explicit version/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
binding({
|
||||
assignments: [
|
||||
{ name: 'OPTIONAL_TOKEN', secretRef: null },
|
||||
{
|
||||
name: 'REQUIRED_TOKEN',
|
||||
secretRef: versionedRef('project-2', 'runtime-token'),
|
||||
},
|
||||
],
|
||||
}),
|
||||
/crosses Project boundary/,
|
||||
);
|
||||
});
|
||||
|
||||
test('fails closed on authority, target, order and digest drift', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
binding({
|
||||
authority: { kind: 'operator', evidenceDigest: EVIDENCE_DIGEST },
|
||||
}),
|
||||
/authority kind is invalid/,
|
||||
);
|
||||
const value = binding();
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePluginPackageSecretBinding({
|
||||
...value,
|
||||
entries: [...value.entries].reverse(),
|
||||
}),
|
||||
/canonical order/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePluginPackageSecretBinding({
|
||||
...value,
|
||||
target: { ...value.target, generationDigest: 'd'.repeat(64) },
|
||||
}),
|
||||
/binding digest does not match/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizePluginPackageSecretBinding({ ...value, extra: true }),
|
||||
/binding shape is invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test('revalidates durable binding against the complete generation and Manifest', () => {
|
||||
const value = binding();
|
||||
assert.deepEqual(
|
||||
assertPluginPackageSecretBindingMatches(value, generation(), manifest()),
|
||||
value,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
assertPluginPackageSecretBindingMatches(
|
||||
value,
|
||||
generation('project-2'),
|
||||
manifest(),
|
||||
),
|
||||
/target does not match/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
assertPluginPackageSecretBindingMatches(
|
||||
value,
|
||||
generation(),
|
||||
manifest([{ name: 'REQUIRED_TOKEN', required: true }]),
|
||||
),
|
||||
/target does not match|entries do not exactly match/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a binding when the Package declares no Secret requirement', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
binding({
|
||||
manifest: manifest([]),
|
||||
assignments: [],
|
||||
}),
|
||||
/does not declare Secret requirements/,
|
||||
);
|
||||
});
|
||||
|
||||
test('exports Secret binding only through its explicit subpath', () => {
|
||||
assert.equal(require('../dist').createPluginPackageSecretBinding, undefined);
|
||||
assert.equal(
|
||||
require('@qinglong/runtime-core/plugin-package-secret-binding')
|
||||
.createPluginPackageSecretBinding,
|
||||
createPluginPackageSecretBinding,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user