mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): project plugin secrets into kubernetes
This commit is contained in:
+226
-32
@@ -18,9 +18,34 @@ import {
|
||||
normalizePluginPackageActivationReceipt,
|
||||
type PluginPackageActivationReceipt,
|
||||
} from '@qinglong/runtime-core/plugin-package-install';
|
||||
import type { PluginPackageSecretBindingRepository } from '@qinglong/runtime-core/plugin-package-secret-binding';
|
||||
import type { PluginPackageSecretBindingTransitionReceiptRepository } from '@qinglong/runtime-core/plugin-package-secret-binding-transition-receipt';
|
||||
|
||||
const ACTIVE_POINTER_SCHEMA =
|
||||
import {
|
||||
createPluginPackageKubernetesSecretProjection,
|
||||
isPluginPackageKubernetesSecretName,
|
||||
normalizePluginPackageKubernetesSecretProjection,
|
||||
pluginPackageKubernetesProjectedSecretWorkloadVolume,
|
||||
type PluginPackageKubernetesActiveDeployment,
|
||||
type PluginPackageKubernetesProjectedSecretWorkloadVolume,
|
||||
type PluginPackageKubernetesSecretProjection,
|
||||
type PluginPackageKubernetesSecretProjectionAssignment,
|
||||
type PluginPackageKubernetesSecretProjectionItem,
|
||||
} from '../secret-binding/pluginPackageKubernetesSecretProjection';
|
||||
|
||||
export {
|
||||
pluginPackageKubernetesProjectedSecretWorkloadVolume,
|
||||
type PluginPackageKubernetesActiveDeployment,
|
||||
type PluginPackageKubernetesProjectedSecretWorkloadVolume,
|
||||
type PluginPackageKubernetesSecretProjection,
|
||||
type PluginPackageKubernetesSecretProjectionAssignment,
|
||||
type PluginPackageKubernetesSecretProjectionItem,
|
||||
};
|
||||
|
||||
const ACTIVE_POINTER_SCHEMA_V2 =
|
||||
'qinglong/plugin-package-kubernetes-active-pointer@v2';
|
||||
const ACTIVE_POINTER_SCHEMA_V3 =
|
||||
'qinglong/plugin-package-kubernetes-active-pointer@v3';
|
||||
const ACTIVE_POINTER_KEY = 'active.json';
|
||||
const MANAGED_BY_LABEL = 'app.kubernetes.io/managed-by';
|
||||
const MANAGED_BY_VALUE = 'qinglong3';
|
||||
@@ -58,6 +83,19 @@ export interface PluginPackageKubernetesActivationPublisherOptions {
|
||||
readonly namespace: string;
|
||||
/** Explicit authoritative clock called only for a new publication attempt. */
|
||||
readonly now: () => number | Promise<number>;
|
||||
/**
|
||||
* Optional content-blind source used by the production recovery Job. When
|
||||
* configured, v3 pointers bind the exact projected Secret keys to the same
|
||||
* resourceVersion-fenced activation as the Package generation.
|
||||
*/
|
||||
readonly secretProjection?: Readonly<{
|
||||
readonly sourceSecretName: string;
|
||||
readonly bindings: Pick<PluginPackageSecretBindingRepository, 'find'>;
|
||||
readonly transitions: Pick<
|
||||
PluginPackageSecretBindingTransitionReceiptRepository,
|
||||
'find'
|
||||
>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface PluginPackageKubernetesConfigMap {
|
||||
@@ -110,17 +148,25 @@ export interface PluginPackageKubernetesConfigMapApi {
|
||||
): Promise<PluginPackageKubernetesConfigMap>;
|
||||
}
|
||||
|
||||
interface ActivePointer {
|
||||
readonly schema: typeof ACTIVE_POINTER_SCHEMA;
|
||||
interface ActivePointerV2 {
|
||||
readonly schema: typeof ACTIVE_POINTER_SCHEMA_V2;
|
||||
readonly clusterIdentityDigest: string;
|
||||
readonly intent: Readonly<PluginPackageActivationIntent>;
|
||||
readonly receipt: Readonly<PluginPackageActivationReceipt>;
|
||||
}
|
||||
|
||||
interface StoredPointer extends ActivePointer {
|
||||
readonly resourceVersion: string;
|
||||
interface ActivePointerV3 {
|
||||
readonly schema: typeof ACTIVE_POINTER_SCHEMA_V3;
|
||||
readonly clusterIdentityDigest: string;
|
||||
readonly intent: Readonly<PluginPackageActivationIntent>;
|
||||
readonly receipt: Readonly<PluginPackageActivationReceipt>;
|
||||
readonly secretProjection: Readonly<PluginPackageKubernetesSecretProjection> | null;
|
||||
}
|
||||
|
||||
type ActivePointer = ActivePointerV2 | ActivePointerV3;
|
||||
|
||||
type StoredPointer = ActivePointer & Readonly<{ resourceVersion: string }>;
|
||||
|
||||
function apiStatus(error: unknown): number | null {
|
||||
if (!error || typeof error !== 'object') return null;
|
||||
if ('code' in error && typeof error.code === 'number') return error.code;
|
||||
@@ -237,11 +283,29 @@ export class PluginPackageKubernetesActivationPublisher
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).sort().join(',') !==
|
||||
'clusterIdentity,namespace,now' ||
|
||||
Object.keys(options).some(
|
||||
(key) =>
|
||||
key !== 'clusterIdentity' &&
|
||||
key !== 'namespace' &&
|
||||
key !== 'now' &&
|
||||
key !== 'secretProjection',
|
||||
) ||
|
||||
!SAFE_IDENTITY.test(options.clusterIdentity) ||
|
||||
!DNS_LABEL.test(options.namespace) ||
|
||||
typeof options.now !== 'function'
|
||||
typeof options.now !== 'function' ||
|
||||
(options.secretProjection !== undefined &&
|
||||
(!options.secretProjection ||
|
||||
typeof options.secretProjection !== 'object' ||
|
||||
Array.isArray(options.secretProjection) ||
|
||||
Object.keys(options.secretProjection).sort().join(',') !==
|
||||
'bindings,sourceSecretName,transitions' ||
|
||||
!isPluginPackageKubernetesSecretName(
|
||||
options.secretProjection.sourceSecretName,
|
||||
) ||
|
||||
!options.secretProjection.bindings ||
|
||||
typeof options.secretProjection.bindings.find !== 'function' ||
|
||||
!options.secretProjection.transitions ||
|
||||
typeof options.secretProjection.transitions.find !== 'function'))
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Plugin Package Kubernetes activation options are invalid',
|
||||
@@ -306,6 +370,38 @@ export class PluginPackageKubernetesActivationPublisher
|
||||
}
|
||||
}
|
||||
|
||||
async #secretProjection(
|
||||
intent: Readonly<PluginPackageActivationIntent>,
|
||||
): Promise<Readonly<PluginPackageKubernetesSecretProjection> | null> {
|
||||
const source = this.options.secretProjection;
|
||||
if (!source) return null;
|
||||
try {
|
||||
const generationDigest = intent.resourceGeneration.generationDigest;
|
||||
const [binding, transition] = await Promise.all([
|
||||
source.bindings.find(generationDigest),
|
||||
source.transitions.find(generationDigest),
|
||||
]);
|
||||
if (
|
||||
binding &&
|
||||
(binding.target.installationId !== intent.installationId ||
|
||||
binding.target.projectId !== intent.projectId ||
|
||||
binding.target.packageName !== intent.packageName ||
|
||||
binding.target.lockDigest !== intent.lockDigest ||
|
||||
binding.target.generation !== intent.targetGeneration)
|
||||
) {
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
return createPluginPackageKubernetesSecretProjection(
|
||||
source.sourceSecretName,
|
||||
generationDigest,
|
||||
binding,
|
||||
transition,
|
||||
);
|
||||
} catch (error) {
|
||||
return preserveDomainError(error);
|
||||
}
|
||||
}
|
||||
|
||||
#parsePointer(
|
||||
configMap: PluginPackageKubernetesConfigMap,
|
||||
expectedName: string,
|
||||
@@ -336,37 +432,71 @@ export class PluginPackageKubernetesActivationPublisher
|
||||
const serialized = data[ACTIVE_POINTER_KEY];
|
||||
if (
|
||||
labels[MANAGED_BY_LABEL] !== MANAGED_BY_VALUE ||
|
||||
labels[ACTIVE_LABEL] !== 'v2' ||
|
||||
(labels[ACTIVE_LABEL] !== 'v2' && labels[ACTIVE_LABEL] !== 'v3') ||
|
||||
typeof serialized !== 'string' ||
|
||||
Buffer.byteLength(serialized, 'utf8') > MAX_ACTIVE_POINTER_BYTES
|
||||
) {
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
const pointer = dataRecord(JSON.parse(serialized));
|
||||
exactKeys(pointer, [
|
||||
'schema',
|
||||
'clusterIdentityDigest',
|
||||
'intent',
|
||||
'receipt',
|
||||
]);
|
||||
if (pointer.schema === ACTIVE_POINTER_SCHEMA_V2) {
|
||||
exactKeys(pointer, [
|
||||
'schema',
|
||||
'clusterIdentityDigest',
|
||||
'intent',
|
||||
'receipt',
|
||||
]);
|
||||
} else if (pointer.schema === ACTIVE_POINTER_SCHEMA_V3) {
|
||||
exactKeys(pointer, [
|
||||
'schema',
|
||||
'clusterIdentityDigest',
|
||||
'intent',
|
||||
'receipt',
|
||||
'secretProjection',
|
||||
]);
|
||||
} else {
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
const intent = normalizeIntent(
|
||||
pointer.intent as PluginPackageActivationIntent,
|
||||
);
|
||||
const receipt = normalizePluginPackageActivationReceipt(pointer.receipt);
|
||||
const normalized: ActivePointer = Object.freeze({
|
||||
schema: ACTIVE_POINTER_SCHEMA,
|
||||
clusterIdentityDigest: this.#clusterIdentityDigest,
|
||||
intent,
|
||||
receipt,
|
||||
});
|
||||
const secretProjection =
|
||||
pointer.schema === ACTIVE_POINTER_SCHEMA_V3
|
||||
? pointer.secretProjection === null
|
||||
? null
|
||||
: normalizePluginPackageKubernetesSecretProjection(
|
||||
pointer.secretProjection,
|
||||
)
|
||||
: undefined;
|
||||
const normalized: ActivePointer =
|
||||
pointer.schema === ACTIVE_POINTER_SCHEMA_V3
|
||||
? Object.freeze({
|
||||
schema: ACTIVE_POINTER_SCHEMA_V3,
|
||||
clusterIdentityDigest: this.#clusterIdentityDigest,
|
||||
intent,
|
||||
receipt,
|
||||
secretProjection: secretProjection!,
|
||||
})
|
||||
: Object.freeze({
|
||||
schema: ACTIVE_POINTER_SCHEMA_V2,
|
||||
clusterIdentityDigest: this.#clusterIdentityDigest,
|
||||
intent,
|
||||
receipt,
|
||||
});
|
||||
if (
|
||||
pointer.schema !== ACTIVE_POINTER_SCHEMA ||
|
||||
pointer.clusterIdentityDigest !== this.#clusterIdentityDigest ||
|
||||
this.#name(intent) !== expectedName ||
|
||||
labels[TARGET_LABEL] !==
|
||||
Buffer.from(this.#targetDigest(intent), 'hex').toString(
|
||||
'base64url',
|
||||
) ||
|
||||
labels[ACTIVE_LABEL] !==
|
||||
(pointer.schema === ACTIVE_POINTER_SCHEMA_V3 ? 'v3' : 'v2') ||
|
||||
(secretProjection !== undefined &&
|
||||
secretProjection !== null &&
|
||||
secretProjection.generationDigest !==
|
||||
intent.resourceGeneration.generationDigest) ||
|
||||
annotations[INTENT_ANNOTATION] !== intent.intentDigest ||
|
||||
receipt.intentDigest !== intent.intentDigest ||
|
||||
receipt.generation !== intent.targetGeneration ||
|
||||
@@ -409,6 +539,7 @@ export class PluginPackageKubernetesActivationPublisher
|
||||
intent: Readonly<PluginPackageActivationIntent>,
|
||||
): Promise<Readonly<PluginPackageActivationObservation>> {
|
||||
await this.#verifyStage(intent);
|
||||
const expectedProjection = await this.#secretProjection(intent);
|
||||
const pointer = await this.#optionalPointer(intent);
|
||||
if (!pointer) {
|
||||
if (intent.previousActiveLockDigest !== null) {
|
||||
@@ -416,7 +547,12 @@ export class PluginPackageKubernetesActivationPublisher
|
||||
}
|
||||
return Object.freeze({ status: 'not_published' });
|
||||
}
|
||||
if (same(pointer.intent, intent)) {
|
||||
if (
|
||||
same(pointer.intent, intent) &&
|
||||
(pointer.schema === ACTIVE_POINTER_SCHEMA_V2
|
||||
? expectedProjection === null
|
||||
: same(pointer.secretProjection, expectedProjection))
|
||||
) {
|
||||
return Object.freeze({ status: 'published', receipt: pointer.receipt });
|
||||
}
|
||||
if (
|
||||
@@ -433,14 +569,24 @@ export class PluginPackageKubernetesActivationPublisher
|
||||
intent: Readonly<PluginPackageActivationIntent>,
|
||||
receipt: Readonly<PluginPackageActivationReceipt>,
|
||||
current: Readonly<StoredPointer> | null,
|
||||
secretProjection: Readonly<PluginPackageKubernetesSecretProjection> | null,
|
||||
): ConfigMapWrite {
|
||||
const targetDigest = this.#targetDigest(intent);
|
||||
const pointer: Readonly<ActivePointer> = Object.freeze({
|
||||
schema: ACTIVE_POINTER_SCHEMA,
|
||||
clusterIdentityDigest: this.#clusterIdentityDigest,
|
||||
intent,
|
||||
receipt,
|
||||
});
|
||||
const usesSecretProjection = secretProjection !== null;
|
||||
const pointer: Readonly<ActivePointer> = usesSecretProjection
|
||||
? Object.freeze({
|
||||
schema: ACTIVE_POINTER_SCHEMA_V3,
|
||||
clusterIdentityDigest: this.#clusterIdentityDigest,
|
||||
intent,
|
||||
receipt,
|
||||
secretProjection,
|
||||
})
|
||||
: Object.freeze({
|
||||
schema: ACTIVE_POINTER_SCHEMA_V2,
|
||||
clusterIdentityDigest: this.#clusterIdentityDigest,
|
||||
intent,
|
||||
receipt,
|
||||
});
|
||||
const serialized = `${JSON.stringify(pointer)}\n`;
|
||||
if (Buffer.byteLength(serialized, 'utf8') > MAX_ACTIVE_POINTER_BYTES) {
|
||||
throw new PluginPackageActivationUnavailableError();
|
||||
@@ -455,7 +601,7 @@ export class PluginPackageKubernetesActivationPublisher
|
||||
...(current ? { resourceVersion: current.resourceVersion } : {}),
|
||||
labels: Object.freeze({
|
||||
[MANAGED_BY_LABEL]: MANAGED_BY_VALUE,
|
||||
[ACTIVE_LABEL]: 'v2',
|
||||
[ACTIVE_LABEL]: usesSecretProjection ? 'v3' : 'v2',
|
||||
[TARGET_LABEL]: Buffer.from(targetDigest, 'hex').toString(
|
||||
'base64url',
|
||||
),
|
||||
@@ -500,6 +646,42 @@ export class PluginPackageKubernetesActivationPublisher
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns one parsed active deployment snapshot for a workload renderer.
|
||||
* It performs no Secret API call and exposes only projection keys, never
|
||||
* Secret material or reversible Secret references.
|
||||
*/
|
||||
async findActiveDeployment(
|
||||
projectId: string,
|
||||
packageName: string,
|
||||
): Promise<Readonly<PluginPackageKubernetesActiveDeployment> | null> {
|
||||
if (
|
||||
typeof projectId !== 'string' ||
|
||||
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(projectId) ||
|
||||
typeof packageName !== 'string' ||
|
||||
!DNS_LABEL.test(packageName)
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Plugin Package active deployment identity is invalid',
|
||||
);
|
||||
}
|
||||
try {
|
||||
const pointer = await this.#optionalPointer(
|
||||
Object.freeze({ projectId, packageName }),
|
||||
);
|
||||
if (!pointer) return null;
|
||||
return Object.freeze({
|
||||
resourceGeneration: pointer.intent.resourceGeneration,
|
||||
secretProjection:
|
||||
pointer.schema === ACTIVE_POINTER_SCHEMA_V3
|
||||
? pointer.secretProjection
|
||||
: null,
|
||||
});
|
||||
} catch (error) {
|
||||
return preserveDomainError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async publish(
|
||||
value: Readonly<PluginPackageActivationIntent>,
|
||||
): Promise<Readonly<PluginPackageActivationReceipt>> {
|
||||
@@ -508,7 +690,18 @@ export class PluginPackageKubernetesActivationPublisher
|
||||
const first = await this.#observe(intent);
|
||||
if (first.status === 'published') return first.receipt;
|
||||
const current = await this.#optionalPointer(intent);
|
||||
if (current && same(current.intent, intent)) return current.receipt;
|
||||
if (current && same(current.intent, intent)) {
|
||||
const expectedProjection = await this.#secretProjection(intent);
|
||||
if (
|
||||
(current.schema === ACTIVE_POINTER_SCHEMA_V2 &&
|
||||
expectedProjection === null) ||
|
||||
(current.schema === ACTIVE_POINTER_SCHEMA_V3 &&
|
||||
same(current.secretProjection, expectedProjection))
|
||||
) {
|
||||
return current.receipt;
|
||||
}
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
if (
|
||||
(!current && intent.previousActiveLockDigest !== null) ||
|
||||
(current &&
|
||||
@@ -529,7 +722,8 @@ export class PluginPackageKubernetesActivationPublisher
|
||||
contentDigest: intent.contentDigest,
|
||||
activatedAtMs,
|
||||
});
|
||||
const body = this.#body(intent, receipt, current);
|
||||
const secretProjection = await this.#secretProjection(intent);
|
||||
const body = this.#body(intent, receipt, current, secretProjection);
|
||||
try {
|
||||
if (current) {
|
||||
await this.api.replaceNamespacedConfigMap({
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
PostgresPluginPackageMaterializedRevisionRepository,
|
||||
PostgresPluginPackageSecretBindingRepository,
|
||||
PostgresPluginPackageSecretBindingActivationPrerequisite,
|
||||
PostgresPluginPackageSecretBindingTransitionRepository,
|
||||
PostgresPluginPackagePublisherProvenanceRepository,
|
||||
PostgresPluginPackageTaskReconciliationRepository,
|
||||
PostgresProjectToolDefinitionSnapshotRepository,
|
||||
@@ -307,6 +308,16 @@ export async function recoverClusterPluginPackages(
|
||||
clusterIdentity: options.clusterIdentity,
|
||||
namespace: options.namespace,
|
||||
now: options.now,
|
||||
secretProjection: {
|
||||
sourceSecretName: 'ql3-cluster-plugin-package-values',
|
||||
bindings: new PostgresPluginPackageSecretBindingRepository(
|
||||
database.pool,
|
||||
),
|
||||
transitions:
|
||||
new PostgresPluginPackageSecretBindingTransitionRepository(
|
||||
database.pool,
|
||||
),
|
||||
},
|
||||
},
|
||||
);
|
||||
const resourceByteSource =
|
||||
|
||||
+315
@@ -0,0 +1,315 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { PluginPackageActivationConflictError } from '@qinglong/runtime-core/plugin-package-activation';
|
||||
import type { PluginPackageResourceGeneration } from '@qinglong/runtime-core/plugin-package-resource-generation';
|
||||
import type { PluginPackageSecretBinding } from '@qinglong/runtime-core/plugin-package-secret-binding';
|
||||
import type { PluginPackageSecretBindingTransitionReceipt } from '@qinglong/runtime-core/plugin-package-secret-binding-transition-receipt';
|
||||
import { secretProjectionFileName } from '@qinglong/runtime-core/secret-projection';
|
||||
|
||||
export const PLUGIN_PACKAGE_KUBERNETES_SECRET_PROJECTION_SCHEMA =
|
||||
'qinglong/plugin-package-kubernetes-secret-projection@v1' as const;
|
||||
export const PLUGIN_PACKAGE_KUBERNETES_SECRET_FILE_MODE = 0o440 as const;
|
||||
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
const DNS_LABEL = /^[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?$/;
|
||||
const ASSIGNMENT_NAME = /^[A-Z_][A-Z0-9_]{0,127}$/;
|
||||
const PROJECTION_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/plugin-package-kubernetes-secret-projection-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
export interface PluginPackageKubernetesSecretProjectionItem {
|
||||
readonly key: string;
|
||||
readonly path: string;
|
||||
}
|
||||
|
||||
export interface PluginPackageKubernetesSecretProjectionAssignment {
|
||||
readonly name: string;
|
||||
readonly required: boolean;
|
||||
readonly path: string | null;
|
||||
}
|
||||
|
||||
export interface PluginPackageKubernetesSecretProjection {
|
||||
readonly schema: typeof PLUGIN_PACKAGE_KUBERNETES_SECRET_PROJECTION_SCHEMA;
|
||||
readonly sourceSecretName: string;
|
||||
readonly defaultMode: typeof PLUGIN_PACKAGE_KUBERNETES_SECRET_FILE_MODE;
|
||||
readonly generationDigest: string;
|
||||
readonly bindingDigest: string | null;
|
||||
readonly transitionReceiptDigest: string | null;
|
||||
readonly items: readonly Readonly<PluginPackageKubernetesSecretProjectionItem>[];
|
||||
readonly assignments: readonly Readonly<PluginPackageKubernetesSecretProjectionAssignment>[];
|
||||
readonly projectionDigest: string;
|
||||
}
|
||||
|
||||
export interface PluginPackageKubernetesActiveDeployment {
|
||||
readonly resourceGeneration: Readonly<PluginPackageResourceGeneration>;
|
||||
readonly secretProjection: Readonly<PluginPackageKubernetesSecretProjection> | null;
|
||||
}
|
||||
|
||||
export interface PluginPackageKubernetesProjectedSecretWorkloadVolume {
|
||||
readonly volume: Readonly<{
|
||||
readonly name: 'plugin-package-values';
|
||||
readonly secret: Readonly<{
|
||||
readonly secretName: string;
|
||||
readonly optional: false;
|
||||
readonly defaultMode: typeof PLUGIN_PACKAGE_KUBERNETES_SECRET_FILE_MODE;
|
||||
readonly items: readonly Readonly<PluginPackageKubernetesSecretProjectionItem>[];
|
||||
}>;
|
||||
}>;
|
||||
readonly volumeMount: Readonly<{
|
||||
readonly name: 'plugin-package-values';
|
||||
readonly mountPath: '/var/run/secrets/qinglong3/plugin-package-values';
|
||||
readonly readOnly: true;
|
||||
}>;
|
||||
}
|
||||
|
||||
function conflict(): never {
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
|
||||
function dataRecord(value: unknown): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
(Object.getPrototypeOf(value) !== Object.prototype &&
|
||||
Object.getPrototypeOf(value) !== null)
|
||||
) {
|
||||
return conflict();
|
||||
}
|
||||
const descriptors = Object.getOwnPropertyDescriptors(value);
|
||||
if (
|
||||
Object.values(descriptors).some(
|
||||
(descriptor) =>
|
||||
descriptor.get !== undefined ||
|
||||
descriptor.set !== undefined ||
|
||||
descriptor.enumerable !== true,
|
||||
)
|
||||
) {
|
||||
return conflict();
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
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])
|
||||
) {
|
||||
conflict();
|
||||
}
|
||||
}
|
||||
|
||||
export function isPluginPackageKubernetesSecretName(
|
||||
value: unknown,
|
||||
): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length <= 253 &&
|
||||
value.split('.').every((label) => DNS_LABEL.test(label))
|
||||
);
|
||||
}
|
||||
|
||||
function digest(
|
||||
value: Omit<PluginPackageKubernetesSecretProjection, 'projectionDigest'>,
|
||||
): string {
|
||||
return createHash('sha256')
|
||||
.update(PROJECTION_DIGEST_DOMAIN)
|
||||
.update(JSON.stringify(value), 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function uniqueItems(
|
||||
assignments: readonly Readonly<PluginPackageKubernetesSecretProjectionAssignment>[],
|
||||
): readonly Readonly<PluginPackageKubernetesSecretProjectionItem>[] {
|
||||
const seen = new Set<string>();
|
||||
return Object.freeze(
|
||||
assignments.flatMap((assignment) => {
|
||||
if (assignment.path === null || seen.has(assignment.path)) return [];
|
||||
seen.add(assignment.path);
|
||||
return [Object.freeze({ key: assignment.path, path: assignment.path })];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function createPluginPackageKubernetesSecretProjection(
|
||||
sourceSecretName: string,
|
||||
generationDigest: string,
|
||||
binding: Readonly<PluginPackageSecretBinding> | null,
|
||||
transition: Readonly<PluginPackageSecretBindingTransitionReceipt> | null,
|
||||
): Readonly<PluginPackageKubernetesSecretProjection> | null {
|
||||
if (
|
||||
!isPluginPackageKubernetesSecretName(sourceSecretName) ||
|
||||
!DIGEST.test(generationDigest)
|
||||
) {
|
||||
return conflict();
|
||||
}
|
||||
if (transition) {
|
||||
if (
|
||||
transition.transitionPlan.nextTarget.generationDigest !==
|
||||
generationDigest ||
|
||||
transition.bindingDigest !== (binding?.bindingDigest ?? null) ||
|
||||
JSON.stringify(
|
||||
transition.transitionPlan.nextBindingPlan?.entries ?? [],
|
||||
) !== JSON.stringify(binding?.entries ?? [])
|
||||
) {
|
||||
return conflict();
|
||||
}
|
||||
} else if (
|
||||
binding !== null &&
|
||||
binding.target.generationDigest !== generationDigest
|
||||
) {
|
||||
return conflict();
|
||||
}
|
||||
if (!binding && !transition) return null;
|
||||
|
||||
const assignments = Object.freeze(
|
||||
(binding?.entries ?? []).map((entry) =>
|
||||
Object.freeze({
|
||||
name: entry.name,
|
||||
required: entry.required,
|
||||
path:
|
||||
entry.secretRef === null
|
||||
? null
|
||||
: secretProjectionFileName(entry.secretRef),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const unsigned = Object.freeze({
|
||||
schema: PLUGIN_PACKAGE_KUBERNETES_SECRET_PROJECTION_SCHEMA,
|
||||
sourceSecretName,
|
||||
defaultMode: PLUGIN_PACKAGE_KUBERNETES_SECRET_FILE_MODE,
|
||||
generationDigest,
|
||||
bindingDigest: binding?.bindingDigest ?? null,
|
||||
transitionReceiptDigest: transition?.receiptDigest ?? null,
|
||||
items: uniqueItems(assignments),
|
||||
assignments,
|
||||
});
|
||||
return Object.freeze({ ...unsigned, projectionDigest: digest(unsigned) });
|
||||
}
|
||||
|
||||
export function normalizePluginPackageKubernetesSecretProjection(
|
||||
value: unknown,
|
||||
): Readonly<PluginPackageKubernetesSecretProjection> {
|
||||
const candidate = dataRecord(value);
|
||||
exactKeys(candidate, [
|
||||
'schema',
|
||||
'sourceSecretName',
|
||||
'defaultMode',
|
||||
'generationDigest',
|
||||
'bindingDigest',
|
||||
'transitionReceiptDigest',
|
||||
'items',
|
||||
'assignments',
|
||||
'projectionDigest',
|
||||
]);
|
||||
if (
|
||||
candidate.schema !== PLUGIN_PACKAGE_KUBERNETES_SECRET_PROJECTION_SCHEMA ||
|
||||
!isPluginPackageKubernetesSecretName(candidate.sourceSecretName) ||
|
||||
candidate.defaultMode !== PLUGIN_PACKAGE_KUBERNETES_SECRET_FILE_MODE ||
|
||||
typeof candidate.generationDigest !== 'string' ||
|
||||
!DIGEST.test(candidate.generationDigest) ||
|
||||
(candidate.bindingDigest !== null &&
|
||||
(typeof candidate.bindingDigest !== 'string' ||
|
||||
!DIGEST.test(candidate.bindingDigest))) ||
|
||||
(candidate.transitionReceiptDigest !== null &&
|
||||
(typeof candidate.transitionReceiptDigest !== 'string' ||
|
||||
!DIGEST.test(candidate.transitionReceiptDigest))) ||
|
||||
!Array.isArray(candidate.items) ||
|
||||
!Array.isArray(candidate.assignments) ||
|
||||
candidate.items.length > 64 ||
|
||||
candidate.assignments.length > 64
|
||||
) {
|
||||
return conflict();
|
||||
}
|
||||
const assignments = Object.freeze(
|
||||
candidate.assignments.map((value) => {
|
||||
const assignment = dataRecord(value);
|
||||
exactKeys(assignment, ['name', 'required', 'path']);
|
||||
if (
|
||||
typeof assignment.name !== 'string' ||
|
||||
!ASSIGNMENT_NAME.test(assignment.name) ||
|
||||
typeof assignment.required !== 'boolean' ||
|
||||
(assignment.path !== null &&
|
||||
(typeof assignment.path !== 'string' ||
|
||||
!DIGEST.test(assignment.path))) ||
|
||||
(assignment.required && assignment.path === null)
|
||||
) {
|
||||
return conflict();
|
||||
}
|
||||
return Object.freeze({
|
||||
name: assignment.name,
|
||||
required: assignment.required,
|
||||
path: assignment.path as string | null,
|
||||
});
|
||||
}),
|
||||
);
|
||||
const items = Object.freeze(
|
||||
candidate.items.map((value) => {
|
||||
const item = dataRecord(value);
|
||||
exactKeys(item, ['key', 'path']);
|
||||
if (
|
||||
typeof item.key !== 'string' ||
|
||||
typeof item.path !== 'string' ||
|
||||
!DIGEST.test(item.key) ||
|
||||
item.path !== item.key
|
||||
) {
|
||||
return conflict();
|
||||
}
|
||||
return Object.freeze({ key: item.key, path: item.path });
|
||||
}),
|
||||
);
|
||||
if (JSON.stringify(items) !== JSON.stringify(uniqueItems(assignments))) {
|
||||
return conflict();
|
||||
}
|
||||
const unsigned = Object.freeze({
|
||||
schema: PLUGIN_PACKAGE_KUBERNETES_SECRET_PROJECTION_SCHEMA,
|
||||
sourceSecretName: candidate.sourceSecretName,
|
||||
defaultMode: PLUGIN_PACKAGE_KUBERNETES_SECRET_FILE_MODE,
|
||||
generationDigest: candidate.generationDigest,
|
||||
bindingDigest: candidate.bindingDigest as string | null,
|
||||
transitionReceiptDigest: candidate.transitionReceiptDigest as string | null,
|
||||
items,
|
||||
assignments,
|
||||
});
|
||||
if (
|
||||
typeof candidate.projectionDigest !== 'string' ||
|
||||
candidate.projectionDigest !== digest(unsigned)
|
||||
) {
|
||||
return conflict();
|
||||
}
|
||||
return Object.freeze({
|
||||
...unsigned,
|
||||
projectionDigest: candidate.projectionDigest,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure Pod-spec fragment renderer. An empty/revoked projection deliberately
|
||||
* returns null: an omitted/empty Secret items mapping can mean "all keys".
|
||||
*/
|
||||
export function pluginPackageKubernetesProjectedSecretWorkloadVolume(
|
||||
value: Readonly<PluginPackageKubernetesSecretProjection> | null,
|
||||
): Readonly<PluginPackageKubernetesProjectedSecretWorkloadVolume> | null {
|
||||
if (value === null) return null;
|
||||
const projection = normalizePluginPackageKubernetesSecretProjection(value);
|
||||
if (projection.items.length === 0) return null;
|
||||
return Object.freeze({
|
||||
volume: Object.freeze({
|
||||
name: 'plugin-package-values' as const,
|
||||
secret: Object.freeze({
|
||||
secretName: projection.sourceSecretName,
|
||||
optional: false as const,
|
||||
defaultMode: PLUGIN_PACKAGE_KUBERNETES_SECRET_FILE_MODE,
|
||||
items: projection.items,
|
||||
}),
|
||||
}),
|
||||
volumeMount: Object.freeze({
|
||||
name: 'plugin-package-values' as const,
|
||||
mountPath: '/var/run/secrets/qinglong3/plugin-package-values' as const,
|
||||
readOnly: true as const,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -9,6 +9,20 @@ const {
|
||||
const {
|
||||
createPluginPackageResourceGeneration,
|
||||
} = require('@qinglong/runtime-core/plugin-package-resource-generation');
|
||||
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,
|
||||
} = require('@qinglong/runtime-core/plugin-package-secret-binding-transition-receipt');
|
||||
const {
|
||||
secretProjectionFileName,
|
||||
} = require('@qinglong/runtime-core/secret-projection');
|
||||
const { createSecretRef } = require('@qinglong/runtime-core/secret-reference');
|
||||
const {
|
||||
PLUGIN_PACKAGE_API_VERSION,
|
||||
PLUGIN_PACKAGE_KIND,
|
||||
@@ -28,6 +42,7 @@ const {
|
||||
} = require('@qinglong/runtime-core/plugin-package-recovery');
|
||||
const {
|
||||
PluginPackageKubernetesActivationPublisher,
|
||||
pluginPackageKubernetesProjectedSecretWorkloadVolume,
|
||||
} = require('../dist/plugin-package/recovery/pluginPackageKubernetesActivation');
|
||||
|
||||
function apiError(code) {
|
||||
@@ -298,11 +313,167 @@ function publisher(api = new FakeConfigMapApi(), overrides = {}) {
|
||||
nowCalls += 1;
|
||||
return overrides.now?.() ?? 500 + nowCalls;
|
||||
},
|
||||
...(overrides.secretProjection === undefined
|
||||
? {}
|
||||
: { secretProjection: overrides.secretProjection }),
|
||||
},
|
||||
);
|
||||
return { api, publisher: value, nowCalls: () => nowCalls };
|
||||
}
|
||||
|
||||
function secretManifest(version, secrets) {
|
||||
return {
|
||||
apiVersion: PLUGIN_PACKAGE_API_VERSION,
|
||||
kind: PLUGIN_PACKAGE_KIND,
|
||||
metadata: {
|
||||
name: 'example-monitor',
|
||||
displayName: 'Example Monitor',
|
||||
version,
|
||||
description: 'Kubernetes Secret projection fixture',
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
spec: {
|
||||
compatibility: {
|
||||
qinglong: '>=3.0.0-0 <4.0.0',
|
||||
architectures: ['arm64'],
|
||||
deploymentProfiles: ['cluster-control'],
|
||||
},
|
||||
runtimes: [],
|
||||
resources: {
|
||||
memory: { recommended: '16Mi' },
|
||||
disk: { install: '4Mi', working: '16Mi' },
|
||||
},
|
||||
permissions: {
|
||||
network: { allowedHosts: [] },
|
||||
secrets,
|
||||
tools: secrets.length === 0 ? [] : ['secret.use'],
|
||||
},
|
||||
contents: { tasks: [], workflows: [], prompts: [], tools: [] },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function projectedTransition(kind) {
|
||||
const previousManifest = secretManifest('1.0.0', [
|
||||
{ name: 'TOKEN', required: true },
|
||||
]);
|
||||
const previousGeneration = createPluginPackageResourceGeneration({
|
||||
installationId: 'install-secret-v1',
|
||||
projectId: 'default',
|
||||
packageName: 'example-monitor',
|
||||
lockDigest: '1'.repeat(64),
|
||||
generation: 1,
|
||||
previousActiveLockDigest: null,
|
||||
contentDigest: '2'.repeat(64),
|
||||
contents: previousManifest.spec.contents,
|
||||
});
|
||||
const previousBinding = createPluginPackageSecretBinding({
|
||||
generation: previousGeneration,
|
||||
manifest: previousManifest,
|
||||
assignments: [
|
||||
{
|
||||
name: 'TOKEN',
|
||||
secretRef: createSecretRef({
|
||||
projectId: 'default',
|
||||
name: 'token',
|
||||
version: 1,
|
||||
}),
|
||||
},
|
||||
],
|
||||
authority: {
|
||||
kind: 'approved-action-execution',
|
||||
evidenceDigest: '3'.repeat(64),
|
||||
},
|
||||
boundAtMs: 10,
|
||||
});
|
||||
const previousActivation = intent({
|
||||
installationId: previousGeneration.installationId,
|
||||
lockDigest: previousGeneration.lockDigest,
|
||||
targetGeneration: previousGeneration.generation,
|
||||
previousActiveLockDigest: null,
|
||||
contentDigest: previousGeneration.contentDigest,
|
||||
resourceGeneration: previousGeneration,
|
||||
intentDigest: kind === 'revoke' ? 'b'.repeat(64) : 'c'.repeat(64),
|
||||
});
|
||||
const nextManifest =
|
||||
kind === 'revoke'
|
||||
? secretManifest('2.0.0', [])
|
||||
: secretManifest('2.0.0', [{ name: 'TOKEN', required: true }]);
|
||||
const nextGeneration = createPluginPackageResourceGeneration({
|
||||
installationId: `install-secret-${kind}`,
|
||||
projectId: 'default',
|
||||
packageName: 'example-monitor',
|
||||
lockDigest: kind === 'revoke' ? '4'.repeat(64) : '5'.repeat(64),
|
||||
generation: 2,
|
||||
previousActiveLockDigest: previousBinding.target.lockDigest,
|
||||
contentDigest: kind === 'revoke' ? '6'.repeat(64) : '7'.repeat(64),
|
||||
contents: nextManifest.spec.contents,
|
||||
});
|
||||
const secretRef = createSecretRef({
|
||||
projectId: 'default',
|
||||
name: 'token',
|
||||
version: 2,
|
||||
});
|
||||
const plan = createPluginPackageSecretBindingTransitionPlan({
|
||||
previousTarget: previousBinding.target,
|
||||
previousBinding,
|
||||
previousAttemptGeneration: 1,
|
||||
nextGeneration,
|
||||
nextManifest,
|
||||
assignments: kind === 'revoke' ? [] : [{ name: 'TOKEN', secretRef }],
|
||||
plannedAtMs: 20,
|
||||
});
|
||||
const binding = createPluginPackageSecretBindingFromTransitionPlan(
|
||||
plan,
|
||||
'approved-action-execution',
|
||||
'8'.repeat(64),
|
||||
30,
|
||||
);
|
||||
const receipt = createPluginPackageSecretBindingTransitionReceipt({
|
||||
transitionPlan: plan,
|
||||
authority: {
|
||||
kind: 'approved-action-execution',
|
||||
evidenceDigest: '8'.repeat(64),
|
||||
},
|
||||
binding,
|
||||
committedAtMs: 30,
|
||||
});
|
||||
const activation = intent({
|
||||
installationId: nextGeneration.installationId,
|
||||
lockDigest: nextGeneration.lockDigest,
|
||||
targetGeneration: nextGeneration.generation,
|
||||
previousActiveLockDigest: nextGeneration.previousActiveLockDigest,
|
||||
contentDigest: nextGeneration.contentDigest,
|
||||
resourceGeneration: nextGeneration,
|
||||
intentDigest: kind === 'revoke' ? '9'.repeat(64) : 'a'.repeat(64),
|
||||
});
|
||||
return { previousActivation, activation, binding, receipt, secretRef };
|
||||
}
|
||||
|
||||
function projectionSource(value) {
|
||||
return {
|
||||
sourceSecretName: 'ql3-cluster-plugin-package-values',
|
||||
bindings: {
|
||||
async find(generationDigest) {
|
||||
assert.equal(
|
||||
generationDigest,
|
||||
value.activation.resourceGeneration.generationDigest,
|
||||
);
|
||||
return value.binding;
|
||||
},
|
||||
},
|
||||
transitions: {
|
||||
async find(generationDigest) {
|
||||
assert.equal(
|
||||
generationDigest,
|
||||
value.activation.resourceGeneration.generationDigest,
|
||||
);
|
||||
return value.receipt;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('publishes one resourceVersion-fenced ConfigMap and exact replays it', async () => {
|
||||
const fixture = publisher();
|
||||
const value = intent();
|
||||
@@ -391,6 +562,195 @@ test('replaces only the exact previous lock and rejects a stale writer', async (
|
||||
);
|
||||
});
|
||||
|
||||
test('publishes a content-blind v3 projection for an approved Secret rotation', async () => {
|
||||
const value = projectedTransition('rotate');
|
||||
const api = new FakeConfigMapApi();
|
||||
await publisher(api).publisher.publish(value.previousActivation);
|
||||
const fixture = publisher(api, {
|
||||
secretProjection: projectionSource(value),
|
||||
});
|
||||
await fixture.publisher.publish(value.activation);
|
||||
const deployment = await fixture.publisher.findActiveDeployment(
|
||||
'default',
|
||||
'example-monitor',
|
||||
);
|
||||
assert.equal(
|
||||
deployment.resourceGeneration.generationDigest,
|
||||
value.activation.resourceGeneration.generationDigest,
|
||||
);
|
||||
assert.deepEqual(deployment.secretProjection.assignments, [
|
||||
{
|
||||
name: 'TOKEN',
|
||||
required: true,
|
||||
path: secretProjectionFileName(value.secretRef),
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(deployment.secretProjection.items, [
|
||||
{
|
||||
key: secretProjectionFileName(value.secretRef),
|
||||
path: secretProjectionFileName(value.secretRef),
|
||||
},
|
||||
]);
|
||||
assert.equal(deployment.secretProjection.defaultMode, 0o440);
|
||||
assert.equal(
|
||||
deployment.secretProjection.bindingDigest,
|
||||
value.binding.bindingDigest,
|
||||
);
|
||||
assert.equal(
|
||||
deployment.secretProjection.transitionReceiptDigest,
|
||||
value.receipt.receiptDigest,
|
||||
);
|
||||
const [stored] = fixture.api.items.values();
|
||||
const pointer = JSON.parse(stored.data['active.json']);
|
||||
assert.equal(pointer.schema.endsWith('@v3'), true);
|
||||
assert.equal(
|
||||
stored.metadata.labels['qinglong.io/plugin-package-active'],
|
||||
'v3',
|
||||
);
|
||||
assert.equal(JSON.stringify(pointer).includes(value.secretRef), false);
|
||||
assert.deepEqual(
|
||||
pluginPackageKubernetesProjectedSecretWorkloadVolume(
|
||||
deployment.secretProjection,
|
||||
),
|
||||
{
|
||||
volume: {
|
||||
name: 'plugin-package-values',
|
||||
secret: {
|
||||
secretName: 'ql3-cluster-plugin-package-values',
|
||||
optional: false,
|
||||
defaultMode: 0o440,
|
||||
items: [
|
||||
{
|
||||
key: secretProjectionFileName(value.secretRef),
|
||||
path: secretProjectionFileName(value.secretRef),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
volumeMount: {
|
||||
name: 'plugin-package-values',
|
||||
mountPath: '/var/run/secrets/qinglong3/plugin-package-values',
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('publishes an explicit empty projection for revoke and rejects projection drift', async () => {
|
||||
const value = projectedTransition('revoke');
|
||||
const api = new FakeConfigMapApi();
|
||||
await publisher(api).publisher.publish(value.previousActivation);
|
||||
const fixture = publisher(api, {
|
||||
secretProjection: projectionSource(value),
|
||||
});
|
||||
await fixture.publisher.publish(value.activation);
|
||||
const deployment = await fixture.publisher.findActiveDeployment(
|
||||
'default',
|
||||
'example-monitor',
|
||||
);
|
||||
assert.deepEqual(deployment.secretProjection.items, []);
|
||||
assert.deepEqual(deployment.secretProjection.assignments, []);
|
||||
assert.equal(deployment.secretProjection.bindingDigest, null);
|
||||
assert.equal(
|
||||
deployment.secretProjection.transitionReceiptDigest,
|
||||
value.receipt.receiptDigest,
|
||||
);
|
||||
assert.equal(
|
||||
pluginPackageKubernetesProjectedSecretWorkloadVolume(
|
||||
deployment.secretProjection,
|
||||
),
|
||||
null,
|
||||
);
|
||||
|
||||
const [key, stored] = fixture.api.items.entries().next().value;
|
||||
const pointer = JSON.parse(stored.data['active.json']);
|
||||
pointer.secretProjection.projectionDigest = '0'.repeat(64);
|
||||
fixture.api.items.set(key, {
|
||||
...stored,
|
||||
data: { 'active.json': `${JSON.stringify(pointer)}\n` },
|
||||
});
|
||||
await assert.rejects(
|
||||
fixture.publisher.findActiveDeployment('default', 'example-monitor'),
|
||||
PluginPackageActivationConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
test('converges a lost v3 replacement response without republishing projection', async () => {
|
||||
const value = projectedTransition('rotate');
|
||||
const api = new FakeConfigMapApi();
|
||||
await publisher(api).publisher.publish(value.previousActivation);
|
||||
api.loseReplaceResponse = true;
|
||||
const fixture = publisher(api, {
|
||||
secretProjection: projectionSource(value),
|
||||
});
|
||||
await assert.rejects(
|
||||
fixture.publisher.publish(value.activation),
|
||||
PluginPackageActivationUnavailableError,
|
||||
);
|
||||
assert.equal(api.replaceCalls, 1);
|
||||
assert.equal(
|
||||
(await fixture.publisher.inspect(value.activation)).status,
|
||||
'published',
|
||||
);
|
||||
await fixture.publisher.publish(value.activation);
|
||||
assert.equal(api.replaceCalls, 1);
|
||||
assert.equal(fixture.nowCalls(), 1);
|
||||
});
|
||||
|
||||
test('does not switch the active pointer when projection evidence is unavailable', async () => {
|
||||
const value = projectedTransition('rotate');
|
||||
const api = new FakeConfigMapApi();
|
||||
await publisher(api).publisher.publish(value.previousActivation);
|
||||
const previousPointer = structuredClone([...api.items.values()][0]);
|
||||
const fixture = publisher(api, {
|
||||
secretProjection: {
|
||||
...projectionSource(value),
|
||||
transitions: {
|
||||
async find() {
|
||||
throw new Error('database unavailable');
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
fixture.publisher.publish(value.activation),
|
||||
PluginPackageActivationUnavailableError,
|
||||
);
|
||||
assert.deepEqual([...api.items.values()][0], previousPointer);
|
||||
assert.equal(fixture.api.replaceCalls, 0);
|
||||
assert.equal(fixture.nowCalls(), 0);
|
||||
});
|
||||
|
||||
test('keeps a staged upgrade without Secret facts on the compatible v2 pointer', async () => {
|
||||
const value = projectedTransition('rotate');
|
||||
const api = new FakeConfigMapApi();
|
||||
await publisher(api).publisher.publish(value.previousActivation);
|
||||
const fixture = publisher(api, {
|
||||
secretProjection: {
|
||||
...projectionSource(value),
|
||||
bindings: {
|
||||
async find() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
transitions: {
|
||||
async find() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await fixture.publisher.publish(value.activation);
|
||||
const deployment = await fixture.publisher.findActiveDeployment(
|
||||
'default',
|
||||
'example-monitor',
|
||||
);
|
||||
assert.equal(deployment.secretProjection, null);
|
||||
const pointer = JSON.parse([...api.items.values()][0].data['active.json']);
|
||||
assert.equal(pointer.schema.endsWith('@v2'), true);
|
||||
assert.equal(Object.hasOwn(pointer, 'secretProjection'), false);
|
||||
});
|
||||
|
||||
test('leaves response loss for recovery inspection without republishing', async () => {
|
||||
const api = new FakeConfigMapApi();
|
||||
api.loseCreateResponse = true;
|
||||
|
||||
@@ -200,6 +200,12 @@ function runtimePrivileges() {
|
||||
plugin_package_automation_disposition_events: [false, false, false, false],
|
||||
plugin_package_automation_publication_heads: [true, false, false, false],
|
||||
plugin_package_secret_binding_approval_plans: [false, false, false, false],
|
||||
plugin_package_secret_binding_transition_approval_plans: [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
],
|
||||
plugin_package_secret_bindings: [false, false, false, false],
|
||||
plugin_package_secret_binding_transition_receipts: [
|
||||
false,
|
||||
|
||||
@@ -114,6 +114,12 @@ function runtimePrivileges() {
|
||||
plugin_package_automation_disposition_events: [false, false, false, false],
|
||||
plugin_package_automation_publication_heads: [true, false, false, false],
|
||||
plugin_package_secret_binding_approval_plans: [false, false, false, false],
|
||||
plugin_package_secret_binding_transition_approval_plans: [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
],
|
||||
plugin_package_secret_bindings: [false, false, false, false],
|
||||
plugin_package_secret_binding_transition_receipts: [
|
||||
false,
|
||||
|
||||
Reference in New Issue
Block a user