mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-23 03:18:09 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
+565
@@ -0,0 +1,565 @@
|
||||
// Cluster Plugin Package recovery boundary; keep Kubernetes activation authority explicit.
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import {
|
||||
PluginPackageActivationConflictError,
|
||||
PluginPackageActivationUnavailableError,
|
||||
normalizePluginPackageActivationIntent,
|
||||
type PluginPackageActivationIntent,
|
||||
type PluginPackageActivationObservation,
|
||||
type PluginPackageActivationPublisher,
|
||||
} from '@qinglong/runtime-core/plugin-package-activation';
|
||||
import type {
|
||||
PluginPackageResourceGeneration,
|
||||
PluginPackageResourceGenerationSource,
|
||||
} from '@qinglong/runtime-core/plugin-package-resource-generation';
|
||||
import {
|
||||
createPluginPackageActivationReceipt,
|
||||
normalizePluginPackageActivationReceipt,
|
||||
type PluginPackageActivationReceipt,
|
||||
} from '@qinglong/runtime-core/plugin-package-install';
|
||||
|
||||
const ACTIVE_POINTER_SCHEMA =
|
||||
'qinglong/plugin-package-kubernetes-active-pointer@v2';
|
||||
const ACTIVE_POINTER_KEY = 'active.json';
|
||||
const MANAGED_BY_LABEL = 'app.kubernetes.io/managed-by';
|
||||
const MANAGED_BY_VALUE = 'qinglong3';
|
||||
const ACTIVE_LABEL = 'qinglong.io/plugin-package-active';
|
||||
const TARGET_LABEL = 'qinglong.io/plugin-package-target';
|
||||
const INTENT_ANNOTATION = 'qinglong.io/plugin-package-intent';
|
||||
const FIELD_MANAGER = 'qinglong-plugin-package-activation';
|
||||
const MAX_ACTIVE_POINTER_BYTES = 512 * 1024;
|
||||
const DNS_LABEL = /^[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?$/;
|
||||
const SAFE_IDENTITY = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/;
|
||||
const RESOURCE_ID = /^[A-Za-z0-9][A-Za-z0-9._:/+=-]{0,511}$/;
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
const TARGET_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/plugin-package-kubernetes-target@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
export interface ClusterPluginPackageStageEvidence {
|
||||
readonly lockDigest: string;
|
||||
readonly stageRef: string;
|
||||
readonly stageReceiptDigest: string;
|
||||
readonly stageEvidenceDigest: string;
|
||||
readonly contentDigest: string;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageStageEvidenceVerifier {
|
||||
verify(
|
||||
intent: Readonly<PluginPackageActivationIntent>,
|
||||
): Promise<Readonly<ClusterPluginPackageStageEvidence>>;
|
||||
}
|
||||
|
||||
export interface PluginPackageKubernetesActivationPublisherOptions {
|
||||
/** Stable operator-reviewed identity for one Kubernetes API cluster. */
|
||||
readonly clusterIdentity: string;
|
||||
readonly namespace: string;
|
||||
/** Explicit authoritative clock called only for a new publication attempt. */
|
||||
readonly now: () => number | Promise<number>;
|
||||
}
|
||||
|
||||
export interface PluginPackageKubernetesConfigMap {
|
||||
readonly apiVersion?: string;
|
||||
readonly kind?: string;
|
||||
readonly immutable?: boolean;
|
||||
readonly data?: Readonly<Record<string, string>>;
|
||||
readonly binaryData?: Readonly<Record<string, string>>;
|
||||
readonly metadata?: Readonly<{
|
||||
name?: string;
|
||||
namespace?: string;
|
||||
uid?: string;
|
||||
resourceVersion?: string;
|
||||
deletionTimestamp?: Date;
|
||||
finalizers?: readonly string[];
|
||||
ownerReferences?: readonly Readonly<Record<string, unknown>>[];
|
||||
labels?: Readonly<Record<string, string>>;
|
||||
annotations?: Readonly<Record<string, string>>;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface ConfigMapWrite extends PluginPackageKubernetesConfigMap {
|
||||
readonly metadata: NonNullable<PluginPackageKubernetesConfigMap['metadata']>;
|
||||
readonly data: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
export interface PluginPackageKubernetesConfigMapApi {
|
||||
readNamespacedConfigMap(
|
||||
request: Readonly<{
|
||||
name: string;
|
||||
namespace: string;
|
||||
}>,
|
||||
): Promise<PluginPackageKubernetesConfigMap>;
|
||||
createNamespacedConfigMap(
|
||||
request: Readonly<{
|
||||
namespace: string;
|
||||
body: ConfigMapWrite;
|
||||
fieldManager: string;
|
||||
fieldValidation: 'Strict';
|
||||
}>,
|
||||
): Promise<PluginPackageKubernetesConfigMap>;
|
||||
replaceNamespacedConfigMap(
|
||||
request: Readonly<{
|
||||
name: string;
|
||||
namespace: string;
|
||||
body: ConfigMapWrite;
|
||||
fieldManager: string;
|
||||
fieldValidation: 'Strict';
|
||||
}>,
|
||||
): Promise<PluginPackageKubernetesConfigMap>;
|
||||
}
|
||||
|
||||
interface ActivePointer {
|
||||
readonly schema: typeof ACTIVE_POINTER_SCHEMA;
|
||||
readonly clusterIdentityDigest: string;
|
||||
readonly intent: Readonly<PluginPackageActivationIntent>;
|
||||
readonly receipt: Readonly<PluginPackageActivationReceipt>;
|
||||
}
|
||||
|
||||
interface StoredPointer extends 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;
|
||||
if (
|
||||
'response' in error &&
|
||||
error.response &&
|
||||
typeof error.response === 'object' &&
|
||||
'statusCode' in error.response &&
|
||||
typeof error.response.statusCode === 'number'
|
||||
) {
|
||||
return error.response.statusCode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function preserveDomainError(error: unknown): never {
|
||||
if (
|
||||
error instanceof PluginPackageActivationConflictError ||
|
||||
error instanceof PluginPackageActivationUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new PluginPackageActivationUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function dataRecord(value: unknown): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
(Object.getPrototypeOf(value) !== Object.prototype &&
|
||||
Object.getPrototypeOf(value) !== null)
|
||||
) {
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
const descriptors = Object.getOwnPropertyDescriptors(value);
|
||||
if (
|
||||
Object.values(descriptors).some(
|
||||
(descriptor) =>
|
||||
descriptor.get !== undefined ||
|
||||
descriptor.set !== undefined ||
|
||||
descriptor.enumerable !== true,
|
||||
)
|
||||
) {
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
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])
|
||||
) {
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
}
|
||||
|
||||
function boundedResourceId(value: unknown): string {
|
||||
if (typeof value !== 'string' || !RESOURCE_ID.test(value)) {
|
||||
throw new PluginPackageActivationUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeIntent(
|
||||
value: Readonly<PluginPackageActivationIntent>,
|
||||
): Readonly<PluginPackageActivationIntent> {
|
||||
try {
|
||||
return normalizePluginPackageActivationIntent(value);
|
||||
} catch {
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
}
|
||||
|
||||
function same(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
/**
|
||||
* Short-lived Kubernetes ConfigMap publisher. It owns no timer, watcher,
|
||||
* database connection or cache; every replacement is resourceVersion fenced.
|
||||
*/
|
||||
export class PluginPackageKubernetesActivationPublisher
|
||||
implements
|
||||
PluginPackageActivationPublisher,
|
||||
PluginPackageResourceGenerationSource
|
||||
{
|
||||
readonly #clusterIdentityDigest: string;
|
||||
|
||||
constructor(
|
||||
private readonly api: PluginPackageKubernetesConfigMapApi,
|
||||
private readonly stageEvidence: ClusterPluginPackageStageEvidenceVerifier,
|
||||
private readonly options: PluginPackageKubernetesActivationPublisherOptions,
|
||||
) {
|
||||
if (
|
||||
!api ||
|
||||
typeof api.readNamespacedConfigMap !== 'function' ||
|
||||
typeof api.createNamespacedConfigMap !== 'function' ||
|
||||
typeof api.replaceNamespacedConfigMap !== 'function'
|
||||
) {
|
||||
throw new TypeError('Plugin Package Kubernetes ConfigMap API is invalid');
|
||||
}
|
||||
if (!stageEvidence || typeof stageEvidence.verify !== 'function') {
|
||||
throw new TypeError(
|
||||
'Plugin Package cluster stage evidence verifier is invalid',
|
||||
);
|
||||
}
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).sort().join(',') !==
|
||||
'clusterIdentity,namespace,now' ||
|
||||
!SAFE_IDENTITY.test(options.clusterIdentity) ||
|
||||
!DNS_LABEL.test(options.namespace) ||
|
||||
typeof options.now !== 'function'
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Plugin Package Kubernetes activation options are invalid',
|
||||
);
|
||||
}
|
||||
this.#clusterIdentityDigest = createHash('sha256')
|
||||
.update('qinglong/plugin-package-kubernetes-cluster@v1\0', 'utf8')
|
||||
.update(options.clusterIdentity, 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
#targetDigest(
|
||||
identity: Readonly<
|
||||
Pick<PluginPackageActivationIntent, 'projectId' | 'packageName'>
|
||||
>,
|
||||
): string {
|
||||
return createHash('sha256')
|
||||
.update(TARGET_DIGEST_DOMAIN)
|
||||
.update(this.#clusterIdentityDigest, 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(this.options.namespace, 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(identity.projectId, 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(identity.packageName, 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
#name(
|
||||
identity: Readonly<
|
||||
Pick<PluginPackageActivationIntent, 'projectId' | 'packageName'>
|
||||
>,
|
||||
): string {
|
||||
return `ql3p-${this.#targetDigest(identity).slice(0, 52)}`;
|
||||
}
|
||||
|
||||
async #verifyStage(
|
||||
intent: Readonly<PluginPackageActivationIntent>,
|
||||
): Promise<void> {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = await this.stageEvidence.verify(intent);
|
||||
} catch (error) {
|
||||
return preserveDomainError(error);
|
||||
}
|
||||
const evidence = dataRecord(value);
|
||||
exactKeys(evidence, [
|
||||
'lockDigest',
|
||||
'stageRef',
|
||||
'stageReceiptDigest',
|
||||
'stageEvidenceDigest',
|
||||
'contentDigest',
|
||||
]);
|
||||
if (
|
||||
evidence.lockDigest !== intent.lockDigest ||
|
||||
evidence.stageRef !== intent.stageRef ||
|
||||
evidence.stageReceiptDigest !== intent.stageReceiptDigest ||
|
||||
evidence.stageEvidenceDigest !== intent.stageEvidenceDigest ||
|
||||
evidence.contentDigest !== intent.contentDigest
|
||||
) {
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
}
|
||||
|
||||
#parsePointer(
|
||||
configMap: PluginPackageKubernetesConfigMap,
|
||||
expectedName: string,
|
||||
): Readonly<StoredPointer> {
|
||||
try {
|
||||
const metadata = configMap?.metadata;
|
||||
if (
|
||||
configMap.apiVersion !== 'v1' ||
|
||||
configMap.kind !== 'ConfigMap' ||
|
||||
configMap.immutable === true ||
|
||||
configMap.binaryData !== undefined ||
|
||||
!metadata ||
|
||||
metadata.name !== expectedName ||
|
||||
metadata.namespace !== this.options.namespace ||
|
||||
metadata.deletionTimestamp !== undefined ||
|
||||
(metadata.finalizers?.length ?? 0) !== 0 ||
|
||||
(metadata.ownerReferences?.length ?? 0) !== 0 ||
|
||||
!configMap.data
|
||||
) {
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
const labels = dataRecord(metadata.labels);
|
||||
exactKeys(labels, [MANAGED_BY_LABEL, ACTIVE_LABEL, TARGET_LABEL]);
|
||||
const annotations = dataRecord(metadata.annotations);
|
||||
exactKeys(annotations, [INTENT_ANNOTATION]);
|
||||
const data = dataRecord(configMap.data);
|
||||
exactKeys(data, [ACTIVE_POINTER_KEY]);
|
||||
const serialized = data[ACTIVE_POINTER_KEY];
|
||||
if (
|
||||
labels[MANAGED_BY_LABEL] !== MANAGED_BY_VALUE ||
|
||||
labels[ACTIVE_LABEL] !== 'v2' ||
|
||||
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',
|
||||
]);
|
||||
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,
|
||||
});
|
||||
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',
|
||||
) ||
|
||||
annotations[INTENT_ANNOTATION] !== intent.intentDigest ||
|
||||
receipt.intentDigest !== intent.intentDigest ||
|
||||
receipt.generation !== intent.targetGeneration ||
|
||||
receipt.contentDigest !== intent.contentDigest ||
|
||||
`${JSON.stringify(normalized)}\n` !== serialized
|
||||
) {
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
boundedResourceId(metadata.uid);
|
||||
return Object.freeze({
|
||||
...normalized,
|
||||
resourceVersion: boundedResourceId(metadata.resourceVersion),
|
||||
});
|
||||
} catch (error) {
|
||||
return preserveDomainError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async #optionalPointer(
|
||||
identity: Readonly<
|
||||
Pick<PluginPackageActivationIntent, 'projectId' | 'packageName'>
|
||||
>,
|
||||
): Promise<Readonly<StoredPointer> | null> {
|
||||
const name = this.#name(identity);
|
||||
try {
|
||||
return this.#parsePointer(
|
||||
await this.api.readNamespacedConfigMap({
|
||||
name,
|
||||
namespace: this.options.namespace,
|
||||
}),
|
||||
name,
|
||||
);
|
||||
} catch (error) {
|
||||
if (apiStatus(error) === 404) return null;
|
||||
return preserveDomainError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async #observe(
|
||||
intent: Readonly<PluginPackageActivationIntent>,
|
||||
): Promise<Readonly<PluginPackageActivationObservation>> {
|
||||
await this.#verifyStage(intent);
|
||||
const pointer = await this.#optionalPointer(intent);
|
||||
if (!pointer) {
|
||||
if (intent.previousActiveLockDigest !== null) {
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
return Object.freeze({ status: 'not_published' });
|
||||
}
|
||||
if (same(pointer.intent, intent)) {
|
||||
return Object.freeze({ status: 'published', receipt: pointer.receipt });
|
||||
}
|
||||
if (
|
||||
pointer.intent.projectId === intent.projectId &&
|
||||
pointer.intent.packageName === intent.packageName &&
|
||||
pointer.intent.lockDigest === intent.previousActiveLockDigest
|
||||
) {
|
||||
return Object.freeze({ status: 'not_published' });
|
||||
}
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
|
||||
#body(
|
||||
intent: Readonly<PluginPackageActivationIntent>,
|
||||
receipt: Readonly<PluginPackageActivationReceipt>,
|
||||
current: Readonly<StoredPointer> | null,
|
||||
): ConfigMapWrite {
|
||||
const targetDigest = this.#targetDigest(intent);
|
||||
const pointer: Readonly<ActivePointer> = Object.freeze({
|
||||
schema: ACTIVE_POINTER_SCHEMA,
|
||||
clusterIdentityDigest: this.#clusterIdentityDigest,
|
||||
intent,
|
||||
receipt,
|
||||
});
|
||||
const serialized = `${JSON.stringify(pointer)}\n`;
|
||||
if (Buffer.byteLength(serialized, 'utf8') > MAX_ACTIVE_POINTER_BYTES) {
|
||||
throw new PluginPackageActivationUnavailableError();
|
||||
}
|
||||
return Object.freeze({
|
||||
apiVersion: 'v1',
|
||||
kind: 'ConfigMap',
|
||||
immutable: false,
|
||||
metadata: Object.freeze({
|
||||
name: this.#name(intent),
|
||||
namespace: this.options.namespace,
|
||||
...(current ? { resourceVersion: current.resourceVersion } : {}),
|
||||
labels: Object.freeze({
|
||||
[MANAGED_BY_LABEL]: MANAGED_BY_VALUE,
|
||||
[ACTIVE_LABEL]: 'v2',
|
||||
[TARGET_LABEL]: Buffer.from(targetDigest, 'hex').toString(
|
||||
'base64url',
|
||||
),
|
||||
}),
|
||||
annotations: Object.freeze({
|
||||
[INTENT_ANNOTATION]: intent.intentDigest,
|
||||
}),
|
||||
}),
|
||||
data: Object.freeze({ [ACTIVE_POINTER_KEY]: serialized }),
|
||||
});
|
||||
}
|
||||
|
||||
async inspect(
|
||||
value: Readonly<PluginPackageActivationIntent>,
|
||||
): Promise<Readonly<PluginPackageActivationObservation>> {
|
||||
try {
|
||||
return await this.#observe(normalizeIntent(value));
|
||||
} catch (error) {
|
||||
return preserveDomainError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async findActiveResourceGeneration(
|
||||
projectId: string,
|
||||
packageName: string,
|
||||
): Promise<Readonly<PluginPackageResourceGeneration> | 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 resource identity is invalid');
|
||||
}
|
||||
try {
|
||||
return (
|
||||
(await this.#optionalPointer(Object.freeze({ projectId, packageName })))
|
||||
?.intent.resourceGeneration ?? null
|
||||
);
|
||||
} catch (error) {
|
||||
return preserveDomainError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async publish(
|
||||
value: Readonly<PluginPackageActivationIntent>,
|
||||
): Promise<Readonly<PluginPackageActivationReceipt>> {
|
||||
const intent = normalizeIntent(value);
|
||||
try {
|
||||
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 && intent.previousActiveLockDigest !== null) ||
|
||||
(current &&
|
||||
(current.intent.projectId !== intent.projectId ||
|
||||
current.intent.packageName !== intent.packageName ||
|
||||
current.intent.lockDigest !== intent.previousActiveLockDigest))
|
||||
) {
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
const activatedAtMs = await this.options.now();
|
||||
if (!Number.isSafeInteger(activatedAtMs) || activatedAtMs < 0) {
|
||||
throw new PluginPackageActivationUnavailableError();
|
||||
}
|
||||
const receipt = createPluginPackageActivationReceipt({
|
||||
activationRef: `k8s-configmap:${this.#targetDigest(intent)}`,
|
||||
intentDigest: intent.intentDigest,
|
||||
generation: intent.targetGeneration,
|
||||
contentDigest: intent.contentDigest,
|
||||
activatedAtMs,
|
||||
});
|
||||
const body = this.#body(intent, receipt, current);
|
||||
try {
|
||||
if (current) {
|
||||
await this.api.replaceNamespacedConfigMap({
|
||||
name: this.#name(intent),
|
||||
namespace: this.options.namespace,
|
||||
body,
|
||||
fieldManager: FIELD_MANAGER,
|
||||
fieldValidation: 'Strict',
|
||||
});
|
||||
} else {
|
||||
await this.api.createNamespacedConfigMap({
|
||||
namespace: this.options.namespace,
|
||||
body,
|
||||
fieldManager: FIELD_MANAGER,
|
||||
fieldValidation: 'Strict',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (apiStatus(error) !== 409) return preserveDomainError(error);
|
||||
const winner = await this.#observe(intent);
|
||||
if (winner.status === 'published') return winner.receipt;
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
const final = await this.#observe(intent);
|
||||
if (final.status !== 'published') {
|
||||
throw new PluginPackageActivationUnavailableError();
|
||||
}
|
||||
return final.receipt;
|
||||
} catch (error) {
|
||||
return preserveDomainError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,450 @@
|
||||
// Cluster Plugin Package recovery boundary; keep recovery coordination authority explicit.
|
||||
import type {
|
||||
OpenPostgresDatabase,
|
||||
PostgresDatabaseResource,
|
||||
PostgresPool,
|
||||
} from '@qinglong/runtime-core';
|
||||
import {
|
||||
MAX_PLUGIN_PACKAGE_INSTALL_RECOVERY_PAGE_SIZE,
|
||||
assertPluginPackageInstallMatchesLock,
|
||||
} from '@qinglong/runtime-core/plugin-package-install';
|
||||
import {
|
||||
MAX_PLUGIN_PACKAGE_RECOVERY_PAGES,
|
||||
PluginPackageRecoveryCoordinator,
|
||||
type PluginPackageRecoveryCycleResult,
|
||||
} from '@qinglong/runtime-core/plugin-package-recovery';
|
||||
import {
|
||||
PluginPackageAutomationPublicationCoordinator,
|
||||
PluginPackageAutomationPublicationRecoveryCoordinator,
|
||||
type PluginPackageAutomationPublicationRecoveryCycleResult,
|
||||
} from '@qinglong/runtime-core/plugin-package-automation-publication';
|
||||
import type { PluginPackageResourceByteSource } from '@qinglong/runtime-core/plugin-package-resource-materialization';
|
||||
import {
|
||||
MAX_PLUGIN_PACKAGE_TASK_PUBLICATION_RECOVERY_PAGES,
|
||||
MAX_PLUGIN_PACKAGE_TASK_PUBLICATION_RECOVERY_PAGE_SIZE,
|
||||
PluginPackageTaskPublicationCoordinator,
|
||||
PluginPackageTaskPublicationRecoveryCoordinator,
|
||||
type PluginPackageTaskPublicationRecoveryCycleResult,
|
||||
} from '@qinglong/runtime-core/plugin-package-task-publication';
|
||||
import {
|
||||
MAX_PROJECT_TOOL_SNAPSHOT_RECOVERY_PAGES,
|
||||
MAX_PROJECT_TOOL_SNAPSHOT_RECOVERY_PAGE_SIZE,
|
||||
MAX_PROJECT_TOOL_SNAPSHOT_SOURCE_PAGE_SIZE,
|
||||
ProjectToolDefinitionSnapshotPublicationCoordinator,
|
||||
ProjectToolDefinitionSnapshotRecoveryCoordinator,
|
||||
type ProjectToolDefinitionSnapshotRecoveryCycleResult,
|
||||
} from '@qinglong/runtime-core/project-tool-definition-snapshot';
|
||||
import { createBuiltInTaskSpecSemanticRegistry } from '@qinglong/runtime-core/task-spec-semantic';
|
||||
import {
|
||||
assertPostgresPackageExecutorSchemaReady,
|
||||
PostgresPluginPackageAutomationPublicationRepository,
|
||||
PostgresPluginPackageMaterializedRevisionRepository,
|
||||
PostgresPluginPackagePublisherProvenanceRepository,
|
||||
PostgresPluginPackageTaskReconciliationRepository,
|
||||
PostgresProjectToolDefinitionSnapshotRepository,
|
||||
type PostgresSchemaReadinessReport,
|
||||
} from '@qinglong/cluster-postgres/package-executor';
|
||||
import { PostgresPluginPackageInstallRepository } from '@qinglong/cluster-postgres/plugin-package-install';
|
||||
|
||||
import {
|
||||
PluginPackageKubernetesActivationPublisher,
|
||||
type PluginPackageKubernetesConfigMapApi,
|
||||
} from './pluginPackageKubernetesActivation';
|
||||
import {
|
||||
ClusterPluginPackageOciResourceByteSource,
|
||||
ClusterPluginPackageOciStageAuthority,
|
||||
clusterPluginPackageActivationEvidence,
|
||||
pluginPackageStageVerificationFailure,
|
||||
type ClusterPluginPackageStageAuthority,
|
||||
} from './pluginPackageOciStage';
|
||||
import {
|
||||
ClusterPluginPackageProvenanceInstallRepository,
|
||||
recoverClusterPluginPackagePublisherProvenance,
|
||||
type ClusterPluginPackagePublisherProvenanceRecoveryResult,
|
||||
} from '../publisher/pluginPackagePublisherProvenanceRecovery';
|
||||
|
||||
export interface ClusterPluginPackageRecoveryOptions {
|
||||
readonly openDatabase: OpenPostgresDatabase;
|
||||
readonly api: PluginPackageKubernetesConfigMapApi;
|
||||
readonly stageAuthority?: ClusterPluginPackageStageAuthority;
|
||||
readonly stageAuthorityFactory?: (
|
||||
pool: PostgresPool,
|
||||
) =>
|
||||
| ClusterPluginPackageStageAuthority
|
||||
| Promise<ClusterPluginPackageStageAuthority>;
|
||||
readonly resourceByteSource?: PluginPackageResourceByteSource;
|
||||
readonly trustAuthorityId: string;
|
||||
readonly clusterIdentity: string;
|
||||
readonly namespace: string;
|
||||
readonly now: () => number | Promise<number>;
|
||||
readonly pageSize?: number;
|
||||
readonly maxPages?: number;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageRecoveryResult {
|
||||
readonly evidence: PostgresSchemaReadinessReport;
|
||||
readonly provenanceRecovery: Readonly<ClusterPluginPackagePublisherProvenanceRecoveryResult>;
|
||||
readonly recovery: Readonly<PluginPackageRecoveryCycleResult>;
|
||||
readonly taskPublicationRecovery: Readonly<PluginPackageTaskPublicationRecoveryCycleResult>;
|
||||
readonly automationPublicationRecovery: Readonly<PluginPackageAutomationPublicationRecoveryCycleResult>;
|
||||
readonly toolSnapshotRecovery: Readonly<ProjectToolDefinitionSnapshotRecoveryCycleResult>;
|
||||
}
|
||||
|
||||
export class ClusterPluginPackageRecoveryRequiredError extends Error {
|
||||
constructor(readonly recovery: Readonly<PluginPackageRecoveryCycleResult>) {
|
||||
super('Cluster has unresolved Plugin Package recovery work');
|
||||
this.name = 'ClusterPluginPackageRecoveryRequiredError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterPluginPackagePublisherProvenanceRecoveryRequiredError extends Error {
|
||||
constructor(
|
||||
readonly recovery: Readonly<ClusterPluginPackagePublisherProvenanceRecoveryResult>,
|
||||
) {
|
||||
super('Cluster has unresolved Plugin Package publisher provenance work');
|
||||
this.name =
|
||||
'ClusterPluginPackagePublisherProvenanceRecoveryRequiredError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterPluginPackageTaskPublicationRequiredError extends Error {
|
||||
constructor(
|
||||
readonly recovery: Readonly<PluginPackageTaskPublicationRecoveryCycleResult>,
|
||||
) {
|
||||
super('Cluster has unresolved Plugin Package Task publication work');
|
||||
this.name = 'ClusterPluginPackageTaskPublicationRequiredError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterPluginPackageAutomationPublicationRequiredError extends Error {
|
||||
constructor(
|
||||
readonly recovery: Readonly<PluginPackageAutomationPublicationRecoveryCycleResult>,
|
||||
) {
|
||||
super(
|
||||
'Cluster has unresolved Plugin Package Workflow/Prompt publication work',
|
||||
);
|
||||
this.name = 'ClusterPluginPackageAutomationPublicationRequiredError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterPluginPackageToolSnapshotRequiredError extends Error {
|
||||
constructor(
|
||||
readonly recovery: Readonly<ProjectToolDefinitionSnapshotRecoveryCycleResult>,
|
||||
) {
|
||||
super('Cluster has unresolved Plugin Package Tool snapshot work');
|
||||
this.name = 'ClusterPluginPackageToolSnapshotRequiredError';
|
||||
}
|
||||
}
|
||||
|
||||
function assertOptions(options: ClusterPluginPackageRecoveryOptions): void {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some(
|
||||
(key) =>
|
||||
![
|
||||
'openDatabase',
|
||||
'api',
|
||||
'stageAuthority',
|
||||
'stageAuthorityFactory',
|
||||
'resourceByteSource',
|
||||
'trustAuthorityId',
|
||||
'clusterIdentity',
|
||||
'namespace',
|
||||
'now',
|
||||
'pageSize',
|
||||
'maxPages',
|
||||
].includes(key),
|
||||
) ||
|
||||
typeof options.openDatabase !== 'function' ||
|
||||
(options.stageAuthority === undefined) ===
|
||||
(options.stageAuthorityFactory === undefined) ||
|
||||
(options.stageAuthorityFactory !== undefined &&
|
||||
typeof options.stageAuthorityFactory !== 'function') ||
|
||||
(options.resourceByteSource !== undefined &&
|
||||
(!options.resourceByteSource ||
|
||||
typeof options.resourceByteSource.open !== 'function')) ||
|
||||
typeof options.trustAuthorityId !== 'string' ||
|
||||
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(
|
||||
options.trustAuthorityId,
|
||||
) ||
|
||||
typeof options.now !== 'function' ||
|
||||
(options.pageSize !== undefined &&
|
||||
(!Number.isSafeInteger(options.pageSize) ||
|
||||
options.pageSize < 1 ||
|
||||
options.pageSize >
|
||||
Math.min(
|
||||
MAX_PLUGIN_PACKAGE_INSTALL_RECOVERY_PAGE_SIZE,
|
||||
MAX_PLUGIN_PACKAGE_TASK_PUBLICATION_RECOVERY_PAGE_SIZE,
|
||||
MAX_PROJECT_TOOL_SNAPSHOT_RECOVERY_PAGE_SIZE,
|
||||
))) ||
|
||||
(options.maxPages !== undefined &&
|
||||
(!Number.isSafeInteger(options.maxPages) ||
|
||||
options.maxPages < 1 ||
|
||||
options.maxPages >
|
||||
Math.min(
|
||||
MAX_PLUGIN_PACKAGE_RECOVERY_PAGES,
|
||||
MAX_PLUGIN_PACKAGE_TASK_PUBLICATION_RECOVERY_PAGES,
|
||||
MAX_PROJECT_TOOL_SNAPSHOT_RECOVERY_PAGES,
|
||||
)))
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster Plugin Package recovery configuration is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertStageAuthority(
|
||||
stageAuthority: ClusterPluginPackageStageAuthority,
|
||||
hasResourceByteSource: boolean,
|
||||
): void {
|
||||
if (
|
||||
!stageAuthority ||
|
||||
typeof stageAuthority.stage !== 'function' ||
|
||||
typeof stageAuthority.publisherEvidence !== 'function' ||
|
||||
typeof stageAuthority.verify !== 'function' ||
|
||||
(!hasResourceByteSource &&
|
||||
!(stageAuthority instanceof ClusterPluginPackageOciStageAuthority))
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster Plugin Package recovery stage authority is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot admin Job composition. The database is always closed before this
|
||||
* function settles, and no repository or Kubernetes authority escapes.
|
||||
*/
|
||||
export async function recoverClusterPluginPackages(
|
||||
options: ClusterPluginPackageRecoveryOptions,
|
||||
): Promise<Readonly<ClusterPluginPackageRecoveryResult>> {
|
||||
assertOptions(options);
|
||||
|
||||
let database: PostgresDatabaseResource | undefined;
|
||||
let result: Readonly<ClusterPluginPackageRecoveryResult> | undefined;
|
||||
let failure: unknown;
|
||||
try {
|
||||
database = await options.openDatabase();
|
||||
const evidence = await assertPostgresPackageExecutorSchemaReady(
|
||||
database.pool,
|
||||
);
|
||||
const stageAuthority =
|
||||
options.stageAuthority ??
|
||||
(await options.stageAuthorityFactory!(database.pool));
|
||||
assertStageAuthority(
|
||||
stageAuthority,
|
||||
options.resourceByteSource !== undefined,
|
||||
);
|
||||
const installRepository = new PostgresPluginPackageInstallRepository(
|
||||
database.pool,
|
||||
);
|
||||
const provenanceRepository =
|
||||
new PostgresPluginPackagePublisherProvenanceRepository(database.pool);
|
||||
const provenanceRecovery =
|
||||
await recoverClusterPluginPackagePublisherProvenance(
|
||||
installRepository,
|
||||
provenanceRepository,
|
||||
stageAuthority,
|
||||
{
|
||||
trustAuthorityId: options.trustAuthorityId,
|
||||
...(options.pageSize === undefined
|
||||
? {}
|
||||
: { pageSize: options.pageSize }),
|
||||
...(options.maxPages === undefined
|
||||
? {}
|
||||
: { maxPages: options.maxPages }),
|
||||
},
|
||||
);
|
||||
if (!provenanceRecovery.safeToAdmit) {
|
||||
throw new ClusterPluginPackagePublisherProvenanceRecoveryRequiredError(
|
||||
provenanceRecovery,
|
||||
);
|
||||
}
|
||||
const repository = new ClusterPluginPackageProvenanceInstallRepository(
|
||||
installRepository,
|
||||
provenanceRepository,
|
||||
stageAuthority,
|
||||
options.trustAuthorityId,
|
||||
);
|
||||
const publisher = new PluginPackageKubernetesActivationPublisher(
|
||||
options.api,
|
||||
{
|
||||
async verify(intent) {
|
||||
try {
|
||||
const [record, lock] = await Promise.all([
|
||||
repository.find(intent.projectId, intent.packageName),
|
||||
repository.findLock(intent.lockDigest),
|
||||
]);
|
||||
if (
|
||||
!record ||
|
||||
!lock ||
|
||||
record.installationId !== intent.installationId ||
|
||||
record.lockDigest !== intent.lockDigest ||
|
||||
record.stageReceipt === null ||
|
||||
record.stageReceipt.stageRef !== intent.stageRef ||
|
||||
record.stageReceipt.receiptDigest !== intent.stageReceiptDigest ||
|
||||
record.stageReceipt.evidenceDigest !==
|
||||
intent.stageEvidenceDigest ||
|
||||
record.stageReceipt.contentDigest !== intent.contentDigest
|
||||
) {
|
||||
return pluginPackageStageVerificationFailure(
|
||||
new Error('durable stage identity conflict'),
|
||||
);
|
||||
}
|
||||
assertPluginPackageInstallMatchesLock(lock, record);
|
||||
await stageAuthority.verify(lock, record.stageReceipt);
|
||||
await provenanceRepository.assertInstallationNotRevoked(
|
||||
record.installationId,
|
||||
);
|
||||
return clusterPluginPackageActivationEvidence(intent);
|
||||
} catch (error) {
|
||||
return pluginPackageStageVerificationFailure(error);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
clusterIdentity: options.clusterIdentity,
|
||||
namespace: options.namespace,
|
||||
now: options.now,
|
||||
},
|
||||
);
|
||||
const resourceByteSource =
|
||||
options.resourceByteSource ??
|
||||
new ClusterPluginPackageOciResourceByteSource({
|
||||
authority:
|
||||
stageAuthority as ClusterPluginPackageOciStageAuthority,
|
||||
lockSource: repository,
|
||||
});
|
||||
const recovery = await new PluginPackageRecoveryCoordinator({
|
||||
repository,
|
||||
stageProvider: stageAuthority,
|
||||
publisher,
|
||||
now: options.now,
|
||||
}).recover({
|
||||
...(options.pageSize === undefined ? {} : { pageSize: options.pageSize }),
|
||||
...(options.maxPages === undefined ? {} : { maxPages: options.maxPages }),
|
||||
});
|
||||
if (!recovery.safeToAdmit) {
|
||||
throw new ClusterPluginPackageRecoveryRequiredError(recovery);
|
||||
}
|
||||
const taskSpecSemanticRegistry = createBuiltInTaskSpecSemanticRegistry();
|
||||
const taskReconciliationRepository =
|
||||
new PostgresPluginPackageTaskReconciliationRepository(
|
||||
database.pool,
|
||||
taskSpecSemanticRegistry,
|
||||
);
|
||||
const materializedRepository =
|
||||
new PostgresPluginPackageMaterializedRevisionRepository(
|
||||
database.pool,
|
||||
taskSpecSemanticRegistry,
|
||||
);
|
||||
const taskPublicationRecovery =
|
||||
await new PluginPackageTaskPublicationRecoveryCoordinator({
|
||||
source: taskReconciliationRepository,
|
||||
publisher: new PluginPackageTaskPublicationCoordinator({
|
||||
generationSource: publisher,
|
||||
lockSource: repository,
|
||||
byteSource: resourceByteSource,
|
||||
materializedRepository,
|
||||
reconciliationRepository: taskReconciliationRepository,
|
||||
taskSpecSemanticRegistry,
|
||||
}),
|
||||
}).recover({
|
||||
...(options.pageSize === undefined
|
||||
? {}
|
||||
: { pageSize: options.pageSize }),
|
||||
...(options.maxPages === undefined
|
||||
? {}
|
||||
: { maxPages: options.maxPages }),
|
||||
});
|
||||
if (!taskPublicationRecovery.safeToAdmit) {
|
||||
throw new ClusterPluginPackageTaskPublicationRequiredError(
|
||||
taskPublicationRecovery,
|
||||
);
|
||||
}
|
||||
const automationPublicationRepository =
|
||||
new PostgresPluginPackageAutomationPublicationRepository(database.pool);
|
||||
const automationPublicationRecovery =
|
||||
await new PluginPackageAutomationPublicationRecoveryCoordinator({
|
||||
source: automationPublicationRepository,
|
||||
publisher: new PluginPackageAutomationPublicationCoordinator({
|
||||
generationSource: publisher,
|
||||
materializedRepository,
|
||||
repository: automationPublicationRepository,
|
||||
taskSpecSemanticRegistry,
|
||||
now: options.now,
|
||||
}),
|
||||
}).recover({
|
||||
...(options.pageSize === undefined
|
||||
? {}
|
||||
: { pageSize: options.pageSize }),
|
||||
...(options.maxPages === undefined
|
||||
? {}
|
||||
: { maxPages: options.maxPages }),
|
||||
});
|
||||
if (!automationPublicationRecovery.safeToAdmit) {
|
||||
throw new ClusterPluginPackageAutomationPublicationRequiredError(
|
||||
automationPublicationRecovery,
|
||||
);
|
||||
}
|
||||
const toolSnapshotRepository =
|
||||
new PostgresProjectToolDefinitionSnapshotRepository(database.pool);
|
||||
const toolSnapshotRecovery =
|
||||
await new ProjectToolDefinitionSnapshotRecoveryCoordinator({
|
||||
source: toolSnapshotRepository,
|
||||
publisher: new ProjectToolDefinitionSnapshotPublicationCoordinator({
|
||||
source: toolSnapshotRepository,
|
||||
materializedRepository,
|
||||
repository: toolSnapshotRepository,
|
||||
taskSpecSemanticRegistry,
|
||||
pageSize: Math.min(
|
||||
options.pageSize ?? 16,
|
||||
MAX_PROJECT_TOOL_SNAPSHOT_SOURCE_PAGE_SIZE,
|
||||
),
|
||||
}),
|
||||
}).recover({
|
||||
...(options.pageSize === undefined
|
||||
? {}
|
||||
: { pageSize: options.pageSize }),
|
||||
...(options.maxPages === undefined
|
||||
? {}
|
||||
: { maxPages: options.maxPages }),
|
||||
});
|
||||
if (!toolSnapshotRecovery.safeToAdmit) {
|
||||
throw new ClusterPluginPackageToolSnapshotRequiredError(
|
||||
toolSnapshotRecovery,
|
||||
);
|
||||
}
|
||||
result = Object.freeze({
|
||||
evidence,
|
||||
provenanceRecovery,
|
||||
recovery,
|
||||
taskPublicationRecovery,
|
||||
automationPublicationRecovery,
|
||||
toolSnapshotRecovery,
|
||||
});
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
|
||||
if (database) {
|
||||
try {
|
||||
await database.close();
|
||||
} catch (closeError) {
|
||||
if (failure !== undefined) {
|
||||
throw new AggregateError(
|
||||
[failure, closeError],
|
||||
'Cluster Plugin Package recovery failed and PostgreSQL did not close',
|
||||
);
|
||||
}
|
||||
throw closeError;
|
||||
}
|
||||
}
|
||||
if (failure !== undefined) throw failure;
|
||||
if (!result) {
|
||||
throw new Error('Cluster Plugin Package recovery produced no result');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
// Cluster Plugin Package recovery boundary; keep the operational CLI explicit.
|
||||
|
||||
import { runClusterPluginPackageRecoveryProcess } from './pluginPackageRecoveryProcess';
|
||||
|
||||
const USAGE = 'Usage: ql3-plugin-package-recover';
|
||||
|
||||
function failureFact(error: unknown): Readonly<Record<string, unknown>> {
|
||||
const candidate = error as {
|
||||
readonly name?: unknown;
|
||||
readonly code?: unknown;
|
||||
};
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-plugin-package-recovery',
|
||||
event: 'recovery_failed',
|
||||
name:
|
||||
typeof candidate?.name === 'string' && candidate.name.length <= 128
|
||||
? candidate.name
|
||||
: 'Error',
|
||||
...(typeof candidate?.code === 'string' && candidate.code.length <= 128
|
||||
? { code: candidate.code }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
if (argv.length !== 0) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'QL3_PLUGIN_PACKAGE_RECOVERY_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await runClusterPluginPackageRecoveryProcess({
|
||||
environment: process.env,
|
||||
emit(record) {
|
||||
process.stdout.write(`${JSON.stringify(record)}\n`);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
process.stderr.write(`${JSON.stringify(failureFact(error))}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void main(process.argv.slice(2));
|
||||
+885
@@ -0,0 +1,885 @@
|
||||
// Cluster Plugin Package recovery boundary; keep process composition explicit.
|
||||
import { closeSync, constants, fstatSync, openSync, readSync } from 'node:fs';
|
||||
import { isAbsolute } from 'node:path';
|
||||
|
||||
import type {
|
||||
OpenPostgresDatabase,
|
||||
PostgresPool,
|
||||
} from '@qinglong/runtime-core';
|
||||
import { MAX_PLUGIN_PACKAGE_INSTALL_RECOVERY_PAGE_SIZE } from '@qinglong/runtime-core/plugin-package-install';
|
||||
import {
|
||||
PluginPackagePublisherTrustRegistry,
|
||||
type PluginPackagePublisherKeyDefinition,
|
||||
} from '@qinglong/runtime-core/plugin-package-bundle';
|
||||
import {
|
||||
createPluginPackagePublisherTrustSnapshot,
|
||||
createPluginPackagePublisherEffectiveTrustRegistry,
|
||||
type PluginPackagePublisherTrustSnapshot,
|
||||
} from '@qinglong/runtime-core/plugin-package-publisher-trust';
|
||||
import {
|
||||
MAX_PLUGIN_PACKAGE_RECOVERY_PAGES,
|
||||
type PluginPackageRecoveryCycleResult,
|
||||
} from '@qinglong/runtime-core/plugin-package-recovery';
|
||||
import type { PluginPackageAutomationPublicationRecoveryCycleResult } from '@qinglong/runtime-core/plugin-package-automation-publication';
|
||||
import type { PluginPackageResourceByteSource } from '@qinglong/runtime-core/plugin-package-resource-materialization';
|
||||
import type { PluginPackageTaskPublicationRecoveryCycleResult } from '@qinglong/runtime-core/plugin-package-task-publication';
|
||||
import type { ProjectToolDefinitionSnapshotRecoveryCycleResult } from '@qinglong/runtime-core/project-tool-definition-snapshot';
|
||||
import {
|
||||
createPostgresDatabaseOpener,
|
||||
isPostgresTlsDnsServername,
|
||||
loadPostgresCertificateAuthorityFile,
|
||||
loadPostgresConnectionEnvironment,
|
||||
PostgresPluginPackagePublisherTrustAuthorityRepository,
|
||||
type PostgresConnectionOptions,
|
||||
type PostgresPoolOptions,
|
||||
} from '@qinglong/cluster-postgres/package-executor';
|
||||
|
||||
import {
|
||||
recoverClusterPluginPackages,
|
||||
type ClusterPluginPackageRecoveryResult,
|
||||
} from './pluginPackageRecovery';
|
||||
import type { ClusterPluginPackagePublisherProvenanceRecoveryResult } from '../publisher/pluginPackagePublisherProvenanceRecovery';
|
||||
import {
|
||||
ClusterPluginPackageOciStageAuthority,
|
||||
type ClusterPluginPackageOciFetch,
|
||||
type ClusterPluginPackageRegistryCredentialProvider,
|
||||
type ClusterPluginPackageStageAuthority,
|
||||
} from './pluginPackageOciStage';
|
||||
import type { PluginPackageKubernetesConfigMapApi } from './pluginPackageKubernetesActivation';
|
||||
|
||||
export type ClusterPluginPackageRecoveryProcessEnvironment = Readonly<
|
||||
Record<string, string | undefined>
|
||||
>;
|
||||
|
||||
export interface ClusterPluginPackageRecoveryProcessConfig {
|
||||
readonly clusterIdentity: string;
|
||||
readonly namespace: string;
|
||||
readonly allowedRegistries: readonly string[];
|
||||
readonly publisherTrustFile: string;
|
||||
readonly publisherTrustAuthorityId: string;
|
||||
readonly registryCredentialFile?: string;
|
||||
readonly requestTimeoutMs: number;
|
||||
readonly pageSize: number;
|
||||
readonly maxPages: number;
|
||||
readonly database: Readonly<{
|
||||
connection: PostgresConnectionOptions;
|
||||
pool: PostgresPoolOptions;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageRecoveryProcessEvent {
|
||||
readonly schemaVersion: 1;
|
||||
readonly component: 'qinglong3-plugin-package-recovery';
|
||||
readonly event: 'recovery_started' | 'recovery_completed';
|
||||
readonly clusterIdentity: string;
|
||||
readonly provenanceRecovery?: Readonly<ClusterPluginPackagePublisherProvenanceRecoveryResult>;
|
||||
readonly recovery?: Readonly<PluginPackageRecoveryCycleResult>;
|
||||
readonly taskPublicationRecovery?: Readonly<PluginPackageTaskPublicationRecoveryCycleResult>;
|
||||
readonly automationPublicationRecovery?: Readonly<PluginPackageAutomationPublicationRecoveryCycleResult>;
|
||||
readonly toolSnapshotRecovery?: Readonly<ProjectToolDefinitionSnapshotRecoveryCycleResult>;
|
||||
}
|
||||
|
||||
export interface RunClusterPluginPackageRecoveryProcessOptions {
|
||||
readonly environment: ClusterPluginPackageRecoveryProcessEnvironment;
|
||||
readonly emit?: (
|
||||
event: ClusterPluginPackageRecoveryProcessEvent,
|
||||
) => void | Promise<void>;
|
||||
readonly openDatabase?: OpenPostgresDatabase;
|
||||
readonly api?: PluginPackageKubernetesConfigMapApi;
|
||||
readonly stageAuthority?: ClusterPluginPackageStageAuthority;
|
||||
readonly resourceByteSource?: PluginPackageResourceByteSource;
|
||||
readonly trust?: PluginPackagePublisherTrustRegistry;
|
||||
readonly fetch?: ClusterPluginPackageOciFetch;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageRegistryCredentialFile
|
||||
extends ClusterPluginPackageRegistryCredentialProvider {
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackagePublisherTrustFileEvidence {
|
||||
readonly registry: PluginPackagePublisherTrustRegistry;
|
||||
readonly snapshot: Readonly<PluginPackagePublisherTrustSnapshot>;
|
||||
readonly definitions: readonly Readonly<PluginPackagePublisherKeyDefinition>[];
|
||||
}
|
||||
|
||||
export class ClusterPluginPackageRecoveryProcessConfigError extends TypeError {
|
||||
readonly code = 'QL3_PLUGIN_PACKAGE_RECOVERY_PROCESS_CONFIG_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(
|
||||
`Plugin Package recovery process configuration is invalid: ${message}`,
|
||||
);
|
||||
this.name = 'ClusterPluginPackageRecoveryProcessConfigError';
|
||||
}
|
||||
}
|
||||
|
||||
const TRUST_SCHEMA = 'qinglong/plugin-package-publisher-trust@v1';
|
||||
const REGISTRY_CREDENTIAL_SCHEMA =
|
||||
'qinglong/plugin-package-registry-credentials@v1';
|
||||
const MAX_TRUST_FILE_BYTES = 256 * 1024;
|
||||
const MAX_REGISTRY_CREDENTIAL_FILE_BYTES = 256 * 1024;
|
||||
const MAX_REGISTRY_CREDENTIALS = 32;
|
||||
const SAFE_IDENTITY = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/;
|
||||
const DNS_LABEL = /^[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?$/;
|
||||
const REGISTRY =
|
||||
/^([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*)(?::([1-9][0-9]{0,4}))?$/;
|
||||
const BEARER_TOKEN = /^[A-Za-z0-9._~+/-]+={0,2}$/;
|
||||
|
||||
class LoadedClusterPluginPackageRegistryCredentialFile
|
||||
implements ClusterPluginPackageRegistryCredentialFile
|
||||
{
|
||||
readonly #authorizations: Map<string, Buffer>;
|
||||
|
||||
constructor(authorizations: Map<string, Buffer>) {
|
||||
this.#authorizations = authorizations;
|
||||
}
|
||||
|
||||
authorizationFor(registry: string): string | undefined {
|
||||
if (typeof registry !== 'string' || !REGISTRY.test(registry)) {
|
||||
return undefined;
|
||||
}
|
||||
return this.#authorizations.get(registry)?.toString('ascii');
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const authorization of this.#authorizations.values()) {
|
||||
authorization.fill(0);
|
||||
}
|
||||
this.#authorizations.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function boundedValue(
|
||||
environment: ClusterPluginPackageRecoveryProcessEnvironment,
|
||||
name: string,
|
||||
maximumLength: number,
|
||||
required = false,
|
||||
): string | undefined {
|
||||
const value = environment[name];
|
||||
if (value === undefined || value === '') {
|
||||
if (required) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
`${name} is required`,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (value.length > maximumLength || /[\0\r\n]/.test(value)) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
`${name} is invalid`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function booleanValue(
|
||||
environment: ClusterPluginPackageRecoveryProcessEnvironment,
|
||||
name: string,
|
||||
): boolean {
|
||||
const value = environment[name];
|
||||
if (value === undefined || value === '') return false;
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
`${name} must be true or false`,
|
||||
);
|
||||
}
|
||||
|
||||
function integerValue(
|
||||
environment: ClusterPluginPackageRecoveryProcessEnvironment,
|
||||
name: string,
|
||||
defaultValue: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
const value = environment[name];
|
||||
if (value === undefined || value === '') return defaultValue;
|
||||
if (!/^\d+$/.test(value)) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
`${name} must be an integer`,
|
||||
);
|
||||
}
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
`${name} must be between ${minimum} and ${maximum}`,
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function loadConnection(
|
||||
environment: ClusterPluginPackageRecoveryProcessEnvironment,
|
||||
): Readonly<{
|
||||
connection: PostgresConnectionOptions;
|
||||
pool: PostgresPoolOptions;
|
||||
}> {
|
||||
let connection: PostgresConnectionOptions;
|
||||
try {
|
||||
connection = loadPostgresConnectionEnvironment(environment, {
|
||||
connectionString: 'QL3_POSTGRES_PACKAGE_EXECUTOR_URL',
|
||||
host: 'QL3_POSTGRES_PACKAGE_EXECUTOR_HOST',
|
||||
port: 'QL3_POSTGRES_PACKAGE_EXECUTOR_PORT',
|
||||
database: 'QL3_POSTGRES_PACKAGE_EXECUTOR_DATABASE',
|
||||
user: 'QL3_POSTGRES_PACKAGE_EXECUTOR_USER',
|
||||
password: 'QL3_POSTGRES_PACKAGE_EXECUTOR_PASSWORD',
|
||||
});
|
||||
} catch (error) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'PostgreSQL Package executor connection is invalid',
|
||||
);
|
||||
}
|
||||
const mode = environment.QL3_POSTGRES_TLS_MODE ?? 'verify-full';
|
||||
if (mode !== 'verify-full' && mode !== 'disable') {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'QL3_POSTGRES_TLS_MODE must be verify-full or disable',
|
||||
);
|
||||
}
|
||||
if (
|
||||
mode === 'disable' &&
|
||||
!booleanValue(environment, 'QL3_POSTGRES_ALLOW_INSECURE')
|
||||
) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'disabling PostgreSQL TLS requires QL3_POSTGRES_ALLOW_INSECURE=true',
|
||||
);
|
||||
}
|
||||
const servername = boundedValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_TLS_SERVERNAME',
|
||||
253,
|
||||
);
|
||||
if (mode === 'verify-full' && !isPostgresTlsDnsServername(servername)) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'QL3_POSTGRES_TLS_SERVERNAME must be an explicit DNS name for verify-full',
|
||||
);
|
||||
}
|
||||
const certificateAuthorityFile = boundedValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_TLS_CA_FILE',
|
||||
4096,
|
||||
);
|
||||
if (mode === 'disable' && certificateAuthorityFile !== undefined) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'QL3_POSTGRES_TLS_CA_FILE cannot be used when TLS is disabled',
|
||||
);
|
||||
}
|
||||
let certificateAuthority: string | undefined;
|
||||
if (certificateAuthorityFile !== undefined) {
|
||||
try {
|
||||
certificateAuthority = loadPostgresCertificateAuthorityFile(
|
||||
certificateAuthorityFile,
|
||||
);
|
||||
} catch {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'QL3_POSTGRES_TLS_CA_FILE must contain a bounded trusted CA bundle',
|
||||
);
|
||||
}
|
||||
}
|
||||
const applicationName =
|
||||
boundedValue(environment, 'QL3_POSTGRES_APPLICATION_NAME', 63) ??
|
||||
'qinglong3-plugin-package-recovery';
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,62}$/.test(applicationName)) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'QL3_POSTGRES_APPLICATION_NAME is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
connection: Object.freeze({
|
||||
...connection,
|
||||
tls:
|
||||
mode === 'disable'
|
||||
? Object.freeze({ mode: 'disable' as const })
|
||||
: Object.freeze({
|
||||
mode: 'verify-full' as const,
|
||||
...(certificateAuthority === undefined
|
||||
? {}
|
||||
: { ca: certificateAuthority }),
|
||||
servername: servername!,
|
||||
}),
|
||||
}),
|
||||
pool: Object.freeze({
|
||||
applicationName,
|
||||
maxConnections: 1,
|
||||
connectionTimeoutMs: 15_000,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function loadClusterPluginPackageRecoveryProcessConfig(
|
||||
environment: ClusterPluginPackageRecoveryProcessEnvironment,
|
||||
): Readonly<ClusterPluginPackageRecoveryProcessConfig> {
|
||||
if (
|
||||
!environment ||
|
||||
typeof environment !== 'object' ||
|
||||
Array.isArray(environment)
|
||||
) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'environment must be an object',
|
||||
);
|
||||
}
|
||||
const clusterIdentity = boundedValue(
|
||||
environment,
|
||||
'QL3_CLUSTER_IDENTITY',
|
||||
256,
|
||||
true,
|
||||
)!;
|
||||
const namespace = boundedValue(
|
||||
environment,
|
||||
'QL3_KUBERNETES_NAMESPACE',
|
||||
63,
|
||||
true,
|
||||
)!;
|
||||
const registryValue = boundedValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_OCI_REGISTRIES',
|
||||
4096,
|
||||
true,
|
||||
)!;
|
||||
const allowedRegistries = registryValue.split(',');
|
||||
const publisherTrustFile = boundedValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_PUBLISHER_TRUST_FILE',
|
||||
4096,
|
||||
true,
|
||||
)!;
|
||||
const registryCredentialFile = boundedValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_REGISTRY_CREDENTIAL_FILE',
|
||||
4096,
|
||||
);
|
||||
if (
|
||||
!SAFE_IDENTITY.test(clusterIdentity) ||
|
||||
!DNS_LABEL.test(namespace) ||
|
||||
allowedRegistries.length < 1 ||
|
||||
allowedRegistries.length > 32 ||
|
||||
allowedRegistries.some((registry) => !REGISTRY.test(registry)) ||
|
||||
new Set(allowedRegistries).size !== allowedRegistries.length ||
|
||||
!isAbsolute(publisherTrustFile) ||
|
||||
(registryCredentialFile !== undefined &&
|
||||
!isAbsolute(registryCredentialFile))
|
||||
) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'cluster, namespace, registry or publisher trust binding is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
clusterIdentity,
|
||||
namespace,
|
||||
allowedRegistries: Object.freeze(allowedRegistries),
|
||||
publisherTrustFile,
|
||||
publisherTrustAuthorityId:
|
||||
boundedValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_TRUST_AUTHORITY_ID',
|
||||
128,
|
||||
) ?? 'cluster',
|
||||
...(registryCredentialFile === undefined ? {} : { registryCredentialFile }),
|
||||
requestTimeoutMs: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_OCI_TIMEOUT_MS',
|
||||
15_000,
|
||||
1_000,
|
||||
60_000,
|
||||
),
|
||||
pageSize: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_RECOVERY_PAGE_SIZE',
|
||||
16,
|
||||
1,
|
||||
MAX_PLUGIN_PACKAGE_INSTALL_RECOVERY_PAGE_SIZE,
|
||||
),
|
||||
maxPages: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_RECOVERY_MAX_PAGES',
|
||||
16,
|
||||
1,
|
||||
MAX_PLUGIN_PACKAGE_RECOVERY_PAGES,
|
||||
),
|
||||
database: loadConnection(environment),
|
||||
});
|
||||
}
|
||||
|
||||
function readClusterPluginPackageRegistryCredentialFile(
|
||||
filePath: string,
|
||||
): Buffer {
|
||||
if (
|
||||
typeof filePath !== 'string' ||
|
||||
filePath.length < 1 ||
|
||||
filePath.length > 4096 ||
|
||||
/[\0\r\n]/.test(filePath) ||
|
||||
!isAbsolute(filePath)
|
||||
) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'registry credential file path is invalid',
|
||||
);
|
||||
}
|
||||
let descriptor: number;
|
||||
try {
|
||||
descriptor = openSync(filePath, constants.O_RDONLY);
|
||||
} catch {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'registry credential file is unavailable',
|
||||
);
|
||||
}
|
||||
try {
|
||||
const stat = fstatSync(descriptor);
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
(stat.mode & 0o027) !== 0 ||
|
||||
!Number.isSafeInteger(stat.size) ||
|
||||
stat.size < 1 ||
|
||||
stat.size > MAX_REGISTRY_CREDENTIAL_FILE_BYTES
|
||||
) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'registry credential file is not a bounded private regular file',
|
||||
);
|
||||
}
|
||||
const bytes = Buffer.alloc(stat.size + 1);
|
||||
let offset = 0;
|
||||
while (offset < bytes.byteLength) {
|
||||
const count = readSync(
|
||||
descriptor,
|
||||
bytes,
|
||||
offset,
|
||||
bytes.byteLength - offset,
|
||||
null,
|
||||
);
|
||||
if (count === 0) break;
|
||||
offset += count;
|
||||
}
|
||||
if (offset !== stat.size) {
|
||||
bytes.fill(0);
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'registry credential file changed while reading',
|
||||
);
|
||||
}
|
||||
return bytes.subarray(0, offset);
|
||||
} finally {
|
||||
closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function credentialRecord(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.getPrototypeOf(value) !== Object.prototype
|
||||
) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
`${label} must be an object`,
|
||||
);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function scrubCredentialSource(value: unknown): void {
|
||||
if (!value || typeof value !== 'object') return;
|
||||
const credentials = (value as { credentials?: unknown }).credentials;
|
||||
if (!Array.isArray(credentials)) return;
|
||||
for (const candidate of credentials) {
|
||||
if (!candidate || typeof candidate !== 'object') continue;
|
||||
const record = candidate as Record<string, unknown>;
|
||||
if (typeof record.password === 'string') record.password = '';
|
||||
if (typeof record.token === 'string') record.token = '';
|
||||
}
|
||||
}
|
||||
|
||||
function zeroAuthorizations(authorizations: Map<string, Buffer>): void {
|
||||
for (const authorization of authorizations.values()) {
|
||||
authorization.fill(0);
|
||||
}
|
||||
authorizations.clear();
|
||||
}
|
||||
|
||||
export function loadClusterPluginPackageRegistryCredentialFile(
|
||||
filePath: string,
|
||||
allowedRegistries: readonly string[],
|
||||
): ClusterPluginPackageRegistryCredentialFile {
|
||||
if (
|
||||
!Array.isArray(allowedRegistries) ||
|
||||
allowedRegistries.length < 1 ||
|
||||
allowedRegistries.length > MAX_REGISTRY_CREDENTIALS ||
|
||||
allowedRegistries.some(
|
||||
(registry) => typeof registry !== 'string' || !REGISTRY.test(registry),
|
||||
) ||
|
||||
new Set(allowedRegistries).size !== allowedRegistries.length
|
||||
) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'registry credential allowlist is invalid',
|
||||
);
|
||||
}
|
||||
const bytes = readClusterPluginPackageRegistryCredentialFile(filePath);
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
|
||||
} catch {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'registry credential file is not valid JSON',
|
||||
);
|
||||
} finally {
|
||||
bytes.fill(0);
|
||||
}
|
||||
const authorizations = new Map<string, Buffer>();
|
||||
try {
|
||||
const root = credentialRecord(value, 'registry credential file');
|
||||
if (
|
||||
Object.keys(root).sort().join(',') !== 'credentials,schema' ||
|
||||
root.schema !== REGISTRY_CREDENTIAL_SCHEMA ||
|
||||
!Array.isArray(root.credentials) ||
|
||||
root.credentials.length < 1 ||
|
||||
root.credentials.length > MAX_REGISTRY_CREDENTIALS
|
||||
) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'registry credential file shape is invalid',
|
||||
);
|
||||
}
|
||||
const allowed = new Set(allowedRegistries);
|
||||
for (const [index, candidate] of root.credentials.entries()) {
|
||||
const entry = credentialRecord(candidate, `registry credential ${index}`);
|
||||
const registry = entry.registry;
|
||||
const scheme = entry.scheme;
|
||||
if (
|
||||
typeof registry !== 'string' ||
|
||||
!REGISTRY.test(registry) ||
|
||||
!allowed.has(registry) ||
|
||||
authorizations.has(registry)
|
||||
) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
`registry credential ${index} binding is invalid`,
|
||||
);
|
||||
}
|
||||
let authorization: Buffer;
|
||||
if (scheme === 'basic') {
|
||||
if (
|
||||
Object.keys(entry).sort().join(',') !==
|
||||
'password,registry,scheme,username' ||
|
||||
typeof entry.username !== 'string' ||
|
||||
Buffer.byteLength(entry.username, 'utf8') < 1 ||
|
||||
Buffer.byteLength(entry.username, 'utf8') > 256 ||
|
||||
/[\0-\x1f\x7f:]/.test(entry.username) ||
|
||||
typeof entry.password !== 'string' ||
|
||||
Buffer.byteLength(entry.password, 'utf8') < 1 ||
|
||||
Buffer.byteLength(entry.password, 'utf8') > 4096 ||
|
||||
/[\0-\x1f\x7f]/.test(entry.password)
|
||||
) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
`registry credential ${index} basic value is invalid`,
|
||||
);
|
||||
}
|
||||
const userPassword = Buffer.from(
|
||||
`${entry.username}:${entry.password}`,
|
||||
'utf8',
|
||||
);
|
||||
try {
|
||||
authorization = Buffer.from(
|
||||
`Basic ${userPassword.toString('base64')}`,
|
||||
'ascii',
|
||||
);
|
||||
} finally {
|
||||
userPassword.fill(0);
|
||||
}
|
||||
} else if (scheme === 'bearer') {
|
||||
if (
|
||||
Object.keys(entry).sort().join(',') !== 'registry,scheme,token' ||
|
||||
typeof entry.token !== 'string' ||
|
||||
entry.token.length < 1 ||
|
||||
entry.token.length > 8192 ||
|
||||
!BEARER_TOKEN.test(entry.token)
|
||||
) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
`registry credential ${index} bearer value is invalid`,
|
||||
);
|
||||
}
|
||||
authorization = Buffer.from(`Bearer ${entry.token}`, 'ascii');
|
||||
} else {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
`registry credential ${index} scheme is invalid`,
|
||||
);
|
||||
}
|
||||
authorizations.set(registry, authorization);
|
||||
}
|
||||
return new LoadedClusterPluginPackageRegistryCredentialFile(authorizations);
|
||||
} catch (error) {
|
||||
zeroAuthorizations(authorizations);
|
||||
throw error;
|
||||
} finally {
|
||||
scrubCredentialSource(value);
|
||||
}
|
||||
}
|
||||
|
||||
export function loadClusterPluginPackagePublisherTrustFileEvidence(
|
||||
filePath: string,
|
||||
): Readonly<ClusterPluginPackagePublisherTrustFileEvidence> {
|
||||
if (
|
||||
typeof filePath !== 'string' ||
|
||||
filePath.length < 1 ||
|
||||
filePath.length > 4096 ||
|
||||
/[\0\r\n]/.test(filePath) ||
|
||||
!isAbsolute(filePath)
|
||||
) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'publisher trust file path is invalid',
|
||||
);
|
||||
}
|
||||
let descriptor: number;
|
||||
try {
|
||||
descriptor = openSync(filePath, constants.O_RDONLY);
|
||||
} catch {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'publisher trust file is unavailable',
|
||||
);
|
||||
}
|
||||
let bytes: Buffer;
|
||||
try {
|
||||
const stat = fstatSync(descriptor);
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
(stat.mode & 0o022) !== 0 ||
|
||||
!Number.isSafeInteger(stat.size) ||
|
||||
stat.size < 1 ||
|
||||
stat.size > MAX_TRUST_FILE_BYTES
|
||||
) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'publisher trust file is not a bounded read-only regular file',
|
||||
);
|
||||
}
|
||||
bytes = Buffer.alloc(stat.size + 1);
|
||||
let offset = 0;
|
||||
while (offset < bytes.byteLength) {
|
||||
const count = readSync(
|
||||
descriptor,
|
||||
bytes,
|
||||
offset,
|
||||
bytes.byteLength - offset,
|
||||
null,
|
||||
);
|
||||
if (count === 0) break;
|
||||
offset += count;
|
||||
}
|
||||
if (offset !== stat.size) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'publisher trust file changed while reading',
|
||||
);
|
||||
}
|
||||
bytes = bytes.subarray(0, offset);
|
||||
} finally {
|
||||
closeSync(descriptor);
|
||||
}
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
|
||||
} catch {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'publisher trust file is not valid JSON',
|
||||
);
|
||||
} finally {
|
||||
bytes.fill(0);
|
||||
}
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.keys(value).sort().join(',') !== 'keys,schema' ||
|
||||
(value as { schema?: unknown }).schema !== TRUST_SCHEMA ||
|
||||
!Array.isArray((value as { keys?: unknown }).keys)
|
||||
) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'publisher trust file shape is invalid',
|
||||
);
|
||||
}
|
||||
try {
|
||||
const definitions = (
|
||||
value as { keys: PluginPackagePublisherKeyDefinition[] }
|
||||
).keys;
|
||||
const frozenDefinitions = Object.freeze(
|
||||
definitions.map((definition) => Object.freeze({ ...definition })),
|
||||
);
|
||||
return Object.freeze({
|
||||
registry: new PluginPackagePublisherTrustRegistry(frozenDefinitions),
|
||||
snapshot:
|
||||
createPluginPackagePublisherTrustSnapshot(frozenDefinitions),
|
||||
definitions: frozenDefinitions,
|
||||
});
|
||||
} catch {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'publisher trust keys are invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function loadClusterPluginPackagePublisherTrustFile(
|
||||
filePath: string,
|
||||
): PluginPackagePublisherTrustRegistry {
|
||||
return loadClusterPluginPackagePublisherTrustFileEvidence(filePath).registry;
|
||||
}
|
||||
|
||||
async function productionKubernetesApi(): Promise<PluginPackageKubernetesConfigMapApi> {
|
||||
const kubernetes = await import('@kubernetes/client-node');
|
||||
const config = new kubernetes.KubeConfig();
|
||||
config.loadFromCluster();
|
||||
return config.makeApiClient(
|
||||
kubernetes.CoreV1Api,
|
||||
) as unknown as PluginPackageKubernetesConfigMapApi;
|
||||
}
|
||||
|
||||
function processEvent(
|
||||
config: Readonly<ClusterPluginPackageRecoveryProcessConfig>,
|
||||
event: ClusterPluginPackageRecoveryProcessEvent['event'],
|
||||
provenanceRecovery?: Readonly<ClusterPluginPackagePublisherProvenanceRecoveryResult>,
|
||||
recovery?: Readonly<PluginPackageRecoveryCycleResult>,
|
||||
taskPublicationRecovery?: Readonly<PluginPackageTaskPublicationRecoveryCycleResult>,
|
||||
automationPublicationRecovery?: Readonly<PluginPackageAutomationPublicationRecoveryCycleResult>,
|
||||
toolSnapshotRecovery?: Readonly<ProjectToolDefinitionSnapshotRecoveryCycleResult>,
|
||||
): Readonly<ClusterPluginPackageRecoveryProcessEvent> {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-plugin-package-recovery',
|
||||
event,
|
||||
clusterIdentity: config.clusterIdentity,
|
||||
...(provenanceRecovery === undefined ? {} : { provenanceRecovery }),
|
||||
...(recovery === undefined ? {} : { recovery }),
|
||||
...(taskPublicationRecovery === undefined
|
||||
? {}
|
||||
: { taskPublicationRecovery }),
|
||||
...(automationPublicationRecovery === undefined
|
||||
? {}
|
||||
: { automationPublicationRecovery }),
|
||||
...(toolSnapshotRecovery === undefined ? {} : { toolSnapshotRecovery }),
|
||||
});
|
||||
}
|
||||
|
||||
async function emit(
|
||||
sink: RunClusterPluginPackageRecoveryProcessOptions['emit'],
|
||||
value: Readonly<ClusterPluginPackageRecoveryProcessEvent>,
|
||||
): Promise<void> {
|
||||
if (!sink) return;
|
||||
try {
|
||||
await sink(value);
|
||||
} catch {
|
||||
// Diagnostics cannot replace recovery or database close outcomes.
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs exactly one admin recovery cycle and owns no resident authority. */
|
||||
export async function runClusterPluginPackageRecoveryProcess(
|
||||
options: RunClusterPluginPackageRecoveryProcessOptions,
|
||||
): Promise<Readonly<ClusterPluginPackageRecoveryResult>> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
(options.emit !== undefined && typeof options.emit !== 'function') ||
|
||||
(options.openDatabase !== undefined &&
|
||||
typeof options.openDatabase !== 'function') ||
|
||||
(options.resourceByteSource !== undefined &&
|
||||
(!options.resourceByteSource ||
|
||||
typeof options.resourceByteSource.open !== 'function')) ||
|
||||
(options.fetch !== undefined && typeof options.fetch !== 'function')
|
||||
) {
|
||||
throw new TypeError('Plugin Package recovery process options are invalid');
|
||||
}
|
||||
const config = loadClusterPluginPackageRecoveryProcessConfig(
|
||||
options.environment,
|
||||
);
|
||||
const trustEvidence =
|
||||
options.trust === undefined && options.stageAuthority === undefined
|
||||
? loadClusterPluginPackagePublisherTrustFileEvidence(
|
||||
config.publisherTrustFile,
|
||||
)
|
||||
: undefined;
|
||||
const registryCredentials =
|
||||
options.stageAuthority !== undefined ||
|
||||
config.registryCredentialFile === undefined
|
||||
? undefined
|
||||
: loadClusterPluginPackageRegistryCredentialFile(
|
||||
config.registryCredentialFile,
|
||||
config.allowedRegistries,
|
||||
);
|
||||
try {
|
||||
const api = options.api ?? (await productionKubernetesApi());
|
||||
const openDatabase =
|
||||
options.openDatabase ??
|
||||
createPostgresDatabaseOpener({
|
||||
role: 'package-executor',
|
||||
connection: config.database.connection,
|
||||
pool: config.database.pool,
|
||||
onPoolError() {
|
||||
// Awaited recovery queries and final close remain authoritative.
|
||||
},
|
||||
});
|
||||
await emit(options.emit, processEvent(config, 'recovery_started'));
|
||||
const result = await recoverClusterPluginPackages({
|
||||
openDatabase,
|
||||
api,
|
||||
...(options.stageAuthority === undefined
|
||||
? {
|
||||
stageAuthorityFactory: async (pool: PostgresPool) => {
|
||||
let effectiveTrust = options.trust;
|
||||
if (effectiveTrust === undefined && trustEvidence !== undefined) {
|
||||
const authority =
|
||||
await new PostgresPluginPackagePublisherTrustAuthorityRepository(
|
||||
pool,
|
||||
).findAuthority(config.publisherTrustAuthorityId);
|
||||
if (!authority) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'durable publisher trust authority is unavailable',
|
||||
);
|
||||
}
|
||||
effectiveTrust =
|
||||
createPluginPackagePublisherEffectiveTrustRegistry(
|
||||
trustEvidence.definitions,
|
||||
authority.effectiveSnapshot,
|
||||
);
|
||||
}
|
||||
if (effectiveTrust === undefined) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'publisher trust evidence is unavailable',
|
||||
);
|
||||
}
|
||||
return new ClusterPluginPackageOciStageAuthority({
|
||||
allowedRegistries: config.allowedRegistries,
|
||||
trust: effectiveTrust,
|
||||
...(registryCredentials === undefined
|
||||
? {}
|
||||
: { credentialProvider: registryCredentials }),
|
||||
...(options.fetch === undefined
|
||||
? {}
|
||||
: { fetch: options.fetch }),
|
||||
requestTimeoutMs: config.requestTimeoutMs,
|
||||
});
|
||||
},
|
||||
}
|
||||
: { stageAuthority: options.stageAuthority }),
|
||||
...(options.resourceByteSource === undefined
|
||||
? {}
|
||||
: { resourceByteSource: options.resourceByteSource }),
|
||||
trustAuthorityId: config.publisherTrustAuthorityId,
|
||||
clusterIdentity: config.clusterIdentity,
|
||||
namespace: config.namespace,
|
||||
now: Date.now,
|
||||
pageSize: config.pageSize,
|
||||
maxPages: config.maxPages,
|
||||
});
|
||||
await emit(
|
||||
options.emit,
|
||||
processEvent(
|
||||
config,
|
||||
'recovery_completed',
|
||||
result.provenanceRecovery,
|
||||
result.recovery,
|
||||
result.taskPublicationRecovery,
|
||||
result.automationPublicationRecovery,
|
||||
result.toolSnapshotRecovery,
|
||||
),
|
||||
);
|
||||
return result;
|
||||
} finally {
|
||||
registryCredentials?.dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user