mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): add owner-confirmed package secret binding
This commit is contained in:
@@ -84,7 +84,10 @@
|
||||
"dist/plugin-package/pluginPackageResourceMaterialization.d.ts"
|
||||
],
|
||||
"plugin-package-secret-binding": [
|
||||
"dist/plugin-package/pluginPackageSecretBinding.d.ts"
|
||||
"dist/plugin-package/secret-binding/binding.d.ts"
|
||||
],
|
||||
"plugin-package-secret-binding-plan": [
|
||||
"dist/plugin-package/secret-binding/plan.d.ts"
|
||||
],
|
||||
"plugin-package-task-reconciliation": [
|
||||
"dist/plugin-package/pluginPackageTaskReconciliation.d.ts"
|
||||
@@ -375,9 +378,14 @@
|
||||
"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"
|
||||
"types": "./dist/plugin-package/secret-binding/binding.d.ts",
|
||||
"require": "./dist/plugin-package/secret-binding/binding.js",
|
||||
"default": "./dist/plugin-package/secret-binding/binding.js"
|
||||
},
|
||||
"./plugin-package-secret-binding-plan": {
|
||||
"types": "./dist/plugin-package/secret-binding/plan.d.ts",
|
||||
"require": "./dist/plugin-package/secret-binding/plan.js",
|
||||
"default": "./dist/plugin-package/secret-binding/plan.js"
|
||||
},
|
||||
"./plugin-package-task-reconciliation": {
|
||||
"types": "./dist/plugin-package/pluginPackageTaskReconciliation.d.ts",
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ import {
|
||||
assertPluginPackageSecretBindingMatches,
|
||||
type PluginPackageSecretBinding,
|
||||
type PluginPackageSecretBindingRepository,
|
||||
} from './pluginPackageSecretBinding';
|
||||
} from './secret-binding/binding';
|
||||
import {
|
||||
normalizeTaskDefinitionLabels,
|
||||
normalizeTaskDefinitionSpec,
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type PluginPackageResourceLockSource,
|
||||
} from './pluginPackageResourceMaterialization';
|
||||
import type { PluginPackageResourceGenerationSource } from './pluginPackageResourceGeneration';
|
||||
import type { PluginPackageSecretBindingRepository } from './pluginPackageSecretBinding';
|
||||
import type { PluginPackageSecretBindingRepository } from './secret-binding/binding';
|
||||
import {
|
||||
InvalidPluginPackageTaskReconciliationError,
|
||||
PluginPackageTaskReconciliationConflictError,
|
||||
|
||||
+44
-17
@@ -5,13 +5,13 @@ import {
|
||||
normalizePluginPackageManifest,
|
||||
type PluginPackageManifest,
|
||||
type PluginPackageSecretRequirement,
|
||||
} from './pluginPackage';
|
||||
import { pluginPackageManifestDigest } from './installation/pluginPackageInstall';
|
||||
} from '../pluginPackage';
|
||||
import { pluginPackageManifestDigest } from '../installation/pluginPackageInstall';
|
||||
import {
|
||||
normalizePluginPackageResourceGeneration,
|
||||
type PluginPackageResourceGeneration,
|
||||
} from './pluginPackageResourceGeneration';
|
||||
import { parseSecretRef } from '../secret/secretReference';
|
||||
} from '../pluginPackageResourceGeneration';
|
||||
import { parseSecretRef } from '../../secret/secretReference';
|
||||
|
||||
export const PLUGIN_PACKAGE_SECRET_BINDING_SCHEMA =
|
||||
'qinglong/plugin-package-secret-binding@v1' as const;
|
||||
@@ -67,6 +67,13 @@ export interface CreatePluginPackageSecretBindingInput {
|
||||
readonly boundAtMs: number;
|
||||
}
|
||||
|
||||
export interface CreatePluginPackageSecretBindingFromEntriesInput {
|
||||
readonly target: Readonly<PluginPackageSecretBindingTarget>;
|
||||
readonly entries: readonly Readonly<PluginPackageSecretBindingEntry>[];
|
||||
readonly authority: Readonly<PluginPackageSecretBindingAuthority>;
|
||||
readonly boundAtMs: number;
|
||||
}
|
||||
|
||||
export interface PluginPackageSecretBindingRepository {
|
||||
find(
|
||||
generationDigest: string,
|
||||
@@ -408,19 +415,24 @@ export function createPluginPackageSecretBinding(
|
||||
return Object.freeze({ ...unsigned, bindingDigest: bindingDigest(unsigned) });
|
||||
}
|
||||
|
||||
export function normalizePluginPackageSecretBinding(
|
||||
value: unknown,
|
||||
export function createPluginPackageSecretBindingFromEntries(
|
||||
input: CreatePluginPackageSecretBindingFromEntriesInput,
|
||||
): 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');
|
||||
const target = normalizeTarget(input.target);
|
||||
const entries = normalizeEntries(input.entries, target.projectId);
|
||||
const authority = normalizeAuthority(input.authority);
|
||||
const boundAtMs = timestamp(input.boundAtMs);
|
||||
const unsigned = unsignedBinding(target, entries, authority, boundAtMs);
|
||||
return normalizePluginPackageSecretBinding({
|
||||
...unsigned,
|
||||
bindingDigest: bindingDigest(unsigned),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeTarget(
|
||||
value: unknown,
|
||||
): Readonly<PluginPackageSecretBindingTarget> {
|
||||
const targetValue = dataRecord(value, 'target');
|
||||
exactKeys(
|
||||
targetValue,
|
||||
[
|
||||
@@ -434,7 +446,7 @@ export function normalizePluginPackageSecretBinding(
|
||||
],
|
||||
'target',
|
||||
);
|
||||
const target = Object.freeze({
|
||||
return Object.freeze({
|
||||
installationId: identifier(targetValue.installationId, 'installation ID'),
|
||||
projectId: identifier(targetValue.projectId, 'Project ID'),
|
||||
packageName: packageName(targetValue.packageName),
|
||||
@@ -443,6 +455,21 @@ export function normalizePluginPackageSecretBinding(
|
||||
generationDigest: digest(targetValue.generationDigest, 'generation digest'),
|
||||
manifestDigest: digest(targetValue.manifestDigest, 'Manifest digest'),
|
||||
});
|
||||
}
|
||||
|
||||
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 target = normalizeTarget(binding.target);
|
||||
const entries = normalizeEntries(binding.entries, target.projectId);
|
||||
const authority = normalizeAuthority(binding.authority);
|
||||
const boundAtMs = timestamp(binding.boundAtMs);
|
||||
@@ -0,0 +1,168 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import {
|
||||
createPluginPackageSecretBinding,
|
||||
createPluginPackageSecretBindingFromEntries,
|
||||
type PluginPackageSecretBinding,
|
||||
type PluginPackageSecretBindingAssignment,
|
||||
type PluginPackageSecretBindingEntry,
|
||||
type PluginPackageSecretBindingTarget,
|
||||
} from './binding';
|
||||
import type { PluginPackageManifest } from '../pluginPackage';
|
||||
import type { PluginPackageResourceGeneration } from '../pluginPackageResourceGeneration';
|
||||
|
||||
export const PLUGIN_PACKAGE_SECRET_BINDING_PLAN_SCHEMA =
|
||||
'qinglong/plugin-package-secret-binding-plan@v1' as const;
|
||||
export const MAX_PLUGIN_PACKAGE_SECRET_BINDING_PLAN_JSON_BYTES = 64 * 1024;
|
||||
|
||||
export interface PluginPackageSecretBindingPlan {
|
||||
readonly schema: typeof PLUGIN_PACKAGE_SECRET_BINDING_PLAN_SCHEMA;
|
||||
readonly target: Readonly<PluginPackageSecretBindingTarget>;
|
||||
readonly entries: readonly Readonly<PluginPackageSecretBindingEntry>[];
|
||||
readonly plannedAtMs: number;
|
||||
readonly planDigest: string;
|
||||
}
|
||||
|
||||
export interface CreatePluginPackageSecretBindingPlanInput {
|
||||
readonly generation: Readonly<PluginPackageResourceGeneration>;
|
||||
readonly manifest: Readonly<PluginPackageManifest>;
|
||||
readonly assignments: readonly Readonly<PluginPackageSecretBindingAssignment>[];
|
||||
readonly plannedAtMs: number;
|
||||
}
|
||||
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
const PLACEHOLDER_EVIDENCE_DIGEST = '0'.repeat(64);
|
||||
const PLAN_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/plugin-package-secret-binding-plan-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new TypeError(
|
||||
`Plugin Package Secret binding plan is invalid: ${message}`,
|
||||
);
|
||||
}
|
||||
|
||||
function exactKeys(value: object, expected: readonly string[]): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
if (
|
||||
actual.length !== canonical.length ||
|
||||
actual.some((key, index) => key !== canonical[index])
|
||||
) {
|
||||
invalid('shape is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function unsignedPlan(
|
||||
binding: Pick<PluginPackageSecretBinding, 'target' | 'entries'>,
|
||||
plannedAtMs: number,
|
||||
): Omit<PluginPackageSecretBindingPlan, 'planDigest'> {
|
||||
if (!Number.isSafeInteger(plannedAtMs) || plannedAtMs < 0) {
|
||||
return invalid('plannedAtMs is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
schema: PLUGIN_PACKAGE_SECRET_BINDING_PLAN_SCHEMA,
|
||||
target: binding.target,
|
||||
entries: binding.entries,
|
||||
plannedAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function planDigest(
|
||||
value: Omit<PluginPackageSecretBindingPlan, 'planDigest'>,
|
||||
): string {
|
||||
return createHash('sha256')
|
||||
.update(PLAN_DIGEST_DOMAIN)
|
||||
.update(JSON.stringify(value), 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function withDigest(
|
||||
value: Omit<PluginPackageSecretBindingPlan, 'planDigest'>,
|
||||
): Readonly<PluginPackageSecretBindingPlan> {
|
||||
const normalized = Object.freeze({
|
||||
...value,
|
||||
planDigest: planDigest(value),
|
||||
});
|
||||
if (
|
||||
Buffer.byteLength(JSON.stringify(normalized), 'utf8') >
|
||||
MAX_PLUGIN_PACKAGE_SECRET_BINDING_PLAN_JSON_BYTES
|
||||
) {
|
||||
return invalid('durable JSON byte budget exceeded');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function createPluginPackageSecretBindingPlan(
|
||||
input: CreatePluginPackageSecretBindingPlanInput,
|
||||
): Readonly<PluginPackageSecretBindingPlan> {
|
||||
const draft = createPluginPackageSecretBinding({
|
||||
generation: input.generation,
|
||||
manifest: input.manifest,
|
||||
assignments: input.assignments,
|
||||
authority: Object.freeze({
|
||||
kind: 'local-owner-confirmation',
|
||||
evidenceDigest: PLACEHOLDER_EVIDENCE_DIGEST,
|
||||
}),
|
||||
boundAtMs: 0,
|
||||
});
|
||||
return withDigest(unsignedPlan(draft, input.plannedAtMs));
|
||||
}
|
||||
|
||||
export function normalizePluginPackageSecretBindingPlan(
|
||||
value: unknown,
|
||||
): Readonly<PluginPackageSecretBindingPlan> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return invalid('value must be an object');
|
||||
}
|
||||
exactKeys(value, [
|
||||
'entries',
|
||||
'planDigest',
|
||||
'plannedAtMs',
|
||||
'schema',
|
||||
'target',
|
||||
]);
|
||||
const candidate = value as PluginPackageSecretBindingPlan;
|
||||
if (candidate.schema !== PLUGIN_PACKAGE_SECRET_BINDING_PLAN_SCHEMA) {
|
||||
return invalid('schema is unsupported');
|
||||
}
|
||||
if (
|
||||
typeof candidate.planDigest !== 'string' ||
|
||||
!DIGEST.test(candidate.planDigest)
|
||||
) {
|
||||
return invalid('plan digest is invalid');
|
||||
}
|
||||
const normalizedBinding = createPluginPackageSecretBindingFromEntries({
|
||||
target: candidate.target,
|
||||
entries: candidate.entries,
|
||||
authority: {
|
||||
kind: 'local-owner-confirmation',
|
||||
evidenceDigest: PLACEHOLDER_EVIDENCE_DIGEST,
|
||||
},
|
||||
boundAtMs: 0,
|
||||
});
|
||||
const unsigned = unsignedPlan(normalizedBinding, candidate.plannedAtMs);
|
||||
const normalized = withDigest(unsigned);
|
||||
if (normalized.planDigest !== candidate.planDigest) {
|
||||
return invalid('plan digest does not match content');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function createPluginPackageSecretBindingFromPlan(
|
||||
planValue: unknown,
|
||||
authorityKind: PluginPackageSecretBinding['authority']['kind'],
|
||||
boundAtMs: number,
|
||||
): Readonly<PluginPackageSecretBinding> {
|
||||
const plan = normalizePluginPackageSecretBindingPlan(planValue);
|
||||
return createPluginPackageSecretBindingFromEntries({
|
||||
target: plan.target,
|
||||
entries: plan.entries,
|
||||
authority: {
|
||||
kind: authorityKind,
|
||||
evidenceDigest: plan.planDigest,
|
||||
},
|
||||
boundAtMs,
|
||||
});
|
||||
}
|
||||
@@ -25,7 +25,7 @@ const {
|
||||
const { createSecretRef } = require('../dist/secret/secretReference');
|
||||
const {
|
||||
createPluginPackageSecretBinding,
|
||||
} = require('../dist/plugin-package/pluginPackageSecretBinding');
|
||||
} = require('../dist/plugin-package/secret-binding/binding');
|
||||
const {
|
||||
InvalidPluginPackageResourceMaterializationError,
|
||||
MAX_PLUGIN_PACKAGE_MATERIALIZED_RESOURCE_BYTES,
|
||||
|
||||
@@ -7,7 +7,7 @@ const {
|
||||
assertPluginPackageSecretBindingMatches,
|
||||
createPluginPackageSecretBinding,
|
||||
normalizePluginPackageSecretBinding,
|
||||
} = require('../dist/plugin-package/pluginPackageSecretBinding');
|
||||
} = require('../dist/plugin-package/secret-binding/binding');
|
||||
const {
|
||||
createPluginPackageResourceGeneration,
|
||||
} = require('../dist/plugin-package/pluginPackageResourceGeneration');
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createPluginPackageSecretBindingFromPlan,
|
||||
createPluginPackageSecretBindingPlan,
|
||||
normalizePluginPackageSecretBindingPlan,
|
||||
} = require('@qinglong/runtime-core/plugin-package-secret-binding-plan');
|
||||
const {
|
||||
createPluginPackageResourceGeneration,
|
||||
} = require('@qinglong/runtime-core/plugin-package-resource-generation');
|
||||
const { createSecretRef } = require('@qinglong/runtime-core/secret-reference');
|
||||
|
||||
const manifest = {
|
||||
apiVersion: 'qinglong.io/v1alpha1',
|
||||
kind: 'Package',
|
||||
metadata: {
|
||||
name: 'example-monitor',
|
||||
displayName: 'Example Monitor',
|
||||
version: '1.0.0',
|
||||
description: 'Secret binding plan 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: [
|
||||
{ name: 'OPTIONAL_TOKEN', required: false },
|
||||
{ name: 'TOKEN', required: true },
|
||||
],
|
||||
tools: ['secret.use'],
|
||||
},
|
||||
contents: { tasks: [], workflows: [], prompts: [], tools: [] },
|
||||
},
|
||||
};
|
||||
|
||||
const generation = createPluginPackageResourceGeneration({
|
||||
installationId: 'install-1',
|
||||
projectId: 'project-1',
|
||||
packageName: 'example-monitor',
|
||||
lockDigest: 'a'.repeat(64),
|
||||
generation: 1,
|
||||
previousActiveLockDigest: null,
|
||||
contentDigest: 'b'.repeat(64),
|
||||
contents: manifest.spec.contents,
|
||||
});
|
||||
|
||||
function plan() {
|
||||
return createPluginPackageSecretBindingPlan({
|
||||
generation,
|
||||
manifest,
|
||||
assignments: [
|
||||
{ name: 'OPTIONAL_TOKEN', secretRef: null },
|
||||
{
|
||||
name: 'TOKEN',
|
||||
secretRef: createSecretRef({
|
||||
projectId: 'project-1',
|
||||
name: 'runtime-token',
|
||||
version: 2,
|
||||
}),
|
||||
},
|
||||
],
|
||||
plannedAtMs: 100,
|
||||
});
|
||||
}
|
||||
|
||||
test('creates one canonical content-free Secret binding plan', () => {
|
||||
const value = plan();
|
||||
assert.match(value.planDigest, /^[0-9a-f]{64}$/);
|
||||
assert.equal(value.plannedAtMs, 100);
|
||||
assert.deepEqual(normalizePluginPackageSecretBindingPlan(value), value);
|
||||
assert.equal(JSON.stringify(value).includes('secret-value'), false);
|
||||
assert.equal(Object.isFrozen(value), true);
|
||||
});
|
||||
|
||||
test('rejects target, entry, time and digest drift', () => {
|
||||
const value = plan();
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePluginPackageSecretBindingPlan({ ...value, plannedAtMs: -1 }),
|
||||
/plannedAtMs is invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePluginPackageSecretBindingPlan({
|
||||
...value,
|
||||
target: { ...value.target, projectId: 'project-2' },
|
||||
}),
|
||||
/crosses Project boundary|plan digest/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizePluginPackageSecretBindingPlan({ ...value, extra: true }),
|
||||
/shape is invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePluginPackageSecretBindingPlan({
|
||||
...value,
|
||||
planDigest: 'c'.repeat(64),
|
||||
}),
|
||||
/plan digest does not match/,
|
||||
);
|
||||
});
|
||||
|
||||
test('creates Local and Cluster authority bindings from the same plan', () => {
|
||||
const value = plan();
|
||||
const local = createPluginPackageSecretBindingFromPlan(
|
||||
value,
|
||||
'local-owner-confirmation',
|
||||
110,
|
||||
);
|
||||
const cluster = createPluginPackageSecretBindingFromPlan(
|
||||
value,
|
||||
'approved-action-execution',
|
||||
120,
|
||||
);
|
||||
assert.equal(local.authority.evidenceDigest, value.planDigest);
|
||||
assert.equal(cluster.authority.evidenceDigest, value.planDigest);
|
||||
assert.equal(local.boundAtMs, 110);
|
||||
assert.equal(cluster.boundAtMs, 120);
|
||||
assert.notEqual(local.bindingDigest, cluster.bindingDigest);
|
||||
});
|
||||
|
||||
test('exports the plan contract only through its explicit subpath', () => {
|
||||
assert.equal(
|
||||
require('../dist').createPluginPackageSecretBindingPlan,
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
require('@qinglong/runtime-core/plugin-package-secret-binding-plan')
|
||||
.createPluginPackageSecretBindingPlan,
|
||||
createPluginPackageSecretBindingPlan,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user