mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 01:32:44 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
// Cluster Plugin Package executor boundary; keep approved-action dispatch explicit.
|
||||
import type { PostgresPool } from '@qinglong/runtime-core';
|
||||
import {
|
||||
ApprovedActionDispatcher,
|
||||
type ApprovedActionDispatcherOptions,
|
||||
} from '@qinglong/runtime-core/approved-action-dispatcher';
|
||||
import { PluginPackageApprovedActionHandler } from '@qinglong/runtime-core/plugin-package-approved-action';
|
||||
import { PostgresApprovedActionExecutionRepository } from '@qinglong/cluster-postgres/approved-action-execution';
|
||||
import { PostgresPluginPackageInstallRepository } from '@qinglong/cluster-postgres/plugin-package-install';
|
||||
import { PostgresPluginPackageInstallProposalRepository } from '@qinglong/cluster-postgres/plugin-package-proposal';
|
||||
import {
|
||||
PostgresPluginPackagePublisherRevocationProposalRepository,
|
||||
PostgresPluginPackagePublisherTrustTransitionProposalRepository,
|
||||
PostgresPluginPackagePublisherTrustTransitionRepository,
|
||||
} from '@qinglong/cluster-postgres/package-executor';
|
||||
|
||||
import {
|
||||
ClusterPluginPackagePublisherRevocationApprovedActionHandler,
|
||||
type ClusterPluginPackagePublisherRevocationExecutionPort,
|
||||
} from '../publisher/pluginPackagePublisherRevocationApprovedAction';
|
||||
import {
|
||||
ClusterPluginPackagePublisherTrustTransitionApprovedActionHandler,
|
||||
type ClusterPluginPackagePublisherTrustTransitionExecutionPort,
|
||||
} from '../publisher/pluginPackagePublisherTrustTransitionApprovedAction';
|
||||
|
||||
export const CLUSTER_PLUGIN_PACKAGE_DISPATCH_BATCH_LIMIT = 16;
|
||||
|
||||
export interface ClusterPluginPackageApprovedActionDispatcherOptions
|
||||
extends Omit<ApprovedActionDispatcherOptions, 'defaultBatchSize'> {
|
||||
readonly pool: PostgresPool;
|
||||
readonly defaultBatchSize?: number;
|
||||
readonly publisherRevocations?: ClusterPluginPackagePublisherRevocationExecutionPort;
|
||||
readonly publisherTrustTransitions?: ClusterPluginPackagePublisherTrustTransitionExecutionPort;
|
||||
}
|
||||
|
||||
export function createClusterPluginPackageApprovedActionDispatcher(
|
||||
options: ClusterPluginPackageApprovedActionDispatcherOptions,
|
||||
): ApprovedActionDispatcher {
|
||||
if (!options || typeof options !== 'object') {
|
||||
throw new TypeError('cluster Package Approved Action options are invalid');
|
||||
}
|
||||
const {
|
||||
pool,
|
||||
defaultBatchSize,
|
||||
publisherRevocations,
|
||||
publisherTrustTransitions,
|
||||
...dispatcherOptions
|
||||
} = options;
|
||||
const executions = new PostgresApprovedActionExecutionRepository(pool);
|
||||
const handler = new PluginPackageApprovedActionHandler(
|
||||
new PostgresPluginPackageInstallProposalRepository(pool),
|
||||
new PostgresPluginPackageInstallRepository(pool),
|
||||
);
|
||||
const handlers = [
|
||||
handler,
|
||||
...(['overlap_add', 'safe_retire'] as const).map(
|
||||
(mode) =>
|
||||
new ClusterPluginPackagePublisherTrustTransitionApprovedActionHandler(
|
||||
mode,
|
||||
new PostgresPluginPackagePublisherTrustTransitionProposalRepository(
|
||||
pool,
|
||||
),
|
||||
publisherTrustTransitions ??
|
||||
new PostgresPluginPackagePublisherTrustTransitionRepository(pool),
|
||||
),
|
||||
),
|
||||
...(publisherRevocations
|
||||
? [
|
||||
new ClusterPluginPackagePublisherRevocationApprovedActionHandler(
|
||||
new PostgresPluginPackagePublisherRevocationProposalRepository(
|
||||
pool,
|
||||
),
|
||||
publisherRevocations,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
];
|
||||
return new ApprovedActionDispatcher(executions, handlers, {
|
||||
...dispatcherOptions,
|
||||
defaultBatchSize:
|
||||
defaultBatchSize ?? CLUSTER_PLUGIN_PACKAGE_DISPATCH_BATCH_LIMIT,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env node
|
||||
// Cluster Plugin Package executor boundary; keep the operational CLI explicit.
|
||||
|
||||
import { runClusterPluginPackageExecutorProcess } from './pluginPackageExecutorProcess';
|
||||
|
||||
const USAGE = 'Usage: ql3-plugin-package-execute';
|
||||
|
||||
function emit(value: Readonly<Record<string, unknown>>): void {
|
||||
process.stdout.write(`${JSON.stringify(value)}\n`);
|
||||
}
|
||||
|
||||
async function run(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_EXECUTOR_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await runClusterPluginPackageExecutorProcess({
|
||||
environment: process.env,
|
||||
});
|
||||
if (result.status === 'disabled') {
|
||||
emit({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-plugin-package-executor',
|
||||
event: 'executor_disabled',
|
||||
});
|
||||
return;
|
||||
}
|
||||
emit({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-plugin-package-executor',
|
||||
event: 'executor_completed',
|
||||
databaseContractVersion: result.database.contractVersion,
|
||||
databaseMigrationCount: result.database.migrationIds.length,
|
||||
batches: result.batches,
|
||||
});
|
||||
} catch (error) {
|
||||
const candidate = error as {
|
||||
readonly name?: unknown;
|
||||
readonly code?: unknown;
|
||||
};
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-plugin-package-executor',
|
||||
event: 'executor_failed',
|
||||
name:
|
||||
typeof candidate?.name === 'string'
|
||||
? candidate.name.slice(0, 128)
|
||||
: 'Error',
|
||||
...(typeof candidate?.code === 'string'
|
||||
? { code: candidate.code.slice(0, 128) }
|
||||
: {}),
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void run(process.argv.slice(2));
|
||||
+464
@@ -0,0 +1,464 @@
|
||||
// Cluster Plugin Package executor boundary; keep process composition explicit.
|
||||
import type {
|
||||
OpenPostgresDatabase,
|
||||
PostgresDatabaseResource,
|
||||
PostgresPool,
|
||||
} from '@qinglong/runtime-core';
|
||||
import type {
|
||||
ApprovedActionDispatchBatchSummary,
|
||||
ApprovedActionDispatcher,
|
||||
} from '@qinglong/runtime-core/approved-action-dispatcher';
|
||||
import {
|
||||
assertPostgresPackageExecutorSchemaReady,
|
||||
createPostgresDatabaseOpener,
|
||||
isPostgresTlsDnsServername,
|
||||
loadPostgresCertificateAuthorityFile,
|
||||
loadPostgresConnectionEnvironment,
|
||||
type PostgresConnectionOptions,
|
||||
type PostgresPoolOptions,
|
||||
type PostgresSchemaReadinessReport,
|
||||
} from '@qinglong/cluster-postgres/package-executor';
|
||||
|
||||
import {
|
||||
createClusterPluginPackageApprovedActionDispatcher,
|
||||
type ClusterPluginPackageApprovedActionDispatcherOptions,
|
||||
} from './pluginPackageApprovedAction';
|
||||
import {
|
||||
consumeClusterPluginPackagePublisherRevocationApprovals,
|
||||
type ClusterPluginPackagePublisherRevocationApprovalSummary,
|
||||
type ConsumeClusterPluginPackagePublisherRevocationApprovalsOptions,
|
||||
} from '../publisher/pluginPackagePublisherRevocationApprovalConsumer';
|
||||
import {
|
||||
consumeClusterPluginPackagePublisherTrustTransitionApprovals,
|
||||
type ClusterPluginPackagePublisherTrustTransitionApprovalSummary,
|
||||
type ConsumeClusterPluginPackagePublisherTrustTransitionApprovalsOptions,
|
||||
} from '../publisher/pluginPackagePublisherTrustTransitionApprovalConsumer';
|
||||
import {
|
||||
runClusterPluginPackagePublisherRevocation,
|
||||
} from '../publisher/pluginPackagePublisherRevocation';
|
||||
|
||||
export type ClusterPluginPackageExecutorProcessEnvironment = Readonly<
|
||||
Record<string, string | undefined>
|
||||
>;
|
||||
|
||||
export type ClusterPluginPackageExecutorProcessConfig =
|
||||
| Readonly<{ enabled: false }>
|
||||
| Readonly<{
|
||||
enabled: true;
|
||||
owner: string;
|
||||
approvalBatchSize: number;
|
||||
dispatchBatchSize: number;
|
||||
maxBatches: number;
|
||||
leaseDurationMs: number;
|
||||
revocationPageSize: number;
|
||||
revocationMaxPages: number;
|
||||
database: Readonly<{
|
||||
connection: PostgresConnectionOptions;
|
||||
pool: PostgresPoolOptions;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
export interface ClusterPluginPackageExecutorBatchResult {
|
||||
readonly approvals: Readonly<ClusterPluginPackagePublisherRevocationApprovalSummary>;
|
||||
readonly trustTransitionApprovals: Readonly<ClusterPluginPackagePublisherTrustTransitionApprovalSummary>;
|
||||
readonly dispatch: Readonly<ApprovedActionDispatchBatchSummary>;
|
||||
}
|
||||
|
||||
export type ClusterPluginPackageExecutorProcessResult =
|
||||
| Readonly<{ status: 'disabled' }>
|
||||
| Readonly<{
|
||||
status: 'completed';
|
||||
database: PostgresSchemaReadinessReport;
|
||||
batches: readonly Readonly<ClusterPluginPackageExecutorBatchResult>[];
|
||||
}>;
|
||||
|
||||
export interface RunClusterPluginPackageExecutorProcessOptions {
|
||||
readonly environment: ClusterPluginPackageExecutorProcessEnvironment;
|
||||
readonly openDatabase?: OpenPostgresDatabase;
|
||||
readonly consumeApprovals?: (
|
||||
options: ConsumeClusterPluginPackagePublisherRevocationApprovalsOptions,
|
||||
) => Promise<
|
||||
Readonly<ClusterPluginPackagePublisherRevocationApprovalSummary>
|
||||
>;
|
||||
readonly consumeTrustTransitionApprovals?: (
|
||||
options: ConsumeClusterPluginPackagePublisherTrustTransitionApprovalsOptions,
|
||||
) => Promise<
|
||||
Readonly<ClusterPluginPackagePublisherTrustTransitionApprovalSummary>
|
||||
>;
|
||||
readonly createDispatcher?: (
|
||||
options: ClusterPluginPackageApprovedActionDispatcherOptions,
|
||||
) => ApprovedActionDispatcher;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
export class ClusterPluginPackageExecutorProcessConfigError extends TypeError {
|
||||
readonly code = 'QL3_PLUGIN_PACKAGE_EXECUTOR_PROCESS_CONFIG_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(
|
||||
`Plugin Package executor process configuration is invalid: ${message}`,
|
||||
);
|
||||
this.name = 'ClusterPluginPackageExecutorProcessConfigError';
|
||||
}
|
||||
}
|
||||
|
||||
const SAFE_OWNER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
|
||||
function enabledValue(
|
||||
environment: ClusterPluginPackageExecutorProcessEnvironment,
|
||||
): boolean {
|
||||
const value = environment.QL3_PLUGIN_PACKAGE_EXECUTOR_ENABLED;
|
||||
if (value === undefined || value === '' || value === 'false') return false;
|
||||
if (value === 'true') return true;
|
||||
throw new ClusterPluginPackageExecutorProcessConfigError(
|
||||
'QL3_PLUGIN_PACKAGE_EXECUTOR_ENABLED must be true or false',
|
||||
);
|
||||
}
|
||||
|
||||
function boundedValue(
|
||||
environment: ClusterPluginPackageExecutorProcessEnvironment,
|
||||
name: string,
|
||||
maximumLength: number,
|
||||
): string | undefined {
|
||||
const value = environment[name];
|
||||
if (value === undefined || value === '') return undefined;
|
||||
if (value.length > maximumLength || /[\0\r\n]/.test(value)) {
|
||||
throw new ClusterPluginPackageExecutorProcessConfigError(
|
||||
`${name} is invalid`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integerValue(
|
||||
environment: ClusterPluginPackageExecutorProcessEnvironment,
|
||||
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 ClusterPluginPackageExecutorProcessConfigError(
|
||||
`${name} must be an integer`,
|
||||
);
|
||||
}
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
|
||||
throw new ClusterPluginPackageExecutorProcessConfigError(
|
||||
`${name} must be between ${minimum} and ${maximum}`,
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function databaseConfig(
|
||||
environment: ClusterPluginPackageExecutorProcessEnvironment,
|
||||
): 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 ClusterPluginPackageExecutorProcessConfigError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'PostgreSQL Package executor connection is invalid',
|
||||
);
|
||||
}
|
||||
const tlsMode = environment.QL3_POSTGRES_TLS_MODE ?? 'verify-full';
|
||||
let tls: PostgresConnectionOptions['tls'];
|
||||
if (tlsMode === 'disable') {
|
||||
if (environment.QL3_POSTGRES_ALLOW_INSECURE !== 'true') {
|
||||
throw new ClusterPluginPackageExecutorProcessConfigError(
|
||||
'disabling PostgreSQL TLS requires QL3_POSTGRES_ALLOW_INSECURE=true',
|
||||
);
|
||||
}
|
||||
tls = Object.freeze({ mode: 'disable' });
|
||||
} else if (tlsMode === 'verify-full') {
|
||||
const servername = boundedValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_TLS_SERVERNAME',
|
||||
253,
|
||||
);
|
||||
if (!isPostgresTlsDnsServername(servername)) {
|
||||
throw new ClusterPluginPackageExecutorProcessConfigError(
|
||||
'QL3_POSTGRES_TLS_SERVERNAME must be an explicit DNS name for verify-full',
|
||||
);
|
||||
}
|
||||
const caFile = boundedValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_TLS_CA_FILE',
|
||||
4096,
|
||||
);
|
||||
let ca: string | undefined;
|
||||
if (caFile !== undefined) {
|
||||
try {
|
||||
ca = loadPostgresCertificateAuthorityFile(caFile);
|
||||
} catch {
|
||||
throw new ClusterPluginPackageExecutorProcessConfigError(
|
||||
'QL3_POSTGRES_TLS_CA_FILE must contain a bounded trusted CA bundle',
|
||||
);
|
||||
}
|
||||
}
|
||||
tls = Object.freeze({
|
||||
mode: 'verify-full',
|
||||
servername,
|
||||
...(ca === undefined ? {} : { ca }),
|
||||
});
|
||||
} else {
|
||||
throw new ClusterPluginPackageExecutorProcessConfigError(
|
||||
'QL3_POSTGRES_TLS_MODE must be verify-full or disable',
|
||||
);
|
||||
}
|
||||
const applicationName =
|
||||
boundedValue(environment, 'QL3_POSTGRES_APPLICATION_NAME', 63) ??
|
||||
'qinglong3-plugin-package-executor';
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,62}$/.test(applicationName)) {
|
||||
throw new ClusterPluginPackageExecutorProcessConfigError(
|
||||
'QL3_POSTGRES_APPLICATION_NAME is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
connection: Object.freeze({ ...connection, tls }),
|
||||
pool: Object.freeze({
|
||||
applicationName,
|
||||
maxConnections: integerValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_MAX_CONNECTIONS',
|
||||
2,
|
||||
1,
|
||||
4,
|
||||
),
|
||||
connectionTimeoutMs: integerValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_CONNECTION_TIMEOUT_MS',
|
||||
5_000,
|
||||
100,
|
||||
60_000,
|
||||
),
|
||||
idleTimeoutMs: integerValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_IDLE_TIMEOUT_MS',
|
||||
10_000,
|
||||
1_000,
|
||||
300_000,
|
||||
),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function loadClusterPluginPackageExecutorProcessConfig(
|
||||
environment: ClusterPluginPackageExecutorProcessEnvironment,
|
||||
): ClusterPluginPackageExecutorProcessConfig {
|
||||
if (!environment || typeof environment !== 'object') {
|
||||
throw new ClusterPluginPackageExecutorProcessConfigError(
|
||||
'environment is required',
|
||||
);
|
||||
}
|
||||
if (!enabledValue(environment)) return Object.freeze({ enabled: false });
|
||||
const owner =
|
||||
boundedValue(environment, 'QL3_PLUGIN_PACKAGE_EXECUTOR_OWNER', 128) ??
|
||||
'cluster_package_executor_1';
|
||||
if (!SAFE_OWNER.test(owner)) {
|
||||
throw new ClusterPluginPackageExecutorProcessConfigError(
|
||||
'QL3_PLUGIN_PACKAGE_EXECUTOR_OWNER is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
enabled: true,
|
||||
owner,
|
||||
approvalBatchSize: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_EXECUTOR_APPROVAL_BATCH_SIZE',
|
||||
8,
|
||||
1,
|
||||
64,
|
||||
),
|
||||
dispatchBatchSize: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_EXECUTOR_DISPATCH_BATCH_SIZE',
|
||||
8,
|
||||
1,
|
||||
64,
|
||||
),
|
||||
maxBatches: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_EXECUTOR_MAX_BATCHES',
|
||||
4,
|
||||
1,
|
||||
64,
|
||||
),
|
||||
leaseDurationMs: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_EXECUTOR_LEASE_DURATION_MS',
|
||||
600_000,
|
||||
1,
|
||||
600_000,
|
||||
),
|
||||
revocationPageSize: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_EXECUTOR_REVOCATION_PAGE_SIZE',
|
||||
16,
|
||||
1,
|
||||
128,
|
||||
),
|
||||
revocationMaxPages: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_EXECUTOR_REVOCATION_MAX_PAGES',
|
||||
16,
|
||||
1,
|
||||
64,
|
||||
),
|
||||
database: databaseConfig(environment),
|
||||
});
|
||||
}
|
||||
|
||||
function borrowedDatabase(
|
||||
database: PostgresDatabaseResource,
|
||||
): OpenPostgresDatabase {
|
||||
return async () =>
|
||||
Object.freeze({
|
||||
pool: database.pool,
|
||||
close: async () => undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function isIdleBatch(
|
||||
batch: Readonly<ClusterPluginPackageExecutorBatchResult>,
|
||||
): boolean {
|
||||
return (
|
||||
batch.approvals.scanned === 0 &&
|
||||
batch.trustTransitionApprovals.scanned === 0 &&
|
||||
batch.dispatch.scanned === 0
|
||||
);
|
||||
}
|
||||
|
||||
export async function runClusterPluginPackageExecutorProcess(
|
||||
options: RunClusterPluginPackageExecutorProcessOptions,
|
||||
): Promise<ClusterPluginPackageExecutorProcessResult> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
!options.environment ||
|
||||
(options.openDatabase !== undefined &&
|
||||
typeof options.openDatabase !== 'function') ||
|
||||
(options.consumeApprovals !== undefined &&
|
||||
typeof options.consumeApprovals !== 'function') ||
|
||||
(options.consumeTrustTransitionApprovals !== undefined &&
|
||||
typeof options.consumeTrustTransitionApprovals !== 'function') ||
|
||||
(options.createDispatcher !== undefined &&
|
||||
typeof options.createDispatcher !== 'function') ||
|
||||
(options.now !== undefined && typeof options.now !== 'function')
|
||||
) {
|
||||
throw new TypeError('Plugin Package executor process options are invalid');
|
||||
}
|
||||
const config = loadClusterPluginPackageExecutorProcessConfig(
|
||||
options.environment,
|
||||
);
|
||||
if (!config.enabled) return Object.freeze({ status: 'disabled' });
|
||||
const openDatabase =
|
||||
options.openDatabase ??
|
||||
createPostgresDatabaseOpener({
|
||||
role: 'package-executor',
|
||||
connection: config.database.connection,
|
||||
pool: config.database.pool,
|
||||
onPoolError: () => undefined,
|
||||
});
|
||||
const database = await openDatabase();
|
||||
let failure: unknown;
|
||||
try {
|
||||
const evidence = await assertPostgresPackageExecutorSchemaReady(
|
||||
database.pool,
|
||||
);
|
||||
const dispatcherFactory =
|
||||
options.createDispatcher ??
|
||||
createClusterPluginPackageApprovedActionDispatcher;
|
||||
const consumeApprovals =
|
||||
options.consumeApprovals ??
|
||||
consumeClusterPluginPackagePublisherRevocationApprovals;
|
||||
const consumeTrustTransitionApprovals =
|
||||
options.consumeTrustTransitionApprovals ??
|
||||
consumeClusterPluginPackagePublisherTrustTransitionApprovals;
|
||||
const dispatcher = dispatcherFactory({
|
||||
pool: database.pool,
|
||||
owner: config.owner,
|
||||
leaseDurationMs: config.leaseDurationMs,
|
||||
defaultBatchSize: config.dispatchBatchSize,
|
||||
...(options.now ? { clock: options.now } : {}),
|
||||
publisherRevocations: {
|
||||
async run(receipt) {
|
||||
const result = await runClusterPluginPackagePublisherRevocation({
|
||||
openDatabase: borrowedDatabase(database),
|
||||
receipt,
|
||||
// Durable proposal, dispatch, Project Policy fence and trust-head
|
||||
// generation are revalidated in the same SERIALIZABLE mutation.
|
||||
confirmAuthorization: () => undefined,
|
||||
pageSize: config.revocationPageSize,
|
||||
maxPages: config.revocationMaxPages,
|
||||
});
|
||||
return Object.freeze({
|
||||
safeToAdmit: result.safeToAdmit,
|
||||
receiptDigest: result.receiptDigest,
|
||||
impactDigest: result.impactDigest,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
const batches: Readonly<ClusterPluginPackageExecutorBatchResult>[] = [];
|
||||
for (let index = 0; index < config.maxBatches; index += 1) {
|
||||
const approvals = await consumeApprovals({
|
||||
pool: database.pool,
|
||||
limit: config.approvalBatchSize,
|
||||
...(options.now ? { now: options.now } : {}),
|
||||
});
|
||||
const trustTransitionApprovals =
|
||||
await consumeTrustTransitionApprovals({
|
||||
pool: database.pool,
|
||||
limit: config.approvalBatchSize,
|
||||
...(options.now ? { now: options.now } : {}),
|
||||
});
|
||||
const dispatch = await dispatcher.dispatchBatch({
|
||||
limit: config.dispatchBatchSize,
|
||||
});
|
||||
const batch = Object.freeze({
|
||||
approvals,
|
||||
trustTransitionApprovals,
|
||||
dispatch,
|
||||
});
|
||||
batches.push(batch);
|
||||
if (isIdleBatch(batch)) break;
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'completed',
|
||||
database: evidence,
|
||||
batches: Object.freeze([...batches]),
|
||||
});
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
throw error;
|
||||
} finally {
|
||||
try {
|
||||
await database.close();
|
||||
} catch (closeError) {
|
||||
if (failure !== undefined) {
|
||||
throw new AggregateError(
|
||||
[failure, closeError],
|
||||
'Plugin Package executor process failed and PostgreSQL did not close',
|
||||
);
|
||||
}
|
||||
throw closeError;
|
||||
}
|
||||
}
|
||||
}
|
||||
+450
@@ -0,0 +1,450 @@
|
||||
// Cluster Plugin Package lifecycle boundary; keep execution authority explicit.
|
||||
import {
|
||||
PostgresPluginPackageLifecyclePlanRepository,
|
||||
PostgresPluginPackageLifecycleRepository,
|
||||
assertPostgresPackageExecutorSchemaReady,
|
||||
type PostgresSchemaReadinessReport,
|
||||
} from '@qinglong/cluster-postgres/package-executor';
|
||||
import { PostgresApprovalRequestRepository } from '@qinglong/cluster-postgres/approved-action';
|
||||
import { PostgresProjectPolicyRepository } from '@qinglong/cluster-postgres/project-policy';
|
||||
import type {
|
||||
OpenPostgresDatabase,
|
||||
PostgresDatabaseResource,
|
||||
PostgresPool,
|
||||
} from '@qinglong/runtime-core';
|
||||
import {
|
||||
normalizeApprovalRequestRecord,
|
||||
type ApprovedActionBinding,
|
||||
type ApprovedActionDispatchRecord,
|
||||
} from '@qinglong/runtime-core/approved-action';
|
||||
import {
|
||||
createPluginPackageLifecycleEvent,
|
||||
pluginPackageLifecycleActionDigest,
|
||||
PluginPackageLifecycleConflictError,
|
||||
type PluginPackageLifecycleAction,
|
||||
type PluginPackageLifecycleReceipt,
|
||||
} from '@qinglong/runtime-core/plugin-package-lifecycle';
|
||||
import {
|
||||
MAX_PLUGIN_PACKAGE_LIFECYCLE_PLAN_LIFETIME_MS,
|
||||
PluginPackageLifecyclePlanConflictError,
|
||||
createPluginPackageLifecyclePlan,
|
||||
normalizePluginPackageLifecyclePlan,
|
||||
type PluginPackageLifecyclePlan,
|
||||
} from '@qinglong/runtime-core/plugin-package-lifecycle-plan';
|
||||
import { ProjectPolicyEngine } from '@qinglong/runtime-core/project-policy';
|
||||
import type {
|
||||
SecurityPolicyFence,
|
||||
SecuritySubject,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
import type { SecurityAuditRecord } from '@qinglong/runtime-core/security-audit';
|
||||
|
||||
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const ACTION_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}$/;
|
||||
const PACKAGE_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
const PROJECT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const CLUSTER_LIFECYCLE_CONSUMER = Object.freeze({
|
||||
subject: Object.freeze({
|
||||
type: 'system' as const,
|
||||
id: 'cluster_plugin_package_lifecycle_executor',
|
||||
}),
|
||||
authenticationId: 'cluster_plugin_package_lifecycle_executor_v1',
|
||||
});
|
||||
|
||||
export interface RunClusterPluginPackageLifecyclePlanOptions {
|
||||
readonly openDatabase: OpenPostgresDatabase;
|
||||
readonly actionRef: string;
|
||||
readonly action: PluginPackageLifecycleAction;
|
||||
readonly projectId: string;
|
||||
readonly packageName: string;
|
||||
readonly requestedBy: SecuritySubject;
|
||||
readonly confirmAuthorization: () => void | Promise<void>;
|
||||
readonly lifetimeMs?: number;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageLifecyclePlanRun {
|
||||
readonly database: PostgresSchemaReadinessReport;
|
||||
readonly status: 'created' | 'existing';
|
||||
readonly plan: Readonly<PluginPackageLifecyclePlan>;
|
||||
}
|
||||
|
||||
export interface RunClusterPluginPackageLifecycleExecutionOptions {
|
||||
readonly openDatabase: OpenPostgresDatabase;
|
||||
readonly actionRef: string;
|
||||
readonly approvalRequestId: string;
|
||||
readonly consumptionId: string;
|
||||
readonly dispatchId: string;
|
||||
readonly auditEventId: string;
|
||||
readonly confirmAuthorization: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageLifecycleExecutionRun {
|
||||
readonly database: PostgresSchemaReadinessReport;
|
||||
readonly status: 'created' | 'existing';
|
||||
readonly receipt: Readonly<PluginPackageLifecycleReceipt>;
|
||||
}
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
function identifier(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTIFIER_PATTERN.test(value)) {
|
||||
throw new TypeError(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function actionRef(value: unknown): string {
|
||||
if (typeof value !== 'string' || !ACTION_REF_PATTERN.test(value)) {
|
||||
throw new TypeError('actionRef is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function same(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
async function databaseNowMs(pool: PostgresPool): Promise<number> {
|
||||
const result = await pool.query<Row>(
|
||||
`SELECT floor(extract(epoch FROM clock_timestamp()) * 1000)::bigint
|
||||
AS "nowMs"`,
|
||||
);
|
||||
const value = result.rows[0]?.nowMs;
|
||||
const parsed =
|
||||
typeof value === 'number'
|
||||
? value
|
||||
: typeof value === 'string' && /^(0|[1-9]\d*)$/.test(value)
|
||||
? Number(value)
|
||||
: Number.NaN;
|
||||
if (
|
||||
result.rows.length !== 1 ||
|
||||
!Number.isSafeInteger(parsed) ||
|
||||
parsed < 0
|
||||
) {
|
||||
throw new Error('PostgreSQL lifecycle clock is unavailable');
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function binding(
|
||||
plan: Readonly<PluginPackageLifecyclePlan>,
|
||||
): Readonly<ApprovedActionBinding> {
|
||||
return Object.freeze({
|
||||
permission: 'package.manage',
|
||||
actionType: `plugin_package.lifecycle.${plan.impact.action}`,
|
||||
actionRef: plan.actionRef,
|
||||
actionDigest: pluginPackageLifecycleActionDigest(plan.impact),
|
||||
previewDigest: plan.impact.impactDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function audit(
|
||||
eventId: string,
|
||||
approvalRequestId: string,
|
||||
projectId: string,
|
||||
fence: Readonly<SecurityPolicyFence>,
|
||||
occurredAtMs: number,
|
||||
): Readonly<SecurityAuditRecord> {
|
||||
return Object.freeze({
|
||||
eventId,
|
||||
requestId: approvalRequestId,
|
||||
operationId: 'approval.consume',
|
||||
projectId,
|
||||
subject: CLUSTER_LIFECYCLE_CONSUMER.subject,
|
||||
authenticationId: CLUSTER_LIFECYCLE_CONSUMER.authenticationId,
|
||||
outcome: 'allowed',
|
||||
reasons: Object.freeze(['package_lifecycle_review']),
|
||||
fence,
|
||||
occurredAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
async function closeDatabase(
|
||||
database: PostgresDatabaseResource | undefined,
|
||||
failure: unknown,
|
||||
): Promise<void> {
|
||||
if (!database) {
|
||||
if (failure !== undefined) throw failure;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await database.close();
|
||||
} catch (closeError) {
|
||||
if (failure !== undefined) {
|
||||
throw new AggregateError(
|
||||
[failure, closeError],
|
||||
'Cluster Plugin Package lifecycle failed and PostgreSQL did not close',
|
||||
);
|
||||
}
|
||||
throw closeError;
|
||||
}
|
||||
if (failure !== undefined) throw failure;
|
||||
}
|
||||
|
||||
export async function runClusterPluginPackageLifecyclePlan(
|
||||
options: RunClusterPluginPackageLifecyclePlanOptions,
|
||||
): Promise<Readonly<ClusterPluginPackageLifecyclePlanRun>> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
typeof options.openDatabase !== 'function' ||
|
||||
typeof options.confirmAuthorization !== 'function' ||
|
||||
typeof options.projectId !== 'string' ||
|
||||
!PROJECT_ID_PATTERN.test(options.projectId) ||
|
||||
typeof options.packageName !== 'string' ||
|
||||
!PACKAGE_NAME_PATTERN.test(options.packageName)
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster Plugin Package lifecycle plan options are invalid',
|
||||
);
|
||||
}
|
||||
const requestedActionRef = actionRef(options.actionRef);
|
||||
const lifetimeMs =
|
||||
options.lifetimeMs ?? MAX_PLUGIN_PACKAGE_LIFECYCLE_PLAN_LIFETIME_MS;
|
||||
if (
|
||||
!Number.isSafeInteger(lifetimeMs) ||
|
||||
lifetimeMs < 1_000 ||
|
||||
lifetimeMs > MAX_PLUGIN_PACKAGE_LIFECYCLE_PLAN_LIFETIME_MS
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster Plugin Package lifecycle plan lifetime is invalid',
|
||||
);
|
||||
}
|
||||
let database: PostgresDatabaseResource | undefined;
|
||||
let failure: unknown;
|
||||
let result: Readonly<ClusterPluginPackageLifecyclePlanRun> | undefined;
|
||||
try {
|
||||
await options.confirmAuthorization();
|
||||
database = await options.openDatabase();
|
||||
const evidence = await assertPostgresPackageExecutorSchemaReady(
|
||||
database.pool,
|
||||
);
|
||||
const lifecycles = new PostgresPluginPackageLifecycleRepository(
|
||||
database.pool,
|
||||
);
|
||||
const plans = new PostgresPluginPackageLifecyclePlanRepository(
|
||||
database.pool,
|
||||
);
|
||||
const existingValue = await plans.findByActionRef(requestedActionRef);
|
||||
if (existingValue) {
|
||||
const existing = normalizePluginPackageLifecyclePlan(existingValue);
|
||||
if (
|
||||
existing.impact.action !== options.action ||
|
||||
existing.impact.target.projectId !== options.projectId ||
|
||||
existing.impact.target.packageName !== options.packageName ||
|
||||
!same(existing.requestedBy, options.requestedBy) ||
|
||||
existing.expiresAtMs - existing.plannedAtMs !== lifetimeMs
|
||||
) {
|
||||
throw new PluginPackageLifecyclePlanConflictError(
|
||||
'actionRef is bound to another lifecycle request',
|
||||
);
|
||||
}
|
||||
await options.confirmAuthorization();
|
||||
result = Object.freeze({
|
||||
database: evidence,
|
||||
status: 'existing' as const,
|
||||
plan: existing,
|
||||
});
|
||||
} else {
|
||||
const impact = await lifecycles.plan(
|
||||
options.action,
|
||||
options.projectId,
|
||||
options.packageName,
|
||||
);
|
||||
const plannedAtMs = await databaseNowMs(database.pool);
|
||||
const plan = createPluginPackageLifecyclePlan({
|
||||
actionRef: requestedActionRef,
|
||||
impact,
|
||||
requestedBy: options.requestedBy,
|
||||
plannedAtMs,
|
||||
expiresAtMs: plannedAtMs + lifetimeMs,
|
||||
});
|
||||
await options.confirmAuthorization();
|
||||
const created = await plans.create(plan);
|
||||
result = Object.freeze({
|
||||
database: evidence,
|
||||
status: created.status,
|
||||
plan: created.plan,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
await closeDatabase(database, failure);
|
||||
if (!result) {
|
||||
throw new Error('Cluster Plugin Package lifecycle plan produced no result');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function runClusterPluginPackageLifecycleExecution(
|
||||
options: RunClusterPluginPackageLifecycleExecutionOptions,
|
||||
): Promise<Readonly<ClusterPluginPackageLifecycleExecutionRun>> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
typeof options.openDatabase !== 'function' ||
|
||||
typeof options.confirmAuthorization !== 'function'
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster Plugin Package lifecycle execution options are invalid',
|
||||
);
|
||||
}
|
||||
const requestedActionRef = actionRef(options.actionRef);
|
||||
const approvalRequestId = identifier(
|
||||
options.approvalRequestId,
|
||||
'approvalRequestId',
|
||||
);
|
||||
const consumptionId = identifier(options.consumptionId, 'consumptionId');
|
||||
const dispatchId = identifier(options.dispatchId, 'dispatchId');
|
||||
const auditEventId = identifier(options.auditEventId, 'auditEventId');
|
||||
let database: PostgresDatabaseResource | undefined;
|
||||
let failure: unknown;
|
||||
let result: Readonly<ClusterPluginPackageLifecycleExecutionRun> | undefined;
|
||||
try {
|
||||
await options.confirmAuthorization();
|
||||
database = await options.openDatabase();
|
||||
const evidence = await assertPostgresPackageExecutorSchemaReady(
|
||||
database.pool,
|
||||
);
|
||||
const plans = new PostgresPluginPackageLifecyclePlanRepository(
|
||||
database.pool,
|
||||
);
|
||||
const planValue = await plans.findByActionRef(requestedActionRef);
|
||||
if (!planValue) {
|
||||
throw new PluginPackageLifecycleConflictError(
|
||||
'durable lifecycle plan is absent',
|
||||
);
|
||||
}
|
||||
const plan = normalizePluginPackageLifecyclePlan(planValue);
|
||||
const approvals = new PostgresApprovalRequestRepository(database.pool);
|
||||
let approvalValue = await approvals.findById(approvalRequestId);
|
||||
if (!approvalValue) {
|
||||
throw new PluginPackageLifecycleConflictError(
|
||||
'lifecycle approval is absent',
|
||||
);
|
||||
}
|
||||
let approval = normalizeApprovalRequestRecord(approvalValue);
|
||||
const approvedAction = binding(plan);
|
||||
if (
|
||||
approval.projectId !== plan.impact.target.projectId ||
|
||||
approval.decisionMode !== 'separation_of_duty' ||
|
||||
!same(approval.action, approvedAction) ||
|
||||
!same(approval.requestedBy, plan.requestedBy)
|
||||
) {
|
||||
throw new PluginPackageLifecycleConflictError(
|
||||
'lifecycle approval does not match durable plan',
|
||||
);
|
||||
}
|
||||
let dispatch: Readonly<ApprovedActionDispatchRecord> | null = null;
|
||||
if (approval.version === 2 && approval.state === 'approved') {
|
||||
const policy = new ProjectPolicyEngine(
|
||||
new PostgresProjectPolicyRepository(database.pool),
|
||||
);
|
||||
const decision = await policy.decide({
|
||||
subject: plan.requestedBy,
|
||||
projectId: plan.impact.target.projectId,
|
||||
permission: 'package.manage',
|
||||
});
|
||||
if (
|
||||
(decision.effect !== 'allow' &&
|
||||
decision.effect !== 'require_approval') ||
|
||||
decision.fence === null
|
||||
) {
|
||||
throw new PluginPackageLifecycleConflictError(
|
||||
'lifecycle requester is no longer authorized',
|
||||
);
|
||||
}
|
||||
const consumedAtMs = await databaseNowMs(database.pool);
|
||||
const consumed = await approvals.consume({
|
||||
requestId: approvalRequestId,
|
||||
expectedVersion: 2,
|
||||
consumptionId,
|
||||
dispatchId,
|
||||
action: approvedAction,
|
||||
requestedBy: plan.requestedBy,
|
||||
consumedBy: CLUSTER_LIFECYCLE_CONSUMER.subject,
|
||||
consumedAtMs,
|
||||
authorizationFence: decision.fence,
|
||||
audit: audit(
|
||||
auditEventId,
|
||||
approvalRequestId,
|
||||
plan.impact.target.projectId,
|
||||
decision.fence,
|
||||
consumedAtMs,
|
||||
),
|
||||
});
|
||||
approval = consumed.request;
|
||||
dispatch = consumed.dispatch;
|
||||
} else if (approval.version === 3 && approval.state === 'consumed') {
|
||||
dispatch = await approvals.findDispatchById(dispatchId);
|
||||
}
|
||||
if (
|
||||
approval.version !== 3 ||
|
||||
approval.state !== 'consumed' ||
|
||||
approval.consumptionId !== consumptionId ||
|
||||
approval.dispatchId !== dispatchId ||
|
||||
!dispatch ||
|
||||
!same(dispatch.action, approvedAction) ||
|
||||
!same(dispatch.requestedBy, plan.requestedBy) ||
|
||||
!same(dispatch.approvedBy, approval.decidedBy) ||
|
||||
!same(dispatch.consumedBy, CLUSTER_LIFECYCLE_CONSUMER.subject)
|
||||
) {
|
||||
throw new PluginPackageLifecycleConflictError(
|
||||
'lifecycle dispatch does not match durable approval',
|
||||
);
|
||||
}
|
||||
const lifecycles = new PostgresPluginPackageLifecycleRepository(
|
||||
database.pool,
|
||||
);
|
||||
const event = createPluginPackageLifecycleEvent({
|
||||
dispatchId: dispatch.id,
|
||||
impact: plan.impact,
|
||||
requestedBy: dispatch.requestedBy,
|
||||
approvedBy: dispatch.approvedBy,
|
||||
authorizationMode: 'separation_of_duty',
|
||||
occurredAtMs: dispatch.createdAtMs,
|
||||
});
|
||||
const existingReceipt = await lifecycles.findByEventDigest(
|
||||
event.eventDigest,
|
||||
);
|
||||
if (existingReceipt) {
|
||||
await options.confirmAuthorization();
|
||||
result = Object.freeze({
|
||||
database: evidence,
|
||||
status: 'existing' as const,
|
||||
receipt: existingReceipt,
|
||||
});
|
||||
} else {
|
||||
const currentImpact = await lifecycles.plan(
|
||||
plan.impact.action,
|
||||
plan.impact.target.projectId,
|
||||
plan.impact.target.packageName,
|
||||
);
|
||||
if (!same(currentImpact, plan.impact)) {
|
||||
throw new PluginPackageLifecycleConflictError(
|
||||
'approved lifecycle impact is stale',
|
||||
);
|
||||
}
|
||||
const transitioned = await lifecycles.transition(
|
||||
event,
|
||||
options.confirmAuthorization,
|
||||
);
|
||||
result = Object.freeze({
|
||||
database: evidence,
|
||||
status: transitioned.status,
|
||||
receipt: transitioned.receipt,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
await closeDatabase(database, failure);
|
||||
if (!result) {
|
||||
throw new Error(
|
||||
'Cluster Plugin Package lifecycle execution produced no result',
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
+525
@@ -0,0 +1,525 @@
|
||||
// Cluster Plugin Package lifecycle boundary; keep approval management authority explicit.
|
||||
import { PostgresApprovalRequestRepository } from '@qinglong/cluster-postgres/approved-action';
|
||||
import { PostgresPluginPackageLifecyclePlanReader } from '@qinglong/cluster-postgres/package-manager';
|
||||
import { PostgresProjectPolicyRepository } from '@qinglong/cluster-postgres/project-policy';
|
||||
import type { PostgresPool } from '@qinglong/runtime-core';
|
||||
import {
|
||||
createApprovalRequest,
|
||||
normalizeApprovalRequestRecord,
|
||||
type ApprovalRequestRecord,
|
||||
type CreateApprovalRequestResult,
|
||||
type DecideApprovalRequestResult,
|
||||
} from '@qinglong/runtime-core/approved-action';
|
||||
import { pluginPackageLifecycleActionDigest } from '@qinglong/runtime-core/plugin-package-lifecycle';
|
||||
import {
|
||||
normalizePluginPackageLifecyclePlan,
|
||||
type PluginPackageLifecyclePlan,
|
||||
} from '@qinglong/runtime-core/plugin-package-lifecycle-plan';
|
||||
import {
|
||||
PluginPackageManagementAuthorizationError,
|
||||
PluginPackageManagementConflictError,
|
||||
PluginPackageManagementRequestError,
|
||||
PluginPackageManagementUnavailableError,
|
||||
} from '@qinglong/runtime-core/plugin-package-management';
|
||||
import { ProjectPolicyEngine } from '@qinglong/runtime-core/project-policy';
|
||||
import {
|
||||
normalizeSecurityPrincipal,
|
||||
type SecurityPolicyFence,
|
||||
type SecurityPrincipal,
|
||||
type SecuritySubject,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
import type { SecurityAuditRecord } from '@qinglong/runtime-core/security-audit';
|
||||
|
||||
const DEFAULT_APPROVAL_LIFETIME_MS = 15 * 60 * 1000;
|
||||
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const ACTION_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}$/;
|
||||
const REASON_PATTERN = /^[a-z][a-z0-9_]{0,63}$/;
|
||||
|
||||
export interface ClusterPluginPackageLifecycleManagementOptions {
|
||||
readonly pool: PostgresPool;
|
||||
readonly now?: () => number;
|
||||
readonly approvalLifetimeMs?: number;
|
||||
}
|
||||
|
||||
export interface ProposeClusterPluginPackageLifecycleRequest {
|
||||
readonly actionRef: string;
|
||||
readonly approvalRequestId: string;
|
||||
readonly approvalAuditEventId: string;
|
||||
readonly principal: SecurityPrincipal;
|
||||
}
|
||||
|
||||
export interface ProposeClusterPluginPackageLifecycleResult {
|
||||
readonly plan: Readonly<PluginPackageLifecyclePlan>;
|
||||
readonly approvalStatus: CreateApprovalRequestResult['status'];
|
||||
readonly approvalRequest: Readonly<ApprovalRequestRecord>;
|
||||
}
|
||||
|
||||
export interface DecideClusterPluginPackageLifecycleRequest {
|
||||
readonly actionRef: string;
|
||||
readonly approvalRequestId: string;
|
||||
readonly expectedVersion: number;
|
||||
readonly decisionId: string;
|
||||
readonly auditEventId: string;
|
||||
readonly decision: 'approved' | 'rejected';
|
||||
readonly reasonCode: string;
|
||||
readonly principal: SecurityPrincipal;
|
||||
}
|
||||
|
||||
export interface InspectClusterPluginPackageLifecycleRequest {
|
||||
readonly actionRef: string;
|
||||
readonly approvalRequestId: string;
|
||||
readonly inspectionId: string;
|
||||
readonly principal: SecurityPrincipal;
|
||||
}
|
||||
|
||||
export interface InspectClusterPluginPackageLifecycleResult {
|
||||
readonly plan: Readonly<PluginPackageLifecyclePlan> | null;
|
||||
readonly approvalRequest: Readonly<ApprovalRequestRecord> | null;
|
||||
readonly stale: boolean;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageLifecycleManagementService {
|
||||
propose(
|
||||
request: ProposeClusterPluginPackageLifecycleRequest,
|
||||
): Promise<Readonly<ProposeClusterPluginPackageLifecycleResult>>;
|
||||
decide(
|
||||
request: DecideClusterPluginPackageLifecycleRequest,
|
||||
): Promise<Readonly<DecideApprovalRequestResult>>;
|
||||
inspectAuthorized(
|
||||
request: InspectClusterPluginPackageLifecycleRequest,
|
||||
): Promise<Readonly<InspectClusterPluginPackageLifecycleResult>>;
|
||||
}
|
||||
|
||||
function identifier(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTIFIER_PATTERN.test(value)) {
|
||||
throw new PluginPackageManagementRequestError(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function actionRef(value: unknown): string {
|
||||
if (typeof value !== 'string' || !ACTION_REF_PATTERN.test(value)) {
|
||||
throw new PluginPackageManagementRequestError('actionRef is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function currentTime(now: () => number): number {
|
||||
const value = now();
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new PluginPackageManagementUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function sameSubject(
|
||||
left: Readonly<SecuritySubject>,
|
||||
right: Readonly<SecuritySubject>,
|
||||
): boolean {
|
||||
return left.type === right.type && left.id === right.id;
|
||||
}
|
||||
|
||||
function action(plan: Readonly<PluginPackageLifecyclePlan>) {
|
||||
return Object.freeze({
|
||||
permission: 'package.manage' as const,
|
||||
actionType: `plugin_package.lifecycle.${plan.impact.action}`,
|
||||
actionRef: plan.actionRef,
|
||||
actionDigest: pluginPackageLifecycleActionDigest(plan.impact),
|
||||
previewDigest: plan.impact.impactDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function audit(
|
||||
eventId: string,
|
||||
requestId: string,
|
||||
operationId: 'approval.request' | 'approval.decide',
|
||||
projectId: string,
|
||||
subject: Readonly<SecuritySubject>,
|
||||
authenticationId: string,
|
||||
outcome: 'allowed' | 'approval_required',
|
||||
fence: Readonly<SecurityPolicyFence>,
|
||||
occurredAtMs: number,
|
||||
): Readonly<SecurityAuditRecord> {
|
||||
return Object.freeze({
|
||||
eventId,
|
||||
requestId,
|
||||
operationId,
|
||||
projectId,
|
||||
subject,
|
||||
authenticationId,
|
||||
outcome,
|
||||
reasons: Object.freeze(['package_lifecycle_review']),
|
||||
fence,
|
||||
occurredAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
export function createClusterPluginPackageLifecycleManagementService(
|
||||
options: ClusterPluginPackageLifecycleManagementOptions,
|
||||
): Readonly<ClusterPluginPackageLifecycleManagementService> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some(
|
||||
(key) =>
|
||||
key !== 'pool' &&
|
||||
key !== 'now' &&
|
||||
key !== 'approvalLifetimeMs',
|
||||
) ||
|
||||
(options.now !== undefined && typeof options.now !== 'function')
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster Plugin Package lifecycle management options are invalid',
|
||||
);
|
||||
}
|
||||
const approvalLifetimeMs =
|
||||
options.approvalLifetimeMs ?? DEFAULT_APPROVAL_LIFETIME_MS;
|
||||
if (
|
||||
!Number.isSafeInteger(approvalLifetimeMs) ||
|
||||
approvalLifetimeMs < 1_000 ||
|
||||
approvalLifetimeMs > DEFAULT_APPROVAL_LIFETIME_MS
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster Plugin Package lifecycle approval lifetime is invalid',
|
||||
);
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
const plans = new PostgresPluginPackageLifecyclePlanReader(options.pool);
|
||||
const approvals = new PostgresApprovalRequestRepository(options.pool);
|
||||
const policy = new ProjectPolicyEngine(
|
||||
new PostgresProjectPolicyRepository(options.pool),
|
||||
);
|
||||
|
||||
const authorize = async (
|
||||
principalValue: SecurityPrincipal,
|
||||
projectId: string,
|
||||
permission: 'package.manage' | 'approval.decide',
|
||||
observedAtMs: number,
|
||||
): Promise<
|
||||
Readonly<{
|
||||
principal: Readonly<SecurityPrincipal>;
|
||||
fence: Readonly<SecurityPolicyFence>;
|
||||
}>
|
||||
> => {
|
||||
let principal;
|
||||
try {
|
||||
principal = normalizeSecurityPrincipal(principalValue, observedAtMs);
|
||||
} catch {
|
||||
throw new PluginPackageManagementAuthorizationError();
|
||||
}
|
||||
if (
|
||||
principal.subject.type !== 'user' ||
|
||||
(principal.assurance !== 'multi_factor' &&
|
||||
principal.assurance !== 'hardware')
|
||||
) {
|
||||
throw new PluginPackageManagementAuthorizationError();
|
||||
}
|
||||
let decision;
|
||||
try {
|
||||
decision = await policy.authorize(principal, projectId, permission);
|
||||
} catch (error) {
|
||||
throw new PluginPackageManagementUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
if (decision.effect !== 'allow' || decision.fence === null) {
|
||||
throw new PluginPackageManagementAuthorizationError();
|
||||
}
|
||||
return Object.freeze({ principal, fence: decision.fence });
|
||||
};
|
||||
|
||||
const loadPlan = async (
|
||||
requestedActionRef: string,
|
||||
): Promise<Readonly<PluginPackageLifecyclePlan>> => {
|
||||
let plan;
|
||||
try {
|
||||
plan = await plans.findByActionRef(actionRef(requestedActionRef));
|
||||
} catch (error) {
|
||||
throw new PluginPackageManagementUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
if (!plan) {
|
||||
throw new PluginPackageManagementConflictError(
|
||||
'Plugin Package lifecycle plan does not exist',
|
||||
);
|
||||
}
|
||||
return normalizePluginPackageLifecyclePlan(plan);
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
async propose(request: ProposeClusterPluginPackageLifecycleRequest) {
|
||||
if (
|
||||
!request ||
|
||||
typeof request !== 'object' ||
|
||||
Array.isArray(request) ||
|
||||
Object.keys(request).sort().join('\0') !==
|
||||
[
|
||||
'actionRef',
|
||||
'approvalAuditEventId',
|
||||
'approvalRequestId',
|
||||
'principal',
|
||||
]
|
||||
.sort()
|
||||
.join('\0')
|
||||
) {
|
||||
throw new PluginPackageManagementRequestError(
|
||||
'lifecycle proposal request is invalid',
|
||||
);
|
||||
}
|
||||
const approvalRequestId = identifier(
|
||||
request.approvalRequestId,
|
||||
'approvalRequestId',
|
||||
);
|
||||
const approvalAuditEventId = identifier(
|
||||
request.approvalAuditEventId,
|
||||
'approvalAuditEventId',
|
||||
);
|
||||
const plan = await loadPlan(request.actionRef);
|
||||
const observedAtMs = currentTime(now);
|
||||
if (observedAtMs > plan.expiresAtMs) {
|
||||
throw new PluginPackageManagementConflictError(
|
||||
'Plugin Package lifecycle plan expired',
|
||||
);
|
||||
}
|
||||
const authorization = await authorize(
|
||||
request.principal,
|
||||
plan.impact.target.projectId,
|
||||
'package.manage',
|
||||
observedAtMs,
|
||||
);
|
||||
if (!sameSubject(plan.requestedBy, authorization.principal.subject)) {
|
||||
throw new PluginPackageManagementAuthorizationError();
|
||||
}
|
||||
const binding = action(plan);
|
||||
const existing = await approvals.findById(approvalRequestId);
|
||||
if (existing) {
|
||||
const normalized = normalizeApprovalRequestRecord(existing);
|
||||
if (
|
||||
normalized.projectId !== plan.impact.target.projectId ||
|
||||
normalized.decisionMode !== 'separation_of_duty' ||
|
||||
!sameSubject(normalized.requestedBy, plan.requestedBy) ||
|
||||
JSON.stringify(normalized.action) !== JSON.stringify(binding)
|
||||
) {
|
||||
throw new PluginPackageManagementConflictError(
|
||||
'Approval request is bound to another lifecycle plan',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
plan,
|
||||
approvalStatus: 'existing' as const,
|
||||
approvalRequest: normalized,
|
||||
});
|
||||
}
|
||||
const expiresAtMs = Math.min(
|
||||
observedAtMs + approvalLifetimeMs,
|
||||
plan.expiresAtMs,
|
||||
);
|
||||
if (expiresAtMs <= observedAtMs) {
|
||||
throw new PluginPackageManagementConflictError(
|
||||
'Plugin Package lifecycle plan has no approval lifetime',
|
||||
);
|
||||
}
|
||||
const result = await approvals.create({
|
||||
request: createApprovalRequest({
|
||||
id: approvalRequestId,
|
||||
projectId: plan.impact.target.projectId,
|
||||
action: binding,
|
||||
risk: 'high',
|
||||
decisionMode: 'separation_of_duty',
|
||||
requestedBy: authorization.principal.subject,
|
||||
requestedAtMs: observedAtMs,
|
||||
expiresAtMs,
|
||||
requestFence: authorization.fence,
|
||||
}),
|
||||
audit: audit(
|
||||
approvalAuditEventId,
|
||||
approvalRequestId,
|
||||
'approval.request',
|
||||
plan.impact.target.projectId,
|
||||
authorization.principal.subject,
|
||||
authorization.principal.authenticationId,
|
||||
'approval_required',
|
||||
authorization.fence,
|
||||
observedAtMs,
|
||||
),
|
||||
});
|
||||
return Object.freeze({
|
||||
plan,
|
||||
approvalStatus: result.status,
|
||||
approvalRequest: result.request,
|
||||
});
|
||||
},
|
||||
|
||||
async decide(request: DecideClusterPluginPackageLifecycleRequest) {
|
||||
if (
|
||||
!request ||
|
||||
typeof request !== 'object' ||
|
||||
Array.isArray(request) ||
|
||||
Object.keys(request).sort().join('\0') !==
|
||||
[
|
||||
'actionRef',
|
||||
'approvalRequestId',
|
||||
'auditEventId',
|
||||
'decision',
|
||||
'decisionId',
|
||||
'expectedVersion',
|
||||
'principal',
|
||||
'reasonCode',
|
||||
]
|
||||
.sort()
|
||||
.join('\0') ||
|
||||
(request.decision !== 'approved' &&
|
||||
request.decision !== 'rejected') ||
|
||||
typeof request.reasonCode !== 'string' ||
|
||||
!REASON_PATTERN.test(request.reasonCode) ||
|
||||
!Number.isSafeInteger(request.expectedVersion) ||
|
||||
request.expectedVersion < 1
|
||||
) {
|
||||
throw new PluginPackageManagementRequestError(
|
||||
'lifecycle decision request is invalid',
|
||||
);
|
||||
}
|
||||
const plan = await loadPlan(request.actionRef);
|
||||
const approvalRequestId = identifier(
|
||||
request.approvalRequestId,
|
||||
'approvalRequestId',
|
||||
);
|
||||
const decisionId = identifier(request.decisionId, 'decisionId');
|
||||
const auditEventId = identifier(request.auditEventId, 'auditEventId');
|
||||
const current = await approvals.findById(approvalRequestId);
|
||||
if (!current) {
|
||||
throw new PluginPackageManagementConflictError(
|
||||
'Approval request does not exist',
|
||||
);
|
||||
}
|
||||
const approval = normalizeApprovalRequestRecord(current);
|
||||
if (
|
||||
approval.action.actionRef !== plan.actionRef ||
|
||||
JSON.stringify(approval.action) !== JSON.stringify(action(plan))
|
||||
) {
|
||||
throw new PluginPackageManagementConflictError(
|
||||
'Approval request does not match lifecycle plan',
|
||||
);
|
||||
}
|
||||
const observedAtMs = currentTime(now);
|
||||
const authorization = await authorize(
|
||||
request.principal,
|
||||
approval.projectId,
|
||||
'approval.decide',
|
||||
observedAtMs,
|
||||
);
|
||||
if (
|
||||
approval.decisionId === decisionId &&
|
||||
approval.decision === request.decision &&
|
||||
approval.decisionReasonCode === request.reasonCode &&
|
||||
approval.decidedBy &&
|
||||
sameSubject(approval.decidedBy, authorization.principal.subject)
|
||||
) {
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
request: approval,
|
||||
});
|
||||
}
|
||||
return approvals.decide({
|
||||
requestId: approvalRequestId,
|
||||
expectedVersion: request.expectedVersion,
|
||||
decisionId,
|
||||
decision: request.decision,
|
||||
reasonCode: request.reasonCode,
|
||||
principal: authorization.principal,
|
||||
decidedAtMs: observedAtMs,
|
||||
authorizationFence: authorization.fence,
|
||||
audit: audit(
|
||||
auditEventId,
|
||||
approvalRequestId,
|
||||
'approval.decide',
|
||||
approval.projectId,
|
||||
authorization.principal.subject,
|
||||
authorization.principal.authenticationId,
|
||||
'allowed',
|
||||
authorization.fence,
|
||||
observedAtMs,
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
async inspectAuthorized(
|
||||
request: InspectClusterPluginPackageLifecycleRequest,
|
||||
) {
|
||||
if (
|
||||
!request ||
|
||||
typeof request !== 'object' ||
|
||||
Array.isArray(request) ||
|
||||
Object.keys(request).sort().join('\0') !==
|
||||
[
|
||||
'actionRef',
|
||||
'approvalRequestId',
|
||||
'inspectionId',
|
||||
'principal',
|
||||
]
|
||||
.sort()
|
||||
.join('\0')
|
||||
) {
|
||||
throw new PluginPackageManagementRequestError(
|
||||
'lifecycle inspection request is invalid',
|
||||
);
|
||||
}
|
||||
identifier(request.inspectionId, 'inspectionId');
|
||||
const requestedActionRef = actionRef(request.actionRef);
|
||||
const approvalRequestId = identifier(
|
||||
request.approvalRequestId,
|
||||
'approvalRequestId',
|
||||
);
|
||||
const [planValue, approvalValue] = await Promise.all([
|
||||
plans.findByActionRef(requestedActionRef),
|
||||
approvals.findById(approvalRequestId),
|
||||
]);
|
||||
if (!planValue && !approvalValue) {
|
||||
throw new PluginPackageManagementConflictError(
|
||||
'Plugin Package lifecycle state does not exist',
|
||||
);
|
||||
}
|
||||
const plan = planValue
|
||||
? normalizePluginPackageLifecyclePlan(planValue)
|
||||
: null;
|
||||
const approval = approvalValue
|
||||
? normalizeApprovalRequestRecord(approvalValue)
|
||||
: null;
|
||||
const projectId = plan?.impact.target.projectId ?? approval?.projectId;
|
||||
if (!projectId) {
|
||||
throw new PluginPackageManagementUnavailableError();
|
||||
}
|
||||
const observedAtMs = currentTime(now);
|
||||
try {
|
||||
await authorize(
|
||||
request.principal,
|
||||
projectId,
|
||||
'package.manage',
|
||||
observedAtMs,
|
||||
);
|
||||
} catch (error) {
|
||||
if (!(error instanceof PluginPackageManagementAuthorizationError)) {
|
||||
throw error;
|
||||
}
|
||||
await authorize(
|
||||
request.principal,
|
||||
projectId,
|
||||
'approval.decide',
|
||||
observedAtMs,
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
plan,
|
||||
approvalRequest: approval,
|
||||
stale:
|
||||
plan === null ||
|
||||
approval === null ||
|
||||
approval.action.actionRef !== plan.actionRef ||
|
||||
JSON.stringify(approval.action) !== JSON.stringify(action(plan)) ||
|
||||
observedAtMs > plan.expiresAtMs,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// Cluster Plugin Package lifecycle boundary; keep quarantine authority explicit.
|
||||
import type {
|
||||
OpenPostgresDatabase,
|
||||
PostgresDatabaseResource,
|
||||
} from '@qinglong/runtime-core';
|
||||
import {
|
||||
InvalidPluginPackageQuarantineError,
|
||||
normalizePluginPackageQuarantineEvent,
|
||||
type PluginPackageQuarantineEvent,
|
||||
type PluginPackageQuarantineRepository,
|
||||
type PluginPackageWithdrawalReceipt,
|
||||
} from '@qinglong/runtime-core/plugin-package-quarantine';
|
||||
import {
|
||||
PostgresPluginPackageQuarantineRepository,
|
||||
assertPostgresPackageExecutorSchemaReady,
|
||||
type PostgresSchemaReadinessReport,
|
||||
} from '@qinglong/cluster-postgres/package-executor';
|
||||
|
||||
export const CLUSTER_PLUGIN_PACKAGE_QUARANTINE_BATCH_LIMIT = 128;
|
||||
|
||||
export interface ClusterPluginPackageQuarantineService {
|
||||
quarantine(
|
||||
events: readonly Readonly<PluginPackageQuarantineEvent>[],
|
||||
confirmAuthorization: (
|
||||
event: Readonly<PluginPackageQuarantineEvent>,
|
||||
) => void | Promise<void>,
|
||||
): Promise<
|
||||
readonly Readonly<{
|
||||
status: 'created' | 'existing';
|
||||
eventDigest: string;
|
||||
receipt: Readonly<PluginPackageWithdrawalReceipt>;
|
||||
}>[]
|
||||
>;
|
||||
}
|
||||
|
||||
export interface RunClusterPluginPackageQuarantineOptions {
|
||||
readonly openDatabase: OpenPostgresDatabase;
|
||||
readonly events: readonly Readonly<PluginPackageQuarantineEvent>[];
|
||||
readonly confirmAuthorization: (
|
||||
event: Readonly<PluginPackageQuarantineEvent>,
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageQuarantineRun {
|
||||
readonly database: PostgresSchemaReadinessReport;
|
||||
readonly results: readonly Readonly<{
|
||||
status: 'created' | 'existing';
|
||||
eventDigest: string;
|
||||
receipt: Readonly<PluginPackageWithdrawalReceipt>;
|
||||
}>[];
|
||||
}
|
||||
|
||||
function targetKey(event: Readonly<PluginPackageQuarantineEvent>): string {
|
||||
return [
|
||||
event.target.projectId,
|
||||
event.target.packageName,
|
||||
event.target.installationId,
|
||||
event.target.lockDigest,
|
||||
].join('\0');
|
||||
}
|
||||
|
||||
function normalizedBatch(
|
||||
events: readonly Readonly<PluginPackageQuarantineEvent>[],
|
||||
): readonly Readonly<PluginPackageQuarantineEvent>[] {
|
||||
if (
|
||||
!Array.isArray(events) ||
|
||||
events.length < 1 ||
|
||||
events.length > CLUSTER_PLUGIN_PACKAGE_QUARANTINE_BATCH_LIMIT ||
|
||||
Object.keys(events).some((key, index) => key !== String(index))
|
||||
) {
|
||||
throw new InvalidPluginPackageQuarantineError(
|
||||
`events must contain 1-${CLUSTER_PLUGIN_PACKAGE_QUARANTINE_BATCH_LIMIT} dense items`,
|
||||
);
|
||||
}
|
||||
const normalized = events.map(normalizePluginPackageQuarantineEvent);
|
||||
const eventDigests = new Set<string>();
|
||||
const targets = new Set<string>();
|
||||
for (const event of normalized) {
|
||||
const target = targetKey(event);
|
||||
if (eventDigests.has(event.eventDigest) || targets.has(target)) {
|
||||
throw new InvalidPluginPackageQuarantineError(
|
||||
'batch event digests and targets must be unique',
|
||||
);
|
||||
}
|
||||
eventDigests.add(event.eventDigest);
|
||||
targets.add(target);
|
||||
}
|
||||
return Object.freeze(normalized);
|
||||
}
|
||||
|
||||
export function createClusterPluginPackageQuarantineService(
|
||||
repository: PluginPackageQuarantineRepository,
|
||||
): Readonly<ClusterPluginPackageQuarantineService> {
|
||||
if (
|
||||
!repository ||
|
||||
typeof repository.findTargetsByLockDigest !== 'function' ||
|
||||
typeof repository.findByEventDigest !== 'function' ||
|
||||
typeof repository.quarantine !== 'function'
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster Plugin Package quarantine repository is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
async quarantine(
|
||||
events: readonly Readonly<PluginPackageQuarantineEvent>[],
|
||||
confirmAuthorization: (
|
||||
event: Readonly<PluginPackageQuarantineEvent>,
|
||||
) => void | Promise<void>,
|
||||
) {
|
||||
const batch = normalizedBatch(events);
|
||||
if (typeof confirmAuthorization !== 'function') {
|
||||
throw new InvalidPluginPackageQuarantineError(
|
||||
'confirmAuthorization is invalid',
|
||||
);
|
||||
}
|
||||
const results = [];
|
||||
for (const event of batch) {
|
||||
const result = await repository.quarantine(event, () =>
|
||||
confirmAuthorization(event),
|
||||
);
|
||||
results.push(
|
||||
Object.freeze({
|
||||
status: result.status,
|
||||
eventDigest: event.eventDigest,
|
||||
receipt: result.receipt,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Object.freeze(results);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function runClusterPluginPackageQuarantine(
|
||||
options: RunClusterPluginPackageQuarantineOptions,
|
||||
): Promise<Readonly<ClusterPluginPackageQuarantineRun>> {
|
||||
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
||||
throw new TypeError(
|
||||
'Cluster Plugin Package quarantine options are invalid',
|
||||
);
|
||||
}
|
||||
if (
|
||||
Object.keys(options).some(
|
||||
(key) =>
|
||||
!['openDatabase', 'events', 'confirmAuthorization'].includes(key),
|
||||
) ||
|
||||
typeof options.openDatabase !== 'function'
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster Plugin Package quarantine options shape is invalid',
|
||||
);
|
||||
}
|
||||
const events = normalizedBatch(options.events);
|
||||
if (typeof options.confirmAuthorization !== 'function') {
|
||||
throw new InvalidPluginPackageQuarantineError(
|
||||
'confirmAuthorization is invalid',
|
||||
);
|
||||
}
|
||||
let database: PostgresDatabaseResource | undefined;
|
||||
try {
|
||||
database = await options.openDatabase();
|
||||
const evidence = await assertPostgresPackageExecutorSchemaReady(
|
||||
database.pool,
|
||||
);
|
||||
const results =
|
||||
await createClusterPluginPackageQuarantineService(
|
||||
new PostgresPluginPackageQuarantineRepository(database.pool),
|
||||
).quarantine(events, options.confirmAuthorization);
|
||||
return Object.freeze({ database: evidence, results });
|
||||
} finally {
|
||||
await database?.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
/** Plugin Package management service boundary. */
|
||||
import { PostgresApprovalRequestRepository } from '@qinglong/cluster-postgres/approved-action';
|
||||
import { PostgresPluginPackageInstallInventoryReader } from '@qinglong/cluster-postgres/package-manager';
|
||||
import { PostgresPluginPackageInstallProposalRepository } from '@qinglong/cluster-postgres/plugin-package-proposal';
|
||||
import { PostgresProjectPolicyRepository } from '@qinglong/cluster-postgres/project-policy';
|
||||
import type { PostgresPool } from '@qinglong/runtime-core';
|
||||
import {
|
||||
PluginPackageManagementAuthorizationError,
|
||||
PluginPackageManagementConflictError,
|
||||
PluginPackageManagementQuotaExceededError,
|
||||
PluginPackageManagementRequestError,
|
||||
PluginPackageManagementUnavailableError,
|
||||
createPluginPackageManagementService,
|
||||
type InspectPluginPackageInstallResult,
|
||||
type PluginPackageManagementQuotaPort,
|
||||
type PluginPackageManagementService as RuntimePluginPackageManagementService,
|
||||
} from '@qinglong/runtime-core/plugin-package-management';
|
||||
import {
|
||||
MAX_PLUGIN_PACKAGE_INSTALL_INVENTORY_PAGE_SIZE,
|
||||
normalizePluginPackageInstallInventoryCursor,
|
||||
type PluginPackageInstallInventoryItem,
|
||||
type PluginPackageInstallInventoryPage,
|
||||
} from '@qinglong/runtime-core/plugin-package-install';
|
||||
import { ProjectPolicyEngine } from '@qinglong/runtime-core/project-policy';
|
||||
import {
|
||||
normalizeSecurityPrincipal,
|
||||
type SecurityPolicyDecision,
|
||||
type SecurityPrincipal,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
|
||||
export const CLUSTER_PLUGIN_PACKAGE_MANAGEMENT_DECISION_MODE =
|
||||
'separation_of_duty' as const;
|
||||
|
||||
type ClusterPluginPackageManagementMutationService = Pick<
|
||||
RuntimePluginPackageManagementService,
|
||||
'propose' | 'decide' | 'inspect'
|
||||
>;
|
||||
|
||||
export interface InspectAuthorizedClusterPluginPackageRequest {
|
||||
readonly actionRef: string;
|
||||
readonly approvalRequestId: string;
|
||||
readonly inspectionId: string;
|
||||
readonly principal: SecurityPrincipal;
|
||||
}
|
||||
|
||||
export interface InspectAuthorizedClusterPluginPackageInstallationRequest {
|
||||
readonly projectId: string;
|
||||
readonly packageName: string;
|
||||
readonly inspectionId: string;
|
||||
readonly principal: SecurityPrincipal;
|
||||
}
|
||||
|
||||
export interface ListAuthorizedClusterPluginPackageInstallationsRequest {
|
||||
readonly projectId: string;
|
||||
readonly limit: number;
|
||||
readonly after?: Readonly<{ packageName: string }>;
|
||||
readonly inspectionId: string;
|
||||
readonly principal: SecurityPrincipal;
|
||||
}
|
||||
|
||||
export type ClusterPluginPackageManagementService =
|
||||
ClusterPluginPackageManagementMutationService &
|
||||
Readonly<{
|
||||
inspectAuthorized(
|
||||
request: InspectAuthorizedClusterPluginPackageRequest,
|
||||
): Promise<Readonly<InspectPluginPackageInstallResult>>;
|
||||
inspectInstallationAuthorized(
|
||||
request: InspectAuthorizedClusterPluginPackageInstallationRequest,
|
||||
): Promise<Readonly<PluginPackageInstallInventoryItem> | null>;
|
||||
listInstallationsAuthorized(
|
||||
request: ListAuthorizedClusterPluginPackageInstallationsRequest,
|
||||
): Promise<Readonly<PluginPackageInstallInventoryPage>>;
|
||||
}>;
|
||||
|
||||
export interface ClusterPluginPackageManagementOptions {
|
||||
readonly pool: PostgresPool;
|
||||
readonly approvalLifetimeMs?: number;
|
||||
readonly now?: () => number;
|
||||
readonly quota?: PluginPackageManagementQuotaPort;
|
||||
}
|
||||
|
||||
const INSPECTION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}$/;
|
||||
const PROJECT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const PACKAGE_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
|
||||
export function createClusterPluginPackageManagementService(
|
||||
options: ClusterPluginPackageManagementOptions,
|
||||
): Readonly<ClusterPluginPackageManagementService> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some(
|
||||
(key) =>
|
||||
key !== 'pool' &&
|
||||
key !== 'approvalLifetimeMs' &&
|
||||
key !== 'now' &&
|
||||
key !== 'quota',
|
||||
)
|
||||
) {
|
||||
throw new TypeError(
|
||||
'cluster Plugin Package management options are invalid',
|
||||
);
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
const policy = new ProjectPolicyEngine(
|
||||
new PostgresProjectPolicyRepository(options.pool),
|
||||
);
|
||||
const installations = new PostgresPluginPackageInstallInventoryReader(
|
||||
options.pool,
|
||||
);
|
||||
const service = createPluginPackageManagementService(
|
||||
policy,
|
||||
new PostgresPluginPackageInstallProposalRepository(options.pool),
|
||||
new PostgresApprovalRequestRepository(options.pool),
|
||||
Object.freeze({
|
||||
async dispatchBatch(): Promise<never> {
|
||||
throw new Error(
|
||||
'cluster Plugin Package management cannot execute approved actions',
|
||||
);
|
||||
},
|
||||
}),
|
||||
{
|
||||
decisionMode: CLUSTER_PLUGIN_PACKAGE_MANAGEMENT_DECISION_MODE,
|
||||
consumer: Object.freeze({
|
||||
subject: Object.freeze({
|
||||
type: 'system' as const,
|
||||
id: 'cluster_package_management_unreachable_consumer',
|
||||
}),
|
||||
authenticationId: 'cluster-package-management-unreachable-consumer',
|
||||
}),
|
||||
...(options.approvalLifetimeMs === undefined
|
||||
? {}
|
||||
: { approvalLifetimeMs: options.approvalLifetimeMs }),
|
||||
now,
|
||||
...(options.quota === undefined ? {} : { quota: options.quota }),
|
||||
},
|
||||
);
|
||||
|
||||
const allowed = (
|
||||
decision: Readonly<SecurityPolicyDecision>,
|
||||
allowApproval: boolean,
|
||||
): boolean =>
|
||||
decision.fence !== null &&
|
||||
(decision.effect === 'allow' ||
|
||||
(allowApproval && decision.effect === 'require_approval'));
|
||||
|
||||
const authorizeInstallationInventory = async (
|
||||
projectId: string,
|
||||
inspectionId: string,
|
||||
principalValue: SecurityPrincipal,
|
||||
): Promise<Readonly<SecurityPrincipal>> => {
|
||||
const observedAtMs = now();
|
||||
if (!Number.isSafeInteger(observedAtMs) || observedAtMs < 0) {
|
||||
throw new PluginPackageManagementUnavailableError();
|
||||
}
|
||||
let principal: Readonly<SecurityPrincipal>;
|
||||
try {
|
||||
principal = normalizeSecurityPrincipal(principalValue, observedAtMs);
|
||||
} catch {
|
||||
throw new PluginPackageManagementAuthorizationError();
|
||||
}
|
||||
let decision: Readonly<SecurityPolicyDecision>;
|
||||
try {
|
||||
decision = await policy.authorize(principal, projectId, 'package.manage');
|
||||
} catch (error) {
|
||||
throw new PluginPackageManagementUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
if (!allowed(decision, true)) {
|
||||
throw new PluginPackageManagementAuthorizationError();
|
||||
}
|
||||
if (options.quota) {
|
||||
try {
|
||||
await options.quota.consume({
|
||||
projectId,
|
||||
subject: principal.subject,
|
||||
operation: 'plugin-package.inspect',
|
||||
idempotencyKey: inspectionId,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackageManagementQuotaExceededError) {
|
||||
throw error;
|
||||
}
|
||||
throw new PluginPackageManagementUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
return principal;
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
propose: service.propose,
|
||||
decide: service.decide,
|
||||
inspect: service.inspect,
|
||||
async inspectInstallationAuthorized(
|
||||
request: InspectAuthorizedClusterPluginPackageInstallationRequest,
|
||||
): Promise<Readonly<PluginPackageInstallInventoryItem> | null> {
|
||||
if (
|
||||
!request ||
|
||||
typeof request !== 'object' ||
|
||||
Array.isArray(request) ||
|
||||
Object.keys(request).sort().join('\0') !==
|
||||
['inspectionId', 'packageName', 'principal', 'projectId']
|
||||
.sort()
|
||||
.join('\0') ||
|
||||
typeof request.projectId !== 'string' ||
|
||||
!PROJECT_ID_PATTERN.test(request.projectId) ||
|
||||
typeof request.packageName !== 'string' ||
|
||||
!PACKAGE_NAME_PATTERN.test(request.packageName) ||
|
||||
typeof request.inspectionId !== 'string' ||
|
||||
!INSPECTION_ID_PATTERN.test(request.inspectionId)
|
||||
) {
|
||||
throw new PluginPackageManagementRequestError(
|
||||
'installation inspection request is invalid',
|
||||
);
|
||||
}
|
||||
await authorizeInstallationInventory(
|
||||
request.projectId,
|
||||
request.inspectionId,
|
||||
request.principal,
|
||||
);
|
||||
try {
|
||||
return await installations.findCurrent(
|
||||
request.projectId,
|
||||
request.packageName,
|
||||
);
|
||||
} catch (error) {
|
||||
throw new PluginPackageManagementUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
},
|
||||
async listInstallationsAuthorized(
|
||||
request: ListAuthorizedClusterPluginPackageInstallationsRequest,
|
||||
): Promise<Readonly<PluginPackageInstallInventoryPage>> {
|
||||
const keys =
|
||||
request && typeof request === 'object' && !Array.isArray(request)
|
||||
? Object.keys(request)
|
||||
: [];
|
||||
if (
|
||||
!request ||
|
||||
typeof request !== 'object' ||
|
||||
Array.isArray(request) ||
|
||||
!keys.includes('projectId') ||
|
||||
!keys.includes('limit') ||
|
||||
!keys.includes('inspectionId') ||
|
||||
!keys.includes('principal') ||
|
||||
keys.some(
|
||||
(key) =>
|
||||
![
|
||||
'after',
|
||||
'inspectionId',
|
||||
'limit',
|
||||
'principal',
|
||||
'projectId',
|
||||
].includes(key),
|
||||
) ||
|
||||
typeof request.projectId !== 'string' ||
|
||||
!PROJECT_ID_PATTERN.test(request.projectId) ||
|
||||
!Number.isSafeInteger(request.limit) ||
|
||||
request.limit < 1 ||
|
||||
request.limit > MAX_PLUGIN_PACKAGE_INSTALL_INVENTORY_PAGE_SIZE ||
|
||||
typeof request.inspectionId !== 'string' ||
|
||||
!INSPECTION_ID_PATTERN.test(request.inspectionId)
|
||||
) {
|
||||
throw new PluginPackageManagementRequestError(
|
||||
'installation list request is invalid',
|
||||
);
|
||||
}
|
||||
let after: Readonly<{ packageName: string }> | undefined;
|
||||
try {
|
||||
after =
|
||||
request.after === undefined
|
||||
? undefined
|
||||
: normalizePluginPackageInstallInventoryCursor(request.after);
|
||||
} catch {
|
||||
throw new PluginPackageManagementRequestError(
|
||||
'installation list cursor is invalid',
|
||||
);
|
||||
}
|
||||
await authorizeInstallationInventory(
|
||||
request.projectId,
|
||||
request.inspectionId,
|
||||
request.principal,
|
||||
);
|
||||
try {
|
||||
return await installations.listCurrentPage({
|
||||
projectId: request.projectId,
|
||||
limit: request.limit,
|
||||
...(after === undefined ? {} : { after }),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new PluginPackageManagementUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
},
|
||||
async inspectAuthorized(
|
||||
request: InspectAuthorizedClusterPluginPackageRequest,
|
||||
): Promise<Readonly<InspectPluginPackageInstallResult>> {
|
||||
if (
|
||||
!request ||
|
||||
typeof request !== 'object' ||
|
||||
Array.isArray(request) ||
|
||||
Object.keys(request).length !== 4 ||
|
||||
Object.keys(request).some(
|
||||
(key) =>
|
||||
![
|
||||
'actionRef',
|
||||
'approvalRequestId',
|
||||
'inspectionId',
|
||||
'principal',
|
||||
].includes(key),
|
||||
) ||
|
||||
typeof request.inspectionId !== 'string' ||
|
||||
!INSPECTION_ID_PATTERN.test(request.inspectionId)
|
||||
) {
|
||||
throw new PluginPackageManagementRequestError(
|
||||
'inspection request is invalid',
|
||||
);
|
||||
}
|
||||
const observedAtMs = now();
|
||||
if (!Number.isSafeInteger(observedAtMs) || observedAtMs < 0) {
|
||||
throw new PluginPackageManagementUnavailableError();
|
||||
}
|
||||
let principal: Readonly<SecurityPrincipal>;
|
||||
try {
|
||||
principal = normalizeSecurityPrincipal(request.principal, observedAtMs);
|
||||
} catch {
|
||||
throw new PluginPackageManagementAuthorizationError();
|
||||
}
|
||||
const current = await service.inspect(
|
||||
request.actionRef,
|
||||
request.approvalRequestId,
|
||||
);
|
||||
const projectId =
|
||||
current.proposal?.projectId ?? current.approvalRequest?.projectId;
|
||||
if (!projectId) {
|
||||
throw new PluginPackageManagementConflictError(
|
||||
'Plugin Package management state does not exist',
|
||||
);
|
||||
}
|
||||
if (
|
||||
(current.proposal &&
|
||||
current.approvalRequest &&
|
||||
(current.proposal.projectId !== current.approvalRequest.projectId ||
|
||||
current.approvalRequest.action.actionRef !==
|
||||
current.proposal.actionRef ||
|
||||
current.approvalRequest.action.actionDigest !==
|
||||
current.proposal.actionDigest ||
|
||||
current.approvalRequest.action.previewDigest !==
|
||||
current.proposal.previewDigest)) ||
|
||||
(current.proposal &&
|
||||
current.proposal.actionRef !== request.actionRef) ||
|
||||
(current.approvalRequest &&
|
||||
current.approvalRequest.id !== request.approvalRequestId)
|
||||
) {
|
||||
throw new PluginPackageManagementUnavailableError();
|
||||
}
|
||||
let packageDecision: Readonly<SecurityPolicyDecision>;
|
||||
let approvalDecision: Readonly<SecurityPolicyDecision> | undefined;
|
||||
try {
|
||||
packageDecision = await policy.authorize(
|
||||
principal,
|
||||
projectId,
|
||||
'package.manage',
|
||||
);
|
||||
if (!allowed(packageDecision, true)) {
|
||||
approvalDecision = await policy.authorize(
|
||||
principal,
|
||||
projectId,
|
||||
'approval.decide',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new PluginPackageManagementUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
if (
|
||||
!allowed(packageDecision, true) &&
|
||||
(!approvalDecision || !allowed(approvalDecision, false))
|
||||
) {
|
||||
throw new PluginPackageManagementAuthorizationError();
|
||||
}
|
||||
if (options.quota) {
|
||||
try {
|
||||
await options.quota.consume({
|
||||
projectId,
|
||||
subject: principal.subject,
|
||||
operation: 'plugin-package.inspect',
|
||||
idempotencyKey: request.inspectionId,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackageManagementQuotaExceededError) {
|
||||
throw error;
|
||||
}
|
||||
throw new PluginPackageManagementUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
return current;
|
||||
},
|
||||
});
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env node
|
||||
/** One-shot Plugin Package management process CLI boundary. */
|
||||
|
||||
import {
|
||||
startClusterPluginPackageManagementProcess,
|
||||
type ClusterPluginPackageManagementProcessRuntime,
|
||||
} from './pluginPackageManagementProcess';
|
||||
|
||||
const USAGE = 'Usage: ql3-plugin-package-manage';
|
||||
|
||||
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-management',
|
||||
event: 'management_failed',
|
||||
name:
|
||||
typeof candidate?.name === 'string' && candidate.name.length <= 128
|
||||
? candidate.name
|
||||
: 'Error',
|
||||
...(typeof candidate?.code === 'string' && candidate.code.length <= 128
|
||||
? { code: candidate.code }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
function emit(value: Readonly<Record<string, unknown>>): void {
|
||||
process.stdout.write(`${JSON.stringify(value)}\n`);
|
||||
}
|
||||
|
||||
async function run(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_MANAGEMENT_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
|
||||
let runtime: Readonly<ClusterPluginPackageManagementProcessRuntime>;
|
||||
try {
|
||||
runtime = await startClusterPluginPackageManagementProcess({
|
||||
environment: process.env,
|
||||
onError() {
|
||||
emit({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-plugin-package-management',
|
||||
event: 'management_unavailable',
|
||||
});
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
process.stderr.write(`${JSON.stringify(failureFact(error))}\n`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (runtime.status === 'disabled') {
|
||||
emit({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-plugin-package-management',
|
||||
event: 'management_disabled',
|
||||
});
|
||||
return;
|
||||
}
|
||||
emit({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-plugin-package-management',
|
||||
event: 'management_started',
|
||||
address: runtime.address,
|
||||
identityGeneration: runtime.identity.generation,
|
||||
databaseContractVersion: runtime.database.contractVersion,
|
||||
databaseMigrationCount: runtime.database.migrationIds.length,
|
||||
});
|
||||
|
||||
let stopping: Promise<void> | undefined;
|
||||
const stop = (): Promise<void> => {
|
||||
stopping ??= runtime.close().then(() => {
|
||||
emit({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-plugin-package-management',
|
||||
event: 'management_stopped',
|
||||
});
|
||||
});
|
||||
return stopping;
|
||||
};
|
||||
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
||||
process.once(signal, () => {
|
||||
void stop().then(
|
||||
() => {
|
||||
process.exitCode = 0;
|
||||
},
|
||||
(error) => {
|
||||
process.stderr.write(`${JSON.stringify(failureFact(error))}\n`);
|
||||
process.exitCode = 1;
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void run(process.argv.slice(2));
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env node
|
||||
/** One-shot Plugin Package management client CLI boundary. */
|
||||
|
||||
import {
|
||||
ClusterPluginPackageManagementClientRemoteError,
|
||||
executeClusterPluginPackageManagementClient,
|
||||
} from '../../management-support/pluginPackageManagementClient';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-plugin-package-client --config=/absolute/client.json --command=/absolute/command.json --assertion=/absolute/assertion.jwt';
|
||||
|
||||
function parseArguments(
|
||||
argv: readonly string[],
|
||||
): Readonly<{
|
||||
configFile: string;
|
||||
commandFile: string;
|
||||
assertionFile: string;
|
||||
}> | null {
|
||||
if (argv.length !== 3) return null;
|
||||
const values = new Map<string, string>();
|
||||
for (const argument of argv) {
|
||||
const match = /^--(config|command|assertion)=(\/.+)$/.exec(argument);
|
||||
if (!match || values.has(match[1]!)) return null;
|
||||
values.set(match[1]!, match[2]!);
|
||||
}
|
||||
if (
|
||||
!values.has('config') ||
|
||||
!values.has('command') ||
|
||||
!values.has('assertion')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({
|
||||
configFile: values.get('config')!,
|
||||
commandFile: values.get('command')!,
|
||||
assertionFile: values.get('assertion')!,
|
||||
});
|
||||
}
|
||||
|
||||
function failureFact(error: unknown): Readonly<Record<string, unknown>> {
|
||||
const candidate = error as {
|
||||
readonly code?: unknown;
|
||||
};
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-plugin-package-management-client',
|
||||
event: 'command_failed',
|
||||
code:
|
||||
typeof candidate?.code === 'string' &&
|
||||
candidate.code.length <= 128
|
||||
? candidate.code
|
||||
: 'QL3_PLUGIN_PACKAGE_MANAGEMENT_CLIENT_FAILED',
|
||||
...(error instanceof ClusterPluginPackageManagementClientRemoteError
|
||||
? {
|
||||
statusCode: error.statusCode,
|
||||
responseCode: error.responseCode,
|
||||
requestId: error.requestId,
|
||||
...(error.retryAfterSeconds === null
|
||||
? {}
|
||||
: { retryAfterSeconds: error.retryAfterSeconds }),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function run(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
const paths = parseArguments(argv);
|
||||
if (!paths) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-plugin-package-management-client',
|
||||
event: 'usage_invalid',
|
||||
code: 'QL3_PLUGIN_PACKAGE_MANAGEMENT_CLIENT_USAGE_INVALID',
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result =
|
||||
await executeClusterPluginPackageManagementClient(paths);
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-plugin-package-management-client',
|
||||
event: 'command_completed',
|
||||
requestId: result.requestId,
|
||||
result: result.result,
|
||||
})}\n`,
|
||||
);
|
||||
} catch (error) {
|
||||
process.stderr.write(`${JSON.stringify(failureFact(error))}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void run(process.argv.slice(2));
|
||||
+796
@@ -0,0 +1,796 @@
|
||||
/** Explicit Kubernetes PortForward client boundary for Plugin Package management. */
|
||||
import {
|
||||
createPrivateKey,
|
||||
X509Certificate,
|
||||
} from 'node:crypto';
|
||||
import { Duplex, PassThrough, Writable } from 'node:stream';
|
||||
import { TextDecoder } from 'node:util';
|
||||
|
||||
import {
|
||||
ClusterPluginPackageManagementClientConfigurationError,
|
||||
ClusterPluginPackageManagementClientRemoteError,
|
||||
ClusterPluginPackageManagementClientRequestError,
|
||||
executeClusterPluginPackageManagementClient,
|
||||
readCanonicalFile,
|
||||
type ClusterPluginPackageManagementClientPaths,
|
||||
type ClusterPluginPackageManagementClientRawConnection,
|
||||
type ClusterPluginPackageManagementClientResult,
|
||||
} from '../../management-support/pluginPackageManagementClient';
|
||||
|
||||
const MAX_KUBERNETES_CONFIG_BYTES = 16 * 1024;
|
||||
const MAX_KUBECONFIG_BYTES = 256 * 1024;
|
||||
const MAX_KUBERNETES_CA_BYTES = 256 * 1024;
|
||||
const MAX_KUBERNETES_CLIENT_MATERIAL_BYTES = 256 * 1024;
|
||||
const MAX_KUBERNETES_TOKEN_BYTES = 16 * 1024;
|
||||
const MANAGEMENT_NAME = 'ql3-plugin-package-management';
|
||||
const MANAGEMENT_PORT = 8443;
|
||||
const MANAGEMENT_LABEL_SELECTOR =
|
||||
'app.kubernetes.io/name=ql3-plugin-package-management,' +
|
||||
'app.kubernetes.io/component=plugin-package-management';
|
||||
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
||||
const DNS_LABEL_PATTERN =
|
||||
/^[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?$/;
|
||||
const CONTEXT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,255}$/;
|
||||
const POD_NAME_PATTERN =
|
||||
/^ql3-plugin-package-management-[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?(?:-[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?)?$/;
|
||||
const TOKEN_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._~+/-]{0,16383}$/;
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
type KubernetesModule = typeof import('@kubernetes/client-node', {
|
||||
with: { 'resolution-mode': 'import' }
|
||||
});
|
||||
type KubernetesConfig = InstanceType<KubernetesModule['KubeConfig']>;
|
||||
|
||||
interface ReviewedKubernetesClientConfig {
|
||||
readonly schemaVersion: 1;
|
||||
readonly kubeconfigFile: string;
|
||||
readonly context: string;
|
||||
readonly namespace: string;
|
||||
readonly apiTimeoutMs: number;
|
||||
}
|
||||
|
||||
interface KubernetesPod {
|
||||
readonly metadata?: {
|
||||
readonly name?: string;
|
||||
readonly namespace?: string;
|
||||
readonly uid?: string;
|
||||
readonly deletionTimestamp?: unknown;
|
||||
readonly labels?: Readonly<Record<string, string>>;
|
||||
};
|
||||
readonly spec?: {
|
||||
readonly serviceAccountName?: string;
|
||||
readonly automountServiceAccountToken?: boolean;
|
||||
readonly containers?: readonly Readonly<{ readonly name?: string }>[];
|
||||
};
|
||||
readonly status?: {
|
||||
readonly phase?: string;
|
||||
readonly conditions?: readonly Readonly<{
|
||||
readonly type?: string;
|
||||
readonly status?: string;
|
||||
}>[];
|
||||
readonly containerStatuses?: readonly Readonly<{
|
||||
readonly name?: string;
|
||||
readonly ready?: boolean;
|
||||
}>[];
|
||||
};
|
||||
}
|
||||
|
||||
interface KubernetesPodList {
|
||||
readonly metadata?: {
|
||||
readonly continue?: string;
|
||||
};
|
||||
readonly items?: readonly KubernetesPod[];
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageManagementKubernetesPodApi {
|
||||
listNamespacedPod(
|
||||
request: Readonly<{
|
||||
namespace: string;
|
||||
labelSelector: string;
|
||||
limit: number;
|
||||
timeoutSeconds: number;
|
||||
watch: false;
|
||||
}>,
|
||||
): Promise<KubernetesPodList>;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageManagementKubernetesRuntime {
|
||||
readonly pods: ClusterPluginPackageManagementKubernetesPodApi;
|
||||
openPortForward(
|
||||
request: Readonly<{
|
||||
namespace: string;
|
||||
podName: string;
|
||||
port: 8443;
|
||||
}>,
|
||||
): Promise<ClusterPluginPackageManagementClientRawConnection>;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageManagementPortForwardWebSocket {
|
||||
addEventListener(
|
||||
type: 'close' | 'error',
|
||||
listener: () => void,
|
||||
): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageManagementPortForwardApi {
|
||||
portForward(
|
||||
namespace: string,
|
||||
podName: string,
|
||||
targetPorts: number[],
|
||||
output: Writable,
|
||||
error: Writable,
|
||||
input: PassThrough,
|
||||
retryCount: 0,
|
||||
): Promise<
|
||||
| ClusterPluginPackageManagementPortForwardWebSocket
|
||||
| (() => ClusterPluginPackageManagementPortForwardWebSocket | null)
|
||||
>;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageManagementKubernetesClientPaths
|
||||
extends ClusterPluginPackageManagementClientPaths {
|
||||
readonly kubernetesFile: string;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageManagementKubernetesClientOptions {
|
||||
readonly createRuntime?: (
|
||||
kubeConfig: KubernetesConfig,
|
||||
kubernetes: KubernetesModule,
|
||||
) => ClusterPluginPackageManagementKubernetesRuntime;
|
||||
}
|
||||
|
||||
export class ClusterPluginPackageManagementKubernetesClientConfigurationError extends TypeError {
|
||||
readonly code =
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_KUBERNETES_CLIENT_CONFIG_INVALID';
|
||||
|
||||
constructor() {
|
||||
super('Kubernetes Plugin Package management client configuration is invalid');
|
||||
this.name =
|
||||
'ClusterPluginPackageManagementKubernetesClientConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterPluginPackageManagementKubernetesClientTunnelError extends Error {
|
||||
readonly code =
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_KUBERNETES_CLIENT_TUNNEL_FAILED';
|
||||
|
||||
constructor(readonly cause?: unknown) {
|
||||
super('Kubernetes Plugin Package management tunnel failed');
|
||||
this.name = 'ClusterPluginPackageManagementKubernetesClientTunnelError';
|
||||
}
|
||||
}
|
||||
|
||||
function configurationFailure(): ClusterPluginPackageManagementKubernetesClientConfigurationError {
|
||||
return new ClusterPluginPackageManagementKubernetesClientConfigurationError();
|
||||
}
|
||||
|
||||
function exactObject(
|
||||
value: unknown,
|
||||
expectedKeys: readonly string[],
|
||||
): asserts value is JsonObject {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...expectedKeys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
}
|
||||
|
||||
function decodeUtf8(bytes: Buffer): string {
|
||||
try {
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
||||
} catch {
|
||||
throw configurationFailure();
|
||||
}
|
||||
}
|
||||
|
||||
function parseJson(bytes: Buffer): unknown {
|
||||
try {
|
||||
return JSON.parse(decodeUtf8(bytes));
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof
|
||||
ClusterPluginPackageManagementKubernetesClientConfigurationError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw configurationFailure();
|
||||
}
|
||||
}
|
||||
|
||||
function readPrivateFile(filePath: string, maximumBytes: number): Buffer {
|
||||
try {
|
||||
return readCanonicalFile(filePath, maximumBytes, 'private');
|
||||
} catch {
|
||||
throw configurationFailure();
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeConfig(
|
||||
value: unknown,
|
||||
): Readonly<ReviewedKubernetesClientConfig> {
|
||||
exactObject(value, [
|
||||
'schemaVersion',
|
||||
'kubeconfigFile',
|
||||
'context',
|
||||
'namespace',
|
||||
'apiTimeoutMs',
|
||||
]);
|
||||
if (
|
||||
value.schemaVersion !== 1 ||
|
||||
typeof value.kubeconfigFile !== 'string' ||
|
||||
typeof value.context !== 'string' ||
|
||||
!CONTEXT_PATTERN.test(value.context) ||
|
||||
typeof value.namespace !== 'string' ||
|
||||
!DNS_LABEL_PATTERN.test(value.namespace) ||
|
||||
!Number.isSafeInteger(value.apiTimeoutMs) ||
|
||||
(value.apiTimeoutMs as number) < 1_000 ||
|
||||
(value.apiTimeoutMs as number) > 30_000
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
kubeconfigFile: value.kubeconfigFile,
|
||||
context: value.context,
|
||||
namespace: value.namespace,
|
||||
apiTimeoutMs: value.apiTimeoutMs as number,
|
||||
});
|
||||
}
|
||||
|
||||
function decodeCanonicalBase64(
|
||||
value: unknown,
|
||||
maximumBytes: number,
|
||||
): Buffer {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 4 ||
|
||||
value.length > maximumBytes * 2 ||
|
||||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(
|
||||
value,
|
||||
)
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
const bytes = Buffer.from(value, 'base64');
|
||||
if (
|
||||
bytes.length < 1 ||
|
||||
bytes.length > maximumBytes ||
|
||||
bytes.toString('base64') !== value
|
||||
) {
|
||||
bytes.fill(0);
|
||||
throw configurationFailure();
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function validateRawKubeconfig(
|
||||
value: unknown,
|
||||
config: Readonly<ReviewedKubernetesClientConfig>,
|
||||
): void {
|
||||
exactObject(value, [
|
||||
'apiVersion',
|
||||
'kind',
|
||||
'clusters',
|
||||
'users',
|
||||
'contexts',
|
||||
'current-context',
|
||||
]);
|
||||
if (
|
||||
value.apiVersion !== 'v1' ||
|
||||
value.kind !== 'Config' ||
|
||||
value['current-context'] !== config.context ||
|
||||
!Array.isArray(value.clusters) ||
|
||||
value.clusters.length !== 1 ||
|
||||
!Array.isArray(value.users) ||
|
||||
value.users.length !== 1 ||
|
||||
!Array.isArray(value.contexts) ||
|
||||
value.contexts.length !== 1
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
const clusterEntry = value.clusters[0];
|
||||
const userEntry = value.users[0];
|
||||
const contextEntry = value.contexts[0];
|
||||
exactObject(clusterEntry, ['name', 'cluster']);
|
||||
const rawCluster = clusterEntry.cluster;
|
||||
exactObject(rawCluster, [
|
||||
'server',
|
||||
'certificate-authority-data',
|
||||
]);
|
||||
exactObject(userEntry, ['name', 'user']);
|
||||
const rawUser = userEntry.user;
|
||||
if (!rawUser || typeof rawUser !== 'object' || Array.isArray(rawUser)) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
exactObject(contextEntry, ['name', 'context']);
|
||||
const rawContext = contextEntry.context;
|
||||
exactObject(rawContext, [
|
||||
'cluster',
|
||||
'user',
|
||||
'namespace',
|
||||
]);
|
||||
if (
|
||||
typeof clusterEntry.name !== 'string' ||
|
||||
!CONTEXT_PATTERN.test(clusterEntry.name) ||
|
||||
typeof userEntry.name !== 'string' ||
|
||||
!CONTEXT_PATTERN.test(userEntry.name) ||
|
||||
contextEntry.name !== config.context ||
|
||||
rawContext.cluster !== clusterEntry.name ||
|
||||
rawContext.user !== userEntry.name ||
|
||||
rawContext.namespace !== config.namespace
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
const userKeys = Object.keys(rawUser).sort();
|
||||
if (
|
||||
JSON.stringify(userKeys) !== JSON.stringify(['token']) &&
|
||||
JSON.stringify(userKeys) !==
|
||||
JSON.stringify(
|
||||
['client-certificate-data', 'client-key-data'].sort(),
|
||||
)
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
}
|
||||
|
||||
function validateKubeConfig(
|
||||
kubeConfig: KubernetesConfig,
|
||||
config: Readonly<ReviewedKubernetesClientConfig>,
|
||||
): void {
|
||||
kubeConfig.setCurrentContext(config.context);
|
||||
if (kubeConfig.getCurrentContext() !== config.context) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
const context = kubeConfig.getContextObject(config.context);
|
||||
const cluster = kubeConfig.getCurrentCluster();
|
||||
const user = kubeConfig.getCurrentUser();
|
||||
if (
|
||||
!context ||
|
||||
context.namespace !== config.namespace ||
|
||||
!cluster ||
|
||||
!user
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
|
||||
let server: URL;
|
||||
try {
|
||||
server = new URL(cluster.server);
|
||||
} catch {
|
||||
throw configurationFailure();
|
||||
}
|
||||
if (
|
||||
server.protocol !== 'https:' ||
|
||||
server.username !== '' ||
|
||||
server.password !== '' ||
|
||||
(server.pathname !== '' && server.pathname !== '/') ||
|
||||
server.search !== '' ||
|
||||
server.hash !== '' ||
|
||||
server.hostname.length < 1 ||
|
||||
cluster.skipTLSVerify !== false ||
|
||||
cluster.proxyUrl != null ||
|
||||
cluster.caFile != null ||
|
||||
typeof cluster.caData !== 'string' ||
|
||||
(cluster.tlsServerName != null &&
|
||||
cluster.tlsServerName !== server.hostname)
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
const ca = decodeCanonicalBase64(
|
||||
cluster.caData,
|
||||
MAX_KUBERNETES_CA_BYTES,
|
||||
);
|
||||
try {
|
||||
new X509Certificate(ca);
|
||||
} catch {
|
||||
throw configurationFailure();
|
||||
} finally {
|
||||
ca.fill(0);
|
||||
}
|
||||
|
||||
if (
|
||||
user.exec != null ||
|
||||
user.authProvider != null ||
|
||||
user.certFile != null ||
|
||||
user.keyFile != null ||
|
||||
user.username != null ||
|
||||
user.password != null ||
|
||||
user.impersonateUser != null
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
const hasToken = user.token != null;
|
||||
const hasCertificate =
|
||||
user.certData != null || user.keyData != null;
|
||||
if (
|
||||
hasToken === hasCertificate ||
|
||||
(hasToken &&
|
||||
(typeof user.token !== 'string' ||
|
||||
Buffer.byteLength(user.token, 'utf8') >
|
||||
MAX_KUBERNETES_TOKEN_BYTES ||
|
||||
CONTROL_PATTERN.test(user.token) ||
|
||||
!TOKEN_PATTERN.test(user.token)))
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
if (hasCertificate) {
|
||||
const certificate = decodeCanonicalBase64(
|
||||
user.certData,
|
||||
MAX_KUBERNETES_CLIENT_MATERIAL_BYTES,
|
||||
);
|
||||
const privateKey = decodeCanonicalBase64(
|
||||
user.keyData,
|
||||
MAX_KUBERNETES_CLIENT_MATERIAL_BYTES,
|
||||
);
|
||||
try {
|
||||
const parsedCertificate = new X509Certificate(certificate);
|
||||
const parsedPrivateKey = createPrivateKey(privateKey);
|
||||
if (!parsedCertificate.checkPrivateKey(parsedPrivateKey)) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof
|
||||
ClusterPluginPackageManagementKubernetesClientConfigurationError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw configurationFailure();
|
||||
} finally {
|
||||
certificate.fill(0);
|
||||
privateKey.fill(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isReviewedPod(
|
||||
value: KubernetesPod,
|
||||
namespace: string,
|
||||
): value is KubernetesPod & {
|
||||
readonly metadata: {
|
||||
readonly name: string;
|
||||
readonly namespace: string;
|
||||
readonly uid: string;
|
||||
};
|
||||
} {
|
||||
const labels = value.metadata?.labels;
|
||||
return (
|
||||
typeof value.metadata?.name === 'string' &&
|
||||
POD_NAME_PATTERN.test(value.metadata.name) &&
|
||||
value.metadata.namespace === namespace &&
|
||||
typeof value.metadata.uid === 'string' &&
|
||||
value.metadata.uid.length >= 8 &&
|
||||
value.metadata.uid.length <= 128 &&
|
||||
!CONTROL_PATTERN.test(value.metadata.uid) &&
|
||||
value.metadata.deletionTimestamp === undefined &&
|
||||
labels?.['app.kubernetes.io/name'] === MANAGEMENT_NAME &&
|
||||
labels?.['app.kubernetes.io/component'] ===
|
||||
'plugin-package-management' &&
|
||||
value.spec?.serviceAccountName === MANAGEMENT_NAME &&
|
||||
value.spec?.automountServiceAccountToken === false &&
|
||||
value.spec?.containers?.some(({ name }) => name === 'management') ===
|
||||
true &&
|
||||
value.status?.phase === 'Running' &&
|
||||
value.status.conditions?.some(
|
||||
({ type, status }) => type === 'Ready' && status === 'True',
|
||||
) === true &&
|
||||
value.status.containerStatuses?.some(
|
||||
({ name, ready }) => name === 'management' && ready === true,
|
||||
) === true
|
||||
);
|
||||
}
|
||||
|
||||
function selectManagementPod(
|
||||
value: KubernetesPodList,
|
||||
namespace: string,
|
||||
): string {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
!Array.isArray(value.items) ||
|
||||
value.items.length < 1 ||
|
||||
value.items.length > 3 ||
|
||||
(value.metadata?.continue !== undefined &&
|
||||
value.metadata.continue !== '')
|
||||
) {
|
||||
throw new ClusterPluginPackageManagementKubernetesClientTunnelError();
|
||||
}
|
||||
const current = value.items.filter(
|
||||
({ metadata }) => metadata?.deletionTimestamp === undefined,
|
||||
);
|
||||
if (
|
||||
current.length < 1 ||
|
||||
current.length > 2 ||
|
||||
!current.every((pod) => isReviewedPod(pod, namespace))
|
||||
) {
|
||||
throw new ClusterPluginPackageManagementKubernetesClientTunnelError();
|
||||
}
|
||||
return current
|
||||
.map(({ metadata }) => metadata!.name!)
|
||||
.sort()[0]!;
|
||||
}
|
||||
|
||||
function deadline<T>(
|
||||
operation: Promise<T>,
|
||||
timeoutMs: number,
|
||||
disposeLate?: (value: T) => void | Promise<void>,
|
||||
): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(
|
||||
new ClusterPluginPackageManagementKubernetesClientTunnelError(),
|
||||
);
|
||||
}, timeoutMs);
|
||||
operation.then(
|
||||
(value) => {
|
||||
if (settled) {
|
||||
void Promise.resolve(disposeLate?.(value)).catch(() => {});
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve(value);
|
||||
},
|
||||
(error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
reject(
|
||||
error instanceof
|
||||
ClusterPluginPackageManagementKubernetesClientTunnelError
|
||||
? error
|
||||
: new ClusterPluginPackageManagementKubernetesClientTunnelError(
|
||||
error,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function openClusterPluginPackageManagementPortForward(
|
||||
forward: ClusterPluginPackageManagementPortForwardApi,
|
||||
request: Readonly<{
|
||||
namespace: string;
|
||||
podName: string;
|
||||
port: 8443;
|
||||
}>,
|
||||
): Promise<ClusterPluginPackageManagementClientRawConnection> {
|
||||
const incoming = new PassThrough();
|
||||
const outgoing = new PassThrough();
|
||||
let connection: Duplex | undefined;
|
||||
let pendingError = false;
|
||||
const errors = new Writable({
|
||||
write(chunk: Buffer | string, _encoding, callback) {
|
||||
const bytes = Buffer.isBuffer(chunk)
|
||||
? chunk
|
||||
: Buffer.from(chunk);
|
||||
const failed = bytes.length > 0;
|
||||
bytes.fill(0);
|
||||
if (failed) {
|
||||
pendingError = true;
|
||||
connection?.destroy(
|
||||
new ClusterPluginPackageManagementKubernetesClientTunnelError(),
|
||||
);
|
||||
}
|
||||
callback();
|
||||
},
|
||||
});
|
||||
const handle = await forward.portForward(
|
||||
request.namespace,
|
||||
request.podName,
|
||||
[request.port],
|
||||
incoming,
|
||||
errors,
|
||||
outgoing,
|
||||
0,
|
||||
);
|
||||
const webSocket =
|
||||
typeof handle === 'function' ? handle() : handle;
|
||||
if (!webSocket) {
|
||||
throw new ClusterPluginPackageManagementKubernetesClientTunnelError();
|
||||
}
|
||||
const nodeStreamPair = {
|
||||
readable: incoming,
|
||||
writable: outgoing,
|
||||
};
|
||||
// Node supports a { readable, writable } pair of Node streams here, while
|
||||
// @types/node@24.13.3 currently models only the equivalent Web Streams pair.
|
||||
connection = Duplex.from(
|
||||
nodeStreamPair as unknown as Parameters<typeof Duplex.from>[0],
|
||||
);
|
||||
if (pendingError) {
|
||||
connection.destroy(
|
||||
new ClusterPluginPackageManagementKubernetesClientTunnelError(),
|
||||
);
|
||||
}
|
||||
let closed = false;
|
||||
const tunnelFailure = () => {
|
||||
if (!closed) {
|
||||
connection?.destroy(
|
||||
new ClusterPluginPackageManagementKubernetesClientTunnelError(),
|
||||
);
|
||||
}
|
||||
};
|
||||
webSocket.addEventListener('close', tunnelFailure);
|
||||
webSocket.addEventListener('error', tunnelFailure);
|
||||
return Object.freeze({
|
||||
stream: connection,
|
||||
close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
connection?.end();
|
||||
incoming.end();
|
||||
outgoing.end();
|
||||
errors.end();
|
||||
webSocket.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function productionRuntime(
|
||||
kubeConfig: KubernetesConfig,
|
||||
kubernetes: KubernetesModule,
|
||||
): ClusterPluginPackageManagementKubernetesRuntime {
|
||||
const pods = kubeConfig.makeApiClient(
|
||||
kubernetes.CoreV1Api,
|
||||
) as unknown as ClusterPluginPackageManagementKubernetesPodApi;
|
||||
const forward = new kubernetes.PortForward(
|
||||
kubeConfig,
|
||||
true,
|
||||
) as unknown as ClusterPluginPackageManagementPortForwardApi;
|
||||
const runtime: ClusterPluginPackageManagementKubernetesRuntime = {
|
||||
pods,
|
||||
openPortForward: (request) =>
|
||||
openClusterPluginPackageManagementPortForward(
|
||||
forward,
|
||||
request,
|
||||
),
|
||||
};
|
||||
return Object.freeze(runtime);
|
||||
}
|
||||
|
||||
export async function executeClusterPluginPackageManagementKubernetesClient(
|
||||
paths: ClusterPluginPackageManagementKubernetesClientPaths,
|
||||
options: ClusterPluginPackageManagementKubernetesClientOptions = {},
|
||||
): Promise<Readonly<ClusterPluginPackageManagementClientResult>> {
|
||||
exactObject(paths, [
|
||||
'configFile',
|
||||
'commandFile',
|
||||
'assertionFile',
|
||||
'kubernetesFile',
|
||||
]);
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some((key) => key !== 'createRuntime') ||
|
||||
(options.createRuntime !== undefined &&
|
||||
typeof options.createRuntime !== 'function')
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
|
||||
let kubernetesConfigBytes: Buffer | undefined;
|
||||
let kubeconfigBytes: Buffer | undefined;
|
||||
try {
|
||||
kubernetesConfigBytes = readPrivateFile(
|
||||
paths.kubernetesFile,
|
||||
MAX_KUBERNETES_CONFIG_BYTES,
|
||||
);
|
||||
const config = normalizeConfig(parseJson(kubernetesConfigBytes));
|
||||
kubeconfigBytes = readPrivateFile(
|
||||
config.kubeconfigFile,
|
||||
MAX_KUBECONFIG_BYTES,
|
||||
);
|
||||
const kubernetes = await import('@kubernetes/client-node');
|
||||
const kubeConfig = new kubernetes.KubeConfig();
|
||||
try {
|
||||
const kubeconfigText = decodeUtf8(kubeconfigBytes);
|
||||
validateRawKubeconfig(parseJson(kubeconfigBytes), config);
|
||||
kubeConfig.loadFromString(kubeconfigText);
|
||||
validateKubeConfig(kubeConfig, config);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof
|
||||
ClusterPluginPackageManagementKubernetesClientConfigurationError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw configurationFailure();
|
||||
}
|
||||
const runtime = (options.createRuntime ?? productionRuntime)(
|
||||
kubeConfig,
|
||||
kubernetes,
|
||||
);
|
||||
if (
|
||||
!runtime ||
|
||||
typeof runtime !== 'object' ||
|
||||
typeof runtime.pods?.listNamespacedPod !== 'function' ||
|
||||
typeof runtime.openPortForward !== 'function'
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
|
||||
const expectedHostname =
|
||||
`${MANAGEMENT_NAME}.${config.namespace}.svc`;
|
||||
return await executeClusterPluginPackageManagementClient(
|
||||
{
|
||||
configFile: paths.configFile,
|
||||
commandFile: paths.commandFile,
|
||||
assertionFile: paths.assertionFile,
|
||||
},
|
||||
{
|
||||
async connect(target) {
|
||||
if (
|
||||
target.hostname !== expectedHostname ||
|
||||
target.port !== MANAGEMENT_PORT
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
const list = await deadline(
|
||||
runtime.pods.listNamespacedPod({
|
||||
namespace: config.namespace,
|
||||
labelSelector: MANAGEMENT_LABEL_SELECTOR,
|
||||
limit: 3,
|
||||
timeoutSeconds: Math.ceil(config.apiTimeoutMs / 1_000),
|
||||
watch: false,
|
||||
}),
|
||||
config.apiTimeoutMs,
|
||||
);
|
||||
const podName = selectManagementPod(
|
||||
list,
|
||||
config.namespace,
|
||||
);
|
||||
return await deadline(
|
||||
runtime.openPortForward({
|
||||
namespace: config.namespace,
|
||||
podName,
|
||||
port: MANAGEMENT_PORT,
|
||||
}),
|
||||
config.apiTimeoutMs,
|
||||
async (connection) => {
|
||||
await connection.close();
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ClusterPluginPackageManagementClientRequestError &&
|
||||
error.cause instanceof
|
||||
ClusterPluginPackageManagementKubernetesClientTunnelError
|
||||
) {
|
||||
throw error.cause;
|
||||
}
|
||||
if (
|
||||
error instanceof
|
||||
ClusterPluginPackageManagementKubernetesClientConfigurationError ||
|
||||
error instanceof
|
||||
ClusterPluginPackageManagementKubernetesClientTunnelError ||
|
||||
error instanceof
|
||||
ClusterPluginPackageManagementClientConfigurationError ||
|
||||
error instanceof ClusterPluginPackageManagementClientRequestError ||
|
||||
error instanceof ClusterPluginPackageManagementClientRemoteError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new ClusterPluginPackageManagementKubernetesClientTunnelError(
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
kubernetesConfigBytes?.fill(0);
|
||||
kubeconfigBytes?.fill(0);
|
||||
}
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env node
|
||||
/** One-shot Kubernetes-tunneled Plugin Package management client CLI boundary. */
|
||||
|
||||
import {
|
||||
ClusterPluginPackageManagementClientRemoteError,
|
||||
} from '../../management-support/pluginPackageManagementClient';
|
||||
import {
|
||||
executeClusterPluginPackageManagementKubernetesClient,
|
||||
} from './pluginPackageManagementKubernetesClient';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-plugin-package-client-kubernetes ' +
|
||||
'--config=/absolute/client.json --command=/absolute/command.json ' +
|
||||
'--assertion=/absolute/assertion.jwt ' +
|
||||
'--kubernetes=/absolute/kubernetes.json';
|
||||
|
||||
function parseArguments(
|
||||
argv: readonly string[],
|
||||
): Readonly<{
|
||||
configFile: string;
|
||||
commandFile: string;
|
||||
assertionFile: string;
|
||||
kubernetesFile: string;
|
||||
}> | null {
|
||||
if (argv.length !== 4) return null;
|
||||
const values = new Map<string, string>();
|
||||
for (const argument of argv) {
|
||||
const match =
|
||||
/^--(config|command|assertion|kubernetes)=(\/.+)$/.exec(argument);
|
||||
if (!match || values.has(match[1]!)) return null;
|
||||
values.set(match[1]!, match[2]!);
|
||||
}
|
||||
if (
|
||||
!values.has('config') ||
|
||||
!values.has('command') ||
|
||||
!values.has('assertion') ||
|
||||
!values.has('kubernetes')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({
|
||||
configFile: values.get('config')!,
|
||||
commandFile: values.get('command')!,
|
||||
assertionFile: values.get('assertion')!,
|
||||
kubernetesFile: values.get('kubernetes')!,
|
||||
});
|
||||
}
|
||||
|
||||
function failureFact(error: unknown): Readonly<Record<string, unknown>> {
|
||||
const candidate = error as { readonly code?: unknown };
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
component:
|
||||
'qinglong3-plugin-package-management-kubernetes-client',
|
||||
event: 'command_failed',
|
||||
code:
|
||||
typeof candidate?.code === 'string' &&
|
||||
candidate.code.length <= 128
|
||||
? candidate.code
|
||||
: 'QL3_PLUGIN_PACKAGE_MANAGEMENT_KUBERNETES_CLIENT_FAILED',
|
||||
...(error instanceof ClusterPluginPackageManagementClientRemoteError
|
||||
? {
|
||||
statusCode: error.statusCode,
|
||||
responseCode: error.responseCode,
|
||||
requestId: error.requestId,
|
||||
...(error.retryAfterSeconds === null
|
||||
? {}
|
||||
: { retryAfterSeconds: error.retryAfterSeconds }),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function run(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
const paths = parseArguments(argv);
|
||||
if (!paths) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
component:
|
||||
'qinglong3-plugin-package-management-kubernetes-client',
|
||||
event: 'usage_invalid',
|
||||
code:
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_KUBERNETES_CLIENT_USAGE_INVALID',
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result =
|
||||
await executeClusterPluginPackageManagementKubernetesClient(paths);
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
component:
|
||||
'qinglong3-plugin-package-management-kubernetes-client',
|
||||
event: 'command_completed',
|
||||
requestId: result.requestId,
|
||||
result: result.result,
|
||||
})}\n`,
|
||||
);
|
||||
} catch (error) {
|
||||
process.stderr.write(`${JSON.stringify(failureFact(error))}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void run(process.argv.slice(2));
|
||||
+710
@@ -0,0 +1,710 @@
|
||||
/** Optional bounded Plugin Package management process composition boundary. */
|
||||
import type {
|
||||
OpenPostgresDatabase,
|
||||
PostgresDatabaseResource,
|
||||
} from '@qinglong/runtime-core';
|
||||
import type {
|
||||
ObservePluginPackagePublisherTrustSnapshotInput,
|
||||
ObservePluginPackagePublisherTrustSnapshotResult,
|
||||
} from '@qinglong/runtime-core/plugin-package-publisher-trust';
|
||||
import {
|
||||
assertPostgresPackageManagerSchemaReady,
|
||||
createPostgresDatabaseOpener,
|
||||
isPostgresTlsDnsServername,
|
||||
loadPostgresCertificateAuthorityFile,
|
||||
loadPostgresConnectionEnvironment,
|
||||
PostgresPluginPackageIdentityKeysetLedgerRepository,
|
||||
PostgresPluginPackageManagementQuotaRepository,
|
||||
PostgresPluginPackagePublisherTrustAuthorityRepository,
|
||||
type PostgresConnectionOptions,
|
||||
type PostgresPoolOptions,
|
||||
type PostgresSchemaReadinessReport,
|
||||
} from '@qinglong/cluster-postgres/package-manager';
|
||||
|
||||
import {
|
||||
createClusterPluginPackageIdentityKeysetFile,
|
||||
type ClusterPluginPackageIdentityKeysetFile,
|
||||
type ClusterPluginPackageIdentityKeysetSnapshot,
|
||||
} from '../../management-support/pluginPackageIdentityKeyset';
|
||||
import { createClusterPluginPackageManagementService } from './pluginPackageManagement';
|
||||
import { createClusterPluginPackageLifecycleManagementService } from '../lifecycle/pluginPackageLifecycleManagement';
|
||||
import {
|
||||
loadClusterPluginPackagePublisherTrustFileEvidence,
|
||||
type ClusterPluginPackagePublisherTrustFileEvidence,
|
||||
} from '../recovery/pluginPackageRecoveryProcess';
|
||||
import { createClusterPluginPackagePublisherTrustManagementService } from '../publisher/pluginPackagePublisherTrustManagement';
|
||||
import {
|
||||
startClusterPluginPackageManagementHttp,
|
||||
type ClusterPluginPackageManagementHttpApplication,
|
||||
type StartClusterPluginPackageManagementHttpOptions,
|
||||
} from '../../management-support/pluginPackageManagementHttp';
|
||||
import { createClusterPluginPackageManagementTransport } from './pluginPackageManagementTransport';
|
||||
import {
|
||||
absoluteManagementEnvironmentFile,
|
||||
booleanManagementEnvironmentValue,
|
||||
boundedManagementEnvironmentValue,
|
||||
integerManagementEnvironmentValue,
|
||||
readManagementTlsFile,
|
||||
} from '../../management-support/managementProcessSupport';
|
||||
|
||||
const SAFE_HOST = /^[A-Za-z0-9][A-Za-z0-9.:-]{0,254}$/;
|
||||
const SAFE_APPLICATION_NAME = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,62}$/;
|
||||
|
||||
export type ClusterPluginPackageManagementProcessEnvironment = Readonly<
|
||||
Record<string, string | undefined>
|
||||
>;
|
||||
|
||||
export type ClusterPluginPackageManagementProcessConfig =
|
||||
| Readonly<{
|
||||
enabled: false;
|
||||
}>
|
||||
| Readonly<{
|
||||
enabled: true;
|
||||
profile: 'cluster-admin';
|
||||
host: string;
|
||||
port: number;
|
||||
certificateFile: string;
|
||||
privateKeyFile: string;
|
||||
identityKeysetFile: string;
|
||||
publisherTrust: Readonly<{
|
||||
file: string;
|
||||
authorityProjectId: string;
|
||||
authorityId: string;
|
||||
observerId: string;
|
||||
}>;
|
||||
approvalLifetimeMs: number;
|
||||
quota: Readonly<{
|
||||
windowMs: number;
|
||||
proposeLimit: number;
|
||||
decideLimit: number;
|
||||
inspectLimit: number;
|
||||
}>;
|
||||
http: Readonly<{
|
||||
maxBodyBytes: number;
|
||||
maxConnections: number;
|
||||
maxConcurrentRequests: number;
|
||||
requestTimeoutMs: number;
|
||||
drainTimeoutMs: number;
|
||||
rateWindowMs: number;
|
||||
peerRequestLimit: number;
|
||||
globalRequestLimit: number;
|
||||
maxRateLimitPeers: number;
|
||||
}>;
|
||||
database: Readonly<{
|
||||
connection: PostgresConnectionOptions;
|
||||
pool: PostgresPoolOptions;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
export type ClusterPluginPackageManagementProcessRuntime =
|
||||
| Readonly<{
|
||||
status: 'disabled';
|
||||
close(): Promise<void>;
|
||||
}>
|
||||
| Readonly<{
|
||||
status: 'active';
|
||||
address: Readonly<{ host: string; port: number }>;
|
||||
database: PostgresSchemaReadinessReport;
|
||||
identity: ClusterPluginPackageIdentityKeysetSnapshot;
|
||||
publisherTrust: Readonly<{
|
||||
generation: number;
|
||||
baseSnapshotDigest: string;
|
||||
effectiveTrustDigest: string;
|
||||
}>;
|
||||
availabilityStatus(): 'ready' | 'unavailable' | 'stopped';
|
||||
close(): Promise<void>;
|
||||
}>;
|
||||
|
||||
export interface StartClusterPluginPackageManagementProcessOptions {
|
||||
readonly environment: ClusterPluginPackageManagementProcessEnvironment;
|
||||
readonly openDatabase?: OpenPostgresDatabase;
|
||||
readonly identities?: ClusterPluginPackageIdentityKeysetFile;
|
||||
readonly publisherTrustEvidence?: ClusterPluginPackagePublisherTrustFileEvidence;
|
||||
readonly observePublisherTrust?: (
|
||||
pool: PostgresDatabaseResource['pool'],
|
||||
input: ObservePluginPackagePublisherTrustSnapshotInput,
|
||||
) => Promise<Readonly<ObservePluginPackagePublisherTrustSnapshotResult>>;
|
||||
readonly assertReady?: (
|
||||
pool: PostgresDatabaseResource['pool'],
|
||||
) => Promise<PostgresSchemaReadinessReport>;
|
||||
readonly startHttp?: (
|
||||
options: StartClusterPluginPackageManagementHttpOptions,
|
||||
) => Promise<Readonly<ClusterPluginPackageManagementHttpApplication>>;
|
||||
readonly now?: () => number;
|
||||
readonly onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export class ClusterPluginPackageManagementProcessConfigError extends TypeError {
|
||||
readonly code = 'QL3_PLUGIN_PACKAGE_MANAGEMENT_PROCESS_CONFIG_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(
|
||||
`Plugin Package management process configuration is invalid: ${message}`,
|
||||
);
|
||||
this.name = 'ClusterPluginPackageManagementProcessConfigError';
|
||||
}
|
||||
}
|
||||
|
||||
function configFailure(
|
||||
message: string,
|
||||
): ClusterPluginPackageManagementProcessConfigError {
|
||||
return new ClusterPluginPackageManagementProcessConfigError(message);
|
||||
}
|
||||
|
||||
function boundedValue(
|
||||
environment: ClusterPluginPackageManagementProcessEnvironment,
|
||||
name: string,
|
||||
maximumLength: number,
|
||||
required = false,
|
||||
): string | undefined {
|
||||
return boundedManagementEnvironmentValue(
|
||||
environment,
|
||||
name,
|
||||
maximumLength,
|
||||
configFailure,
|
||||
required,
|
||||
);
|
||||
}
|
||||
|
||||
function booleanValue(
|
||||
environment: ClusterPluginPackageManagementProcessEnvironment,
|
||||
name: string,
|
||||
): boolean {
|
||||
return booleanManagementEnvironmentValue(environment, name, configFailure);
|
||||
}
|
||||
|
||||
function integerValue(
|
||||
environment: ClusterPluginPackageManagementProcessEnvironment,
|
||||
name: string,
|
||||
fallback: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
return integerManagementEnvironmentValue(
|
||||
environment,
|
||||
name,
|
||||
fallback,
|
||||
minimum,
|
||||
maximum,
|
||||
configFailure,
|
||||
);
|
||||
}
|
||||
|
||||
function absoluteFile(
|
||||
environment: ClusterPluginPackageManagementProcessEnvironment,
|
||||
name: string,
|
||||
): string {
|
||||
return absoluteManagementEnvironmentFile(environment, name, configFailure);
|
||||
}
|
||||
|
||||
function loadConnection(
|
||||
environment: ClusterPluginPackageManagementProcessEnvironment,
|
||||
): Readonly<{
|
||||
connection: PostgresConnectionOptions;
|
||||
pool: PostgresPoolOptions;
|
||||
}> {
|
||||
let connection: PostgresConnectionOptions;
|
||||
try {
|
||||
connection = loadPostgresConnectionEnvironment(environment, {
|
||||
connectionString: 'QL3_POSTGRES_PACKAGE_MANAGER_URL',
|
||||
host: 'QL3_POSTGRES_PACKAGE_MANAGER_HOST',
|
||||
port: 'QL3_POSTGRES_PACKAGE_MANAGER_PORT',
|
||||
database: 'QL3_POSTGRES_PACKAGE_MANAGER_DATABASE',
|
||||
user: 'QL3_POSTGRES_PACKAGE_MANAGER_USER',
|
||||
password: 'QL3_POSTGRES_PACKAGE_MANAGER_PASSWORD',
|
||||
});
|
||||
} catch (error) {
|
||||
throw configFailure(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'PostgreSQL Package manager connection is invalid',
|
||||
);
|
||||
}
|
||||
const mode =
|
||||
environment.QL3_POSTGRES_PACKAGE_MANAGER_TLS_MODE ?? 'verify-full';
|
||||
if (mode !== 'verify-full' && mode !== 'disable') {
|
||||
throw configFailure(
|
||||
'QL3_POSTGRES_PACKAGE_MANAGER_TLS_MODE must be verify-full or disable',
|
||||
);
|
||||
}
|
||||
if (
|
||||
mode === 'disable' &&
|
||||
!booleanValue(environment, 'QL3_POSTGRES_PACKAGE_MANAGER_ALLOW_INSECURE')
|
||||
) {
|
||||
throw configFailure(
|
||||
'disabling Package manager PostgreSQL TLS requires QL3_POSTGRES_PACKAGE_MANAGER_ALLOW_INSECURE=true',
|
||||
);
|
||||
}
|
||||
const servername = boundedValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_PACKAGE_MANAGER_TLS_SERVERNAME',
|
||||
253,
|
||||
);
|
||||
if (mode === 'verify-full' && !isPostgresTlsDnsServername(servername)) {
|
||||
throw configFailure(
|
||||
'QL3_POSTGRES_PACKAGE_MANAGER_TLS_SERVERNAME must be an explicit DNS name',
|
||||
);
|
||||
}
|
||||
const caFile = boundedValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_PACKAGE_MANAGER_TLS_CA_FILE',
|
||||
4_096,
|
||||
);
|
||||
if (mode === 'disable' && caFile !== undefined) {
|
||||
throw configFailure(
|
||||
'QL3_POSTGRES_PACKAGE_MANAGER_TLS_CA_FILE cannot be used when TLS is disabled',
|
||||
);
|
||||
}
|
||||
let ca: string | undefined;
|
||||
if (caFile !== undefined) {
|
||||
try {
|
||||
ca = loadPostgresCertificateAuthorityFile(caFile);
|
||||
} catch {
|
||||
throw configFailure(
|
||||
'QL3_POSTGRES_PACKAGE_MANAGER_TLS_CA_FILE is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
const applicationName =
|
||||
boundedValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_PACKAGE_MANAGER_APPLICATION_NAME',
|
||||
63,
|
||||
) ?? 'qinglong3-plugin-package-manager';
|
||||
if (!SAFE_APPLICATION_NAME.test(applicationName)) {
|
||||
throw configFailure(
|
||||
'QL3_POSTGRES_PACKAGE_MANAGER_APPLICATION_NAME is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
connection: Object.freeze({
|
||||
...connection,
|
||||
tls:
|
||||
mode === 'disable'
|
||||
? { mode: 'disable' as const }
|
||||
: {
|
||||
mode: 'verify-full' as const,
|
||||
servername: servername!,
|
||||
...(ca === undefined ? {} : { ca }),
|
||||
},
|
||||
}),
|
||||
pool: Object.freeze({
|
||||
applicationName,
|
||||
maxConnections: integerValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_PACKAGE_MANAGER_MAX_CONNECTIONS',
|
||||
2,
|
||||
1,
|
||||
4,
|
||||
),
|
||||
connectionTimeoutMs: integerValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_PACKAGE_MANAGER_CONNECTION_TIMEOUT_MS',
|
||||
5_000,
|
||||
100,
|
||||
60_000,
|
||||
),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function loadClusterPluginPackageManagementProcessConfig(
|
||||
environment: ClusterPluginPackageManagementProcessEnvironment,
|
||||
): Readonly<ClusterPluginPackageManagementProcessConfig> {
|
||||
if (!environment || typeof environment !== 'object') {
|
||||
throw configFailure('environment is invalid');
|
||||
}
|
||||
if (!booleanValue(environment, 'QL3_PLUGIN_PACKAGE_MANAGEMENT_ENABLED')) {
|
||||
return Object.freeze({ enabled: false as const });
|
||||
}
|
||||
if (environment.QL3_PROFILE !== 'cluster-admin') {
|
||||
throw configFailure(
|
||||
'QL3_PROFILE must be cluster-admin when management is enabled',
|
||||
);
|
||||
}
|
||||
const host =
|
||||
boundedValue(environment, 'QL3_PLUGIN_PACKAGE_MANAGEMENT_HOST', 255) ??
|
||||
'0.0.0.0';
|
||||
if (!SAFE_HOST.test(host)) {
|
||||
throw configFailure('QL3_PLUGIN_PACKAGE_MANAGEMENT_HOST is invalid');
|
||||
}
|
||||
const http = Object.freeze({
|
||||
maxBodyBytes: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_MAX_BODY_BYTES',
|
||||
64 * 1024,
|
||||
1_024,
|
||||
256 * 1024,
|
||||
),
|
||||
maxConnections: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_MAX_CONNECTIONS',
|
||||
64,
|
||||
1,
|
||||
512,
|
||||
),
|
||||
maxConcurrentRequests: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_MAX_CONCURRENT_REQUESTS',
|
||||
32,
|
||||
1,
|
||||
256,
|
||||
),
|
||||
requestTimeoutMs: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_REQUEST_TIMEOUT_MS',
|
||||
10_000,
|
||||
1_000,
|
||||
60_000,
|
||||
),
|
||||
drainTimeoutMs: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_DRAIN_TIMEOUT_MS',
|
||||
5_000,
|
||||
100,
|
||||
60_000,
|
||||
),
|
||||
rateWindowMs: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_RATE_WINDOW_MS',
|
||||
60_000,
|
||||
1_000,
|
||||
5 * 60_000,
|
||||
),
|
||||
peerRequestLimit: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_PEER_REQUEST_LIMIT',
|
||||
60,
|
||||
1,
|
||||
10_000,
|
||||
),
|
||||
globalRequestLimit: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_GLOBAL_REQUEST_LIMIT',
|
||||
600,
|
||||
1,
|
||||
100_000,
|
||||
),
|
||||
maxRateLimitPeers: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_MAX_RATE_LIMIT_PEERS',
|
||||
1_024,
|
||||
1,
|
||||
16_384,
|
||||
),
|
||||
});
|
||||
if (http.globalRequestLimit < http.peerRequestLimit) {
|
||||
throw configFailure(
|
||||
'global request limit cannot be below the peer request limit',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
enabled: true as const,
|
||||
profile: 'cluster-admin' as const,
|
||||
host,
|
||||
port: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_PORT',
|
||||
8_443,
|
||||
1,
|
||||
65_535,
|
||||
),
|
||||
certificateFile: absoluteFile(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_TLS_CERT_FILE',
|
||||
),
|
||||
privateKeyFile: absoluteFile(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_TLS_KEY_FILE',
|
||||
),
|
||||
identityKeysetFile: absoluteFile(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_IDENTITY_KEYSET_FILE',
|
||||
),
|
||||
publisherTrust: Object.freeze({
|
||||
file: absoluteFile(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_PUBLISHER_TRUST_FILE',
|
||||
),
|
||||
authorityProjectId: boundedValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_TRUST_AUTHORITY_PROJECT_ID',
|
||||
128,
|
||||
true,
|
||||
)!,
|
||||
authorityId:
|
||||
boundedValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_TRUST_AUTHORITY_ID',
|
||||
128,
|
||||
) ?? 'cluster',
|
||||
observerId:
|
||||
boundedValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_TRUST_OBSERVER_ID',
|
||||
128,
|
||||
) ?? 'cluster-package-manager',
|
||||
}),
|
||||
approvalLifetimeMs: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_APPROVAL_LIFETIME_MS',
|
||||
15 * 60_000,
|
||||
1_000,
|
||||
24 * 60 * 60_000,
|
||||
),
|
||||
quota: Object.freeze({
|
||||
windowMs: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_QUOTA_WINDOW_MS',
|
||||
60_000,
|
||||
1_000,
|
||||
5 * 60_000,
|
||||
),
|
||||
proposeLimit: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_PROPOSE_QUOTA',
|
||||
30,
|
||||
1,
|
||||
1_000,
|
||||
),
|
||||
decideLimit: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_DECIDE_QUOTA',
|
||||
60,
|
||||
1,
|
||||
1_000,
|
||||
),
|
||||
inspectLimit: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_INSPECT_QUOTA',
|
||||
600,
|
||||
1,
|
||||
1_000,
|
||||
),
|
||||
}),
|
||||
http,
|
||||
database: loadConnection(environment),
|
||||
});
|
||||
}
|
||||
|
||||
function readTlsFile(filePath: string, privateMaterial: boolean): Buffer {
|
||||
return readManagementTlsFile(filePath, privateMaterial, configFailure);
|
||||
}
|
||||
|
||||
export async function startClusterPluginPackageManagementProcess(
|
||||
options: StartClusterPluginPackageManagementProcessOptions,
|
||||
): Promise<Readonly<ClusterPluginPackageManagementProcessRuntime>> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some(
|
||||
(key) =>
|
||||
![
|
||||
'environment',
|
||||
'openDatabase',
|
||||
'identities',
|
||||
'publisherTrustEvidence',
|
||||
'observePublisherTrust',
|
||||
'assertReady',
|
||||
'startHttp',
|
||||
'now',
|
||||
'onError',
|
||||
].includes(key),
|
||||
) ||
|
||||
!options.environment ||
|
||||
typeof options.environment !== 'object' ||
|
||||
(options.openDatabase !== undefined &&
|
||||
typeof options.openDatabase !== 'function') ||
|
||||
(options.identities !== undefined &&
|
||||
(typeof options.identities.reload !== 'function' ||
|
||||
typeof options.identities.bind !== 'function')) ||
|
||||
(options.publisherTrustEvidence !== undefined &&
|
||||
(!options.publisherTrustEvidence ||
|
||||
typeof options.publisherTrustEvidence !== 'object' ||
|
||||
!options.publisherTrustEvidence.registry ||
|
||||
!options.publisherTrustEvidence.snapshot)) ||
|
||||
(options.observePublisherTrust !== undefined &&
|
||||
typeof options.observePublisherTrust !== 'function') ||
|
||||
(options.assertReady !== undefined &&
|
||||
typeof options.assertReady !== 'function') ||
|
||||
(options.startHttp !== undefined &&
|
||||
typeof options.startHttp !== 'function') ||
|
||||
(options.now !== undefined && typeof options.now !== 'function') ||
|
||||
(options.onError !== undefined && typeof options.onError !== 'function')
|
||||
) {
|
||||
throw configFailure('options are invalid');
|
||||
}
|
||||
const config = loadClusterPluginPackageManagementProcessConfig(
|
||||
options.environment,
|
||||
);
|
||||
if (!config.enabled) {
|
||||
return Object.freeze({
|
||||
status: 'disabled' as const,
|
||||
close: () => Promise.resolve(),
|
||||
});
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
let http: Readonly<ClusterPluginPackageManagementHttpApplication> | undefined;
|
||||
let database: PostgresDatabaseResource | undefined;
|
||||
let unavailableError: unknown;
|
||||
let closePromise: Promise<void> | undefined;
|
||||
const report = (error: unknown): void => {
|
||||
try {
|
||||
options.onError?.(error);
|
||||
} catch {
|
||||
// Diagnostics do not own availability or cleanup.
|
||||
}
|
||||
};
|
||||
const openDatabase =
|
||||
options.openDatabase ??
|
||||
createPostgresDatabaseOpener({
|
||||
role: 'package-manager',
|
||||
connection: config.database.connection,
|
||||
pool: config.database.pool,
|
||||
onPoolError(error) {
|
||||
const firstAvailabilityError = unavailableError === undefined;
|
||||
unavailableError ??= error;
|
||||
http?.withdraw(error);
|
||||
if (firstAvailabilityError) report(error);
|
||||
},
|
||||
});
|
||||
try {
|
||||
database = await openDatabase();
|
||||
const evidence = await (
|
||||
options.assertReady ?? assertPostgresPackageManagerSchemaReady
|
||||
)(database.pool);
|
||||
if (unavailableError !== undefined) throw unavailableError;
|
||||
const identities =
|
||||
options.identities ??
|
||||
createClusterPluginPackageIdentityKeysetFile({
|
||||
filePath: config.identityKeysetFile,
|
||||
now,
|
||||
ledger: new PostgresPluginPackageIdentityKeysetLedgerRepository(
|
||||
database.pool,
|
||||
),
|
||||
});
|
||||
const identity = await identities.reload();
|
||||
const publisherTrustEvidence =
|
||||
options.publisherTrustEvidence ??
|
||||
loadClusterPluginPackagePublisherTrustFileEvidence(
|
||||
config.publisherTrust.file,
|
||||
);
|
||||
const publisherTrustObservation = await (
|
||||
options.observePublisherTrust ??
|
||||
(async (pool, input) =>
|
||||
new PostgresPluginPackagePublisherTrustAuthorityRepository(
|
||||
pool,
|
||||
).observeSnapshot(input))
|
||||
)(database.pool, {
|
||||
authorityId: config.publisherTrust.authorityId,
|
||||
snapshot: publisherTrustEvidence.snapshot,
|
||||
observedBy: config.publisherTrust.observerId,
|
||||
observedAtMs: now(),
|
||||
});
|
||||
const quota = new PostgresPluginPackageManagementQuotaRepository(
|
||||
database.pool,
|
||||
{
|
||||
windowMs: config.quota.windowMs,
|
||||
limits: {
|
||||
'plugin-package.propose': config.quota.proposeLimit,
|
||||
'plugin-package.decide': config.quota.decideLimit,
|
||||
'plugin-package.inspect': config.quota.inspectLimit,
|
||||
},
|
||||
},
|
||||
);
|
||||
const service = createClusterPluginPackageManagementService({
|
||||
pool: database.pool,
|
||||
approvalLifetimeMs: config.approvalLifetimeMs,
|
||||
now,
|
||||
quota,
|
||||
});
|
||||
const lifecycle =
|
||||
createClusterPluginPackageLifecycleManagementService({
|
||||
pool: database.pool,
|
||||
approvalLifetimeMs: config.approvalLifetimeMs,
|
||||
now,
|
||||
});
|
||||
const publisherTrust =
|
||||
createClusterPluginPackagePublisherTrustManagementService({
|
||||
pool: database.pool,
|
||||
authorityProjectId: config.publisherTrust.authorityProjectId,
|
||||
trustAuthorityId: config.publisherTrust.authorityId,
|
||||
materialSnapshot: publisherTrustEvidence.snapshot,
|
||||
approvalLifetimeMs: config.approvalLifetimeMs,
|
||||
now,
|
||||
quota,
|
||||
});
|
||||
const transport = createClusterPluginPackageManagementTransport({
|
||||
service,
|
||||
lifecycle,
|
||||
publisherTrust,
|
||||
now,
|
||||
});
|
||||
const privateKey = readTlsFile(config.privateKeyFile, true);
|
||||
try {
|
||||
const certificate = readTlsFile(config.certificateFile, false);
|
||||
http = await (
|
||||
options.startHttp ?? startClusterPluginPackageManagementHttp
|
||||
)({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
tls: { privateKey, certificate },
|
||||
transport,
|
||||
identities,
|
||||
limits: config.http,
|
||||
now,
|
||||
onError: report,
|
||||
});
|
||||
} finally {
|
||||
privateKey.fill(0);
|
||||
}
|
||||
if (unavailableError !== undefined) {
|
||||
http.withdraw(unavailableError);
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'active' as const,
|
||||
address: http.address,
|
||||
database: evidence,
|
||||
identity,
|
||||
publisherTrust: Object.freeze({
|
||||
generation: publisherTrustObservation.head.generation,
|
||||
baseSnapshotDigest:
|
||||
publisherTrustObservation.head.baseSnapshotDigest,
|
||||
effectiveTrustDigest:
|
||||
publisherTrustObservation.head.effectiveTrustDigest,
|
||||
}),
|
||||
availabilityStatus: () => http?.availabilityStatus() ?? 'stopped',
|
||||
close(): Promise<void> {
|
||||
if (closePromise) return closePromise;
|
||||
closePromise = (async () => {
|
||||
let primaryError: unknown;
|
||||
try {
|
||||
await http?.close();
|
||||
} catch (error) {
|
||||
primaryError = error;
|
||||
}
|
||||
try {
|
||||
await database?.close();
|
||||
} catch (error) {
|
||||
primaryError ??= error;
|
||||
}
|
||||
if (primaryError) throw primaryError;
|
||||
})();
|
||||
return closePromise;
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
try {
|
||||
await http?.close();
|
||||
} catch {
|
||||
// Preserve startup failure.
|
||||
}
|
||||
try {
|
||||
await database?.close();
|
||||
} catch {
|
||||
// Preserve startup failure.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
+1150
File diff suppressed because it is too large
Load Diff
+234
@@ -0,0 +1,234 @@
|
||||
// Cluster Plugin Package publisher boundary; keep provenance recovery authority explicit.
|
||||
import {
|
||||
assertPluginPackageInstallMatchesLock,
|
||||
type PluginPackageInstallCommit,
|
||||
type PluginPackageInstallCreate,
|
||||
type PluginPackageInstallRecord,
|
||||
type PluginPackageInstallRecoveryCursor,
|
||||
type PluginPackageInstallRecoveryPage,
|
||||
type PluginPackageInstallRepository,
|
||||
type PluginPackageLock,
|
||||
} from '@qinglong/runtime-core/plugin-package-install';
|
||||
import {
|
||||
normalizePluginPackageStageEvidence,
|
||||
type PluginPackageStageEvidence,
|
||||
} from '@qinglong/runtime-core/plugin-package-installation';
|
||||
import {
|
||||
createPluginPackagePublisherProvenance,
|
||||
type PluginPackagePublisherProvenance,
|
||||
} from '@qinglong/runtime-core/plugin-package-publisher-provenance';
|
||||
import {
|
||||
PostgresPluginPackageInstallRepository,
|
||||
} from '@qinglong/cluster-postgres/plugin-package-install';
|
||||
import {
|
||||
POSTGRES_PLUGIN_PACKAGE_PROVENANCE_RECOVERY_PAGE_LIMIT,
|
||||
PostgresPluginPackagePublisherProvenanceRepository,
|
||||
type PluginPackagePublisherProvenanceRecoveryCursor,
|
||||
} from '@qinglong/cluster-postgres/package-executor';
|
||||
|
||||
import type { ClusterPluginPackageStageAuthority } from '../recovery/pluginPackageOciStage';
|
||||
|
||||
export const MAX_CLUSTER_PLUGIN_PACKAGE_PROVENANCE_RECOVERY_PAGES = 64;
|
||||
|
||||
export interface ClusterPluginPackagePublisherProvenanceRecoveryResult {
|
||||
readonly pages: number;
|
||||
readonly scanned: number;
|
||||
readonly created: number;
|
||||
readonly existing: number;
|
||||
readonly remaining: boolean;
|
||||
readonly safeToAdmit: boolean;
|
||||
}
|
||||
|
||||
function stageEvidence(
|
||||
record: Readonly<PluginPackageInstallRecord>,
|
||||
): Readonly<PluginPackageStageEvidence> {
|
||||
if (record.stageReceipt === null) {
|
||||
throw new TypeError(
|
||||
'Plugin Package install lacks durable stage evidence for provenance',
|
||||
);
|
||||
}
|
||||
return normalizePluginPackageStageEvidence({
|
||||
stageRef: record.stageReceipt.stageRef,
|
||||
artifactDigest: record.stageReceipt.artifactDigest,
|
||||
manifestDigest: record.stageReceipt.manifestDigest,
|
||||
contentDigest: record.stageReceipt.contentDigest,
|
||||
evidenceDigest: record.stageReceipt.evidenceDigest,
|
||||
});
|
||||
}
|
||||
|
||||
async function provenanceFor(
|
||||
authority: ClusterPluginPackageStageAuthority,
|
||||
lock: Readonly<PluginPackageLock>,
|
||||
record: Readonly<PluginPackageInstallRecord>,
|
||||
): Promise<Readonly<PluginPackagePublisherProvenance>> {
|
||||
assertPluginPackageInstallMatchesLock(lock, record);
|
||||
const stage = stageEvidence(record);
|
||||
const signature = await authority.publisherEvidence(lock, stage);
|
||||
return createPluginPackagePublisherProvenance({
|
||||
projectId: record.projectId,
|
||||
packageName: record.packageName,
|
||||
installationId: record.installationId,
|
||||
lockDigest: record.lockDigest,
|
||||
artifactDigest: stage.artifactDigest,
|
||||
manifestDigest: stage.manifestDigest,
|
||||
contentDigest: stage.contentDigest,
|
||||
stageEvidenceDigest: stage.evidenceDigest,
|
||||
signature,
|
||||
});
|
||||
}
|
||||
|
||||
export class ClusterPluginPackageProvenanceInstallRepository
|
||||
implements PluginPackageInstallRepository
|
||||
{
|
||||
constructor(
|
||||
private readonly installs: PostgresPluginPackageInstallRepository,
|
||||
private readonly provenance: PostgresPluginPackagePublisherProvenanceRepository,
|
||||
private readonly authority: ClusterPluginPackageStageAuthority,
|
||||
private readonly trustAuthorityId: string,
|
||||
) {
|
||||
if (
|
||||
!(installs instanceof PostgresPluginPackageInstallRepository) ||
|
||||
!(provenance instanceof
|
||||
PostgresPluginPackagePublisherProvenanceRepository) ||
|
||||
!authority ||
|
||||
typeof authority.publisherEvidence !== 'function' ||
|
||||
typeof trustAuthorityId !== 'string' ||
|
||||
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(trustAuthorityId)
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster Plugin Package provenance install repository is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
find(
|
||||
projectId: string,
|
||||
packageName: string,
|
||||
): Promise<Readonly<PluginPackageInstallRecord> | null> {
|
||||
return this.installs.find(projectId, packageName);
|
||||
}
|
||||
|
||||
findLock(
|
||||
lockDigest: string,
|
||||
): Promise<Readonly<PluginPackageLock> | null> {
|
||||
return this.installs.findLock(lockDigest);
|
||||
}
|
||||
|
||||
create(command: Readonly<PluginPackageInstallCreate>): Promise<
|
||||
Readonly<{
|
||||
status: 'created' | 'existing';
|
||||
record: Readonly<PluginPackageInstallRecord>;
|
||||
}>
|
||||
> {
|
||||
return this.installs.create(command);
|
||||
}
|
||||
|
||||
async commit(command: Readonly<PluginPackageInstallCommit>): Promise<
|
||||
Readonly<{
|
||||
status: 'committed' | 'existing';
|
||||
record: Readonly<PluginPackageInstallRecord>;
|
||||
}>
|
||||
> {
|
||||
if (command.record.state !== 'staged') {
|
||||
return this.installs.commit(command);
|
||||
}
|
||||
const lock = await this.installs.findLock(command.record.lockDigest);
|
||||
if (!lock) {
|
||||
throw new TypeError('Plugin Package stage lock is unavailable');
|
||||
}
|
||||
return this.provenance.commitStage(
|
||||
command,
|
||||
await provenanceFor(this.authority, lock, command.record),
|
||||
this.trustAuthorityId,
|
||||
);
|
||||
}
|
||||
|
||||
listRecoveryPage(options: {
|
||||
readonly limit: number;
|
||||
readonly after?: Readonly<PluginPackageInstallRecoveryCursor>;
|
||||
}): Promise<Readonly<PluginPackageInstallRecoveryPage>> {
|
||||
return this.installs.listRecoveryPage(options);
|
||||
}
|
||||
}
|
||||
|
||||
export async function recoverClusterPluginPackagePublisherProvenance(
|
||||
installs: PostgresPluginPackageInstallRepository,
|
||||
repository: PostgresPluginPackagePublisherProvenanceRepository,
|
||||
authority: ClusterPluginPackageStageAuthority,
|
||||
options: Readonly<{
|
||||
trustAuthorityId: string;
|
||||
pageSize?: number;
|
||||
maxPages?: number;
|
||||
}>,
|
||||
): Promise<Readonly<ClusterPluginPackagePublisherProvenanceRecoveryResult>> {
|
||||
const pageSize = options.pageSize ?? 16;
|
||||
const maxPages = options.maxPages ?? 16;
|
||||
if (
|
||||
!(installs instanceof PostgresPluginPackageInstallRepository) ||
|
||||
!(repository instanceof
|
||||
PostgresPluginPackagePublisherProvenanceRepository) ||
|
||||
!authority ||
|
||||
typeof authority.verify !== 'function' ||
|
||||
typeof authority.publisherEvidence !== 'function' ||
|
||||
typeof options.trustAuthorityId !== 'string' ||
|
||||
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(
|
||||
options.trustAuthorityId,
|
||||
) ||
|
||||
!Number.isSafeInteger(pageSize) ||
|
||||
pageSize < 1 ||
|
||||
pageSize > POSTGRES_PLUGIN_PACKAGE_PROVENANCE_RECOVERY_PAGE_LIMIT ||
|
||||
!Number.isSafeInteger(maxPages) ||
|
||||
maxPages < 1 ||
|
||||
maxPages > MAX_CLUSTER_PLUGIN_PACKAGE_PROVENANCE_RECOVERY_PAGES
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster Plugin Package provenance recovery configuration is invalid',
|
||||
);
|
||||
}
|
||||
let after:
|
||||
| Readonly<PluginPackagePublisherProvenanceRecoveryCursor>
|
||||
| undefined;
|
||||
const counts = {
|
||||
pages: 0,
|
||||
scanned: 0,
|
||||
created: 0,
|
||||
existing: 0,
|
||||
};
|
||||
let exhausted = false;
|
||||
while (counts.pages < maxPages) {
|
||||
const page = await repository.listMissingPage({
|
||||
limit: pageSize,
|
||||
...(after ? { after } : {}),
|
||||
});
|
||||
counts.pages += 1;
|
||||
for (const record of page.records) {
|
||||
const lock = await installs.findLock(record.lockDigest);
|
||||
if (!lock || record.stageReceipt === null) {
|
||||
throw new TypeError(
|
||||
'Cluster Plugin Package provenance recovery source is incomplete',
|
||||
);
|
||||
}
|
||||
assertPluginPackageInstallMatchesLock(lock, record);
|
||||
await authority.verify(lock, record.stageReceipt);
|
||||
const result = await repository.recordExisting(
|
||||
record,
|
||||
await provenanceFor(authority, lock, record),
|
||||
options.trustAuthorityId,
|
||||
);
|
||||
counts.scanned += 1;
|
||||
counts[result.status] += 1;
|
||||
}
|
||||
if (!page.truncated) {
|
||||
exhausted = true;
|
||||
break;
|
||||
}
|
||||
after = page.next;
|
||||
}
|
||||
const probe = await repository.listMissingPage({ limit: 1 });
|
||||
const remaining = !exhausted || probe.records.length > 0;
|
||||
return Object.freeze({
|
||||
...counts,
|
||||
remaining,
|
||||
safeToAdmit: !remaining,
|
||||
});
|
||||
}
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
// Cluster Plugin Package publisher boundary; keep revocation authority explicit.
|
||||
import type {
|
||||
OpenPostgresDatabase,
|
||||
PostgresDatabaseResource,
|
||||
} from '@qinglong/runtime-core';
|
||||
import {
|
||||
normalizePluginPackagePublisherRevocationReceipt,
|
||||
type PluginPackagePublisherRevocationReceipt,
|
||||
} from '@qinglong/runtime-core/plugin-package-publisher-provenance';
|
||||
import {
|
||||
createPluginPackageQuarantineEvent,
|
||||
pluginPackageQuarantineMutationId,
|
||||
} from '@qinglong/runtime-core/plugin-package-quarantine';
|
||||
import {
|
||||
PostgresPluginPackagePublisherProvenanceRepository,
|
||||
PostgresPluginPackageQuarantineRepository,
|
||||
assertPostgresPackageExecutorSchemaReady,
|
||||
type PostgresSchemaReadinessReport,
|
||||
} from '@qinglong/cluster-postgres/package-executor';
|
||||
|
||||
import {
|
||||
CLUSTER_PLUGIN_PACKAGE_QUARANTINE_BATCH_LIMIT,
|
||||
createClusterPluginPackageQuarantineService,
|
||||
} from '../lifecycle/pluginPackageQuarantine';
|
||||
|
||||
export const MAX_CLUSTER_PLUGIN_PACKAGE_REVOCATION_PAGES = 64;
|
||||
|
||||
export interface RunClusterPluginPackagePublisherRevocationOptions {
|
||||
readonly openDatabase: OpenPostgresDatabase;
|
||||
readonly receipt: Readonly<PluginPackagePublisherRevocationReceipt>;
|
||||
readonly confirmAuthorization: (
|
||||
receipt: Readonly<PluginPackagePublisherRevocationReceipt>,
|
||||
) => void | Promise<void>;
|
||||
readonly pageSize?: number;
|
||||
readonly maxPages?: number;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackagePublisherRevocationRun {
|
||||
readonly database: PostgresSchemaReadinessReport;
|
||||
readonly receiptStatus: 'created' | 'existing';
|
||||
readonly receiptDigest: string;
|
||||
readonly impactDigest: string;
|
||||
readonly impacted: number;
|
||||
readonly pages: number;
|
||||
readonly quarantined: number;
|
||||
readonly existing: number;
|
||||
readonly remaining: boolean;
|
||||
readonly safeToAdmit: boolean;
|
||||
}
|
||||
|
||||
function normalizedOptions(
|
||||
options: RunClusterPluginPackagePublisherRevocationOptions,
|
||||
): Readonly<{
|
||||
receipt: Readonly<PluginPackagePublisherRevocationReceipt>;
|
||||
pageSize: number;
|
||||
maxPages: number;
|
||||
}> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some(
|
||||
(key) =>
|
||||
![
|
||||
'openDatabase',
|
||||
'receipt',
|
||||
'confirmAuthorization',
|
||||
'pageSize',
|
||||
'maxPages',
|
||||
].includes(key),
|
||||
) ||
|
||||
typeof options.openDatabase !== 'function' ||
|
||||
typeof options.confirmAuthorization !== 'function'
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster Plugin Package publisher revocation options are invalid',
|
||||
);
|
||||
}
|
||||
const pageSize = options.pageSize ?? 64;
|
||||
const maxPages = options.maxPages ?? 32;
|
||||
if (
|
||||
!Number.isSafeInteger(pageSize) ||
|
||||
pageSize < 1 ||
|
||||
pageSize > CLUSTER_PLUGIN_PACKAGE_QUARANTINE_BATCH_LIMIT ||
|
||||
!Number.isSafeInteger(maxPages) ||
|
||||
maxPages < 1 ||
|
||||
maxPages > MAX_CLUSTER_PLUGIN_PACKAGE_REVOCATION_PAGES
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster Plugin Package publisher revocation bounds are invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
receipt: normalizePluginPackagePublisherRevocationReceipt(options.receipt),
|
||||
pageSize,
|
||||
maxPages,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Short-lived administration composition. The immutable revocation receipt
|
||||
* and its bounded impact are committed before quarantine materialization.
|
||||
* Re-running the same receipt converges on the same facts and skips targets
|
||||
* already quarantined or superseded by a newer installation head.
|
||||
*/
|
||||
export async function runClusterPluginPackagePublisherRevocation(
|
||||
options: RunClusterPluginPackagePublisherRevocationOptions,
|
||||
): Promise<Readonly<ClusterPluginPackagePublisherRevocationRun>> {
|
||||
const normalized = normalizedOptions(options);
|
||||
let database: PostgresDatabaseResource | undefined;
|
||||
let result: Readonly<ClusterPluginPackagePublisherRevocationRun> | undefined;
|
||||
let failure: unknown;
|
||||
try {
|
||||
database = await options.openDatabase();
|
||||
const evidence = await assertPostgresPackageExecutorSchemaReady(
|
||||
database.pool,
|
||||
);
|
||||
const provenance =
|
||||
new PostgresPluginPackagePublisherProvenanceRepository(database.pool);
|
||||
const quarantine = createClusterPluginPackageQuarantineService(
|
||||
new PostgresPluginPackageQuarantineRepository(database.pool),
|
||||
);
|
||||
const impactResult = await provenance.recordRevocationImpact(
|
||||
normalized.receipt,
|
||||
() => options.confirmAuthorization(normalized.receipt),
|
||||
);
|
||||
let pages = 0;
|
||||
let quarantined = 0;
|
||||
let existing = 0;
|
||||
while (pages < normalized.maxPages) {
|
||||
const page = await provenance.listPendingQuarantineTargets(
|
||||
impactResult.impact.impactDigest,
|
||||
normalized.pageSize,
|
||||
);
|
||||
if (page.targets.length === 0) break;
|
||||
const events = page.targets.map((target) =>
|
||||
createPluginPackageQuarantineEvent({
|
||||
mutationId: pluginPackageQuarantineMutationId(
|
||||
normalized.receipt.receiptDigest,
|
||||
target,
|
||||
),
|
||||
revocationReceiptDigest: normalized.receipt.receiptDigest,
|
||||
impactDigest: impactResult.impact.impactDigest,
|
||||
target,
|
||||
proposer: normalized.receipt.proposer,
|
||||
confirmer: normalized.receipt.confirmer,
|
||||
authorizationMode: normalized.receipt.authorizationMode,
|
||||
reasonCode: normalized.receipt.reasonCode,
|
||||
occurredAtMs: normalized.receipt.revokedAtMs,
|
||||
}),
|
||||
);
|
||||
const quarantineResults = await quarantine.quarantine(
|
||||
events,
|
||||
() => options.confirmAuthorization(normalized.receipt),
|
||||
);
|
||||
pages += 1;
|
||||
for (const item of quarantineResults) {
|
||||
if (item.status === 'created') quarantined += 1;
|
||||
else existing += 1;
|
||||
}
|
||||
}
|
||||
const probe = await provenance.listPendingQuarantineTargets(
|
||||
impactResult.impact.impactDigest,
|
||||
1,
|
||||
);
|
||||
const remaining = probe.targets.length > 0;
|
||||
result = Object.freeze({
|
||||
database: evidence,
|
||||
receiptStatus: impactResult.status,
|
||||
receiptDigest: normalized.receipt.receiptDigest,
|
||||
impactDigest: impactResult.impact.impactDigest,
|
||||
impacted: impactResult.impact.items.length,
|
||||
pages,
|
||||
quarantined,
|
||||
existing,
|
||||
remaining,
|
||||
safeToAdmit: !remaining,
|
||||
});
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
|
||||
if (database) {
|
||||
try {
|
||||
await database.close();
|
||||
} catch (closeError) {
|
||||
if (failure !== undefined) {
|
||||
throw new AggregateError(
|
||||
[failure, closeError],
|
||||
'Cluster Plugin Package publisher revocation failed and PostgreSQL did not close',
|
||||
);
|
||||
}
|
||||
throw closeError;
|
||||
}
|
||||
}
|
||||
if (failure !== undefined) throw failure;
|
||||
if (!result) {
|
||||
throw new Error(
|
||||
'Cluster Plugin Package publisher revocation produced no result',
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
// Cluster Plugin Package publisher boundary; keep approval consumption authority explicit.
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { PostgresApprovalRequestRepository } from '@qinglong/cluster-postgres/approved-action';
|
||||
import {
|
||||
PostgresPluginPackagePublisherRevocationProposalRepository,
|
||||
} from '@qinglong/cluster-postgres/package-executor';
|
||||
import { PostgresProjectPolicyRepository } from '@qinglong/cluster-postgres/project-policy';
|
||||
import type { PostgresPool } from '@qinglong/runtime-core';
|
||||
import { ProjectPolicyEngine } from '@qinglong/runtime-core/project-policy';
|
||||
|
||||
export const CLUSTER_PLUGIN_PACKAGE_PUBLISHER_APPROVAL_BATCH_LIMIT = 16;
|
||||
|
||||
export interface ConsumeClusterPluginPackagePublisherRevocationApprovalsOptions {
|
||||
readonly pool: PostgresPool;
|
||||
readonly now?: () => number;
|
||||
readonly limit?: number;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackagePublisherRevocationApprovalSummary {
|
||||
readonly scanned: number;
|
||||
readonly consumed: number;
|
||||
readonly existing: number;
|
||||
readonly expired: number;
|
||||
readonly blocked: number;
|
||||
}
|
||||
|
||||
function stableDigest(domain: string, value: string): string {
|
||||
return createHash('sha256')
|
||||
.update(domain)
|
||||
.update('\0')
|
||||
.update(value)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function stableId(prefix: string, domain: string, value: string): string {
|
||||
return `${prefix}-${stableDigest(domain, value)}`;
|
||||
}
|
||||
|
||||
function stableAuditEventId(requestId: string): string {
|
||||
const bytes = Buffer.from(
|
||||
stableDigest(
|
||||
'qinglong/plugin-package-publisher-revocation-consume-audit@v1',
|
||||
requestId,
|
||||
),
|
||||
'hex',
|
||||
);
|
||||
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
|
||||
const hex = bytes.toString('hex');
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(
|
||||
12,
|
||||
16,
|
||||
)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
||||
}
|
||||
|
||||
export async function consumeClusterPluginPackagePublisherRevocationApprovals(
|
||||
options: ConsumeClusterPluginPackagePublisherRevocationApprovalsOptions,
|
||||
): Promise<
|
||||
Readonly<ClusterPluginPackagePublisherRevocationApprovalSummary>
|
||||
> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some(
|
||||
(key) => !['pool', 'now', 'limit'].includes(key),
|
||||
) ||
|
||||
!options.pool ||
|
||||
typeof options.pool.query !== 'function' ||
|
||||
typeof options.pool.connect !== 'function' ||
|
||||
(options.now !== undefined && typeof options.now !== 'function')
|
||||
) {
|
||||
throw new TypeError(
|
||||
'publisher revocation approval consumer options are invalid',
|
||||
);
|
||||
}
|
||||
const limit =
|
||||
options.limit ?? CLUSTER_PLUGIN_PACKAGE_PUBLISHER_APPROVAL_BATCH_LIMIT;
|
||||
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 64) {
|
||||
throw new TypeError(
|
||||
'publisher revocation approval consumer limit is invalid',
|
||||
);
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
const observedAtMs = now();
|
||||
if (!Number.isSafeInteger(observedAtMs) || observedAtMs < 0) {
|
||||
throw new TypeError(
|
||||
'publisher revocation approval consumer clock is invalid',
|
||||
);
|
||||
}
|
||||
const proposals =
|
||||
new PostgresPluginPackagePublisherRevocationProposalRepository(
|
||||
options.pool,
|
||||
);
|
||||
const approvals = new PostgresApprovalRequestRepository(options.pool);
|
||||
const policy = new ProjectPolicyEngine(
|
||||
new PostgresProjectPolicyRepository(options.pool),
|
||||
);
|
||||
const requests = await proposals.listApprovedRequests(limit);
|
||||
let consumed = 0;
|
||||
let existing = 0;
|
||||
let expired = 0;
|
||||
let blocked = 0;
|
||||
for (const request of requests) {
|
||||
if (observedAtMs >= request.expiresAtMs) {
|
||||
expired += 1;
|
||||
continue;
|
||||
}
|
||||
const decision = await policy.decide({
|
||||
subject: request.requestedBy,
|
||||
projectId: request.projectId,
|
||||
permission: 'package.manage',
|
||||
});
|
||||
if (
|
||||
decision.fence === null ||
|
||||
(decision.effect !== 'allow' &&
|
||||
decision.effect !== 'require_approval')
|
||||
) {
|
||||
blocked += 1;
|
||||
continue;
|
||||
}
|
||||
const consumptionId = stableId(
|
||||
'pprc',
|
||||
'qinglong/plugin-package-publisher-revocation-consumption@v1',
|
||||
request.id,
|
||||
);
|
||||
const dispatchId = stableId(
|
||||
'pprd',
|
||||
'qinglong/plugin-package-publisher-revocation-dispatch@v1',
|
||||
request.id,
|
||||
);
|
||||
const result = await approvals.consume({
|
||||
requestId: request.id,
|
||||
expectedVersion: request.version,
|
||||
consumptionId,
|
||||
dispatchId,
|
||||
action: request.action,
|
||||
requestedBy: request.requestedBy,
|
||||
consumedBy: {
|
||||
type: 'system',
|
||||
id: 'cluster_package_executor',
|
||||
},
|
||||
consumedAtMs: observedAtMs,
|
||||
authorizationFence: decision.fence,
|
||||
audit: {
|
||||
eventId: stableAuditEventId(request.id),
|
||||
requestId: request.id,
|
||||
operationId: 'approval.consume',
|
||||
projectId: request.projectId,
|
||||
subject: {
|
||||
type: 'system',
|
||||
id: 'cluster_package_executor',
|
||||
},
|
||||
authenticationId: 'cluster-package-executor',
|
||||
outcome: 'allowed',
|
||||
reasons: ['publisher_revocation_execution'],
|
||||
fence: decision.fence,
|
||||
occurredAtMs: observedAtMs,
|
||||
},
|
||||
});
|
||||
if (result.status === 'consumed') consumed += 1;
|
||||
else existing += 1;
|
||||
}
|
||||
return Object.freeze({
|
||||
scanned: requests.length,
|
||||
consumed,
|
||||
existing,
|
||||
expired,
|
||||
blocked,
|
||||
});
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
// Cluster Plugin Package publisher boundary; keep Approved Action authority explicit.
|
||||
import {
|
||||
type ApprovedActionHandler,
|
||||
type ApprovedActionHandlerExecutionContext,
|
||||
type ApprovedActionHandlerInspection,
|
||||
type ApprovedActionHandlerResult,
|
||||
} from '@qinglong/runtime-core/approved-action-dispatcher';
|
||||
import {
|
||||
PLUGIN_PACKAGE_PUBLISHER_REVOCATION_ACTION_TYPE,
|
||||
PluginPackagePublisherRevocationProposalBindingConflictError,
|
||||
normalizePluginPackagePublisherRevocationProposal,
|
||||
resolvePluginPackagePublisherRevocationProposal,
|
||||
type PluginPackagePublisherRevocationProposalRepository,
|
||||
} from '@qinglong/runtime-core/plugin-package-publisher-revocation-proposal';
|
||||
import type {
|
||||
PluginPackagePublisherRevocationReceipt,
|
||||
} from '@qinglong/runtime-core/plugin-package-publisher-provenance';
|
||||
|
||||
export interface ClusterPluginPackagePublisherRevocationExecutionResult {
|
||||
readonly safeToAdmit: boolean;
|
||||
readonly receiptDigest: string;
|
||||
readonly impactDigest: string;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackagePublisherRevocationExecutionPort {
|
||||
run(
|
||||
receipt: Readonly<PluginPackagePublisherRevocationReceipt>,
|
||||
): Promise<
|
||||
Readonly<ClusterPluginPackagePublisherRevocationExecutionResult>
|
||||
>;
|
||||
}
|
||||
|
||||
export class ClusterPluginPackagePublisherRevocationApprovedActionHandler
|
||||
implements ApprovedActionHandler
|
||||
{
|
||||
readonly actionType = PLUGIN_PACKAGE_PUBLISHER_REVOCATION_ACTION_TYPE;
|
||||
|
||||
constructor(
|
||||
readonly proposals: PluginPackagePublisherRevocationProposalRepository,
|
||||
readonly revocations: ClusterPluginPackagePublisherRevocationExecutionPort,
|
||||
) {
|
||||
if (
|
||||
!proposals ||
|
||||
typeof proposals.findProposalByActionRef !== 'function' ||
|
||||
!revocations ||
|
||||
typeof revocations.run !== 'function'
|
||||
) {
|
||||
throw new TypeError(
|
||||
'publisher revocation Approved Action authority is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async inspect(
|
||||
dispatch: ApprovedActionHandlerExecutionContext['dispatch'],
|
||||
): Promise<ApprovedActionHandlerInspection> {
|
||||
let proposal;
|
||||
try {
|
||||
proposal = await this.proposals.findProposalByActionRef(
|
||||
dispatch.action.actionRef,
|
||||
);
|
||||
} catch {
|
||||
return Object.freeze({
|
||||
status: 'retry',
|
||||
resultCode: 'publisher_revocation_proposal_unavailable',
|
||||
});
|
||||
}
|
||||
if (!proposal) {
|
||||
return Object.freeze({
|
||||
status: 'blocked',
|
||||
resultCode: 'publisher_revocation_proposal_missing',
|
||||
});
|
||||
}
|
||||
try {
|
||||
const normalized =
|
||||
normalizePluginPackagePublisherRevocationProposal(proposal);
|
||||
resolvePluginPackagePublisherRevocationProposal(
|
||||
normalized,
|
||||
dispatch,
|
||||
dispatch.createdAtMs,
|
||||
);
|
||||
return Object.freeze({
|
||||
status: 'ready',
|
||||
actionDigest: normalized.actionDigest,
|
||||
});
|
||||
} catch {
|
||||
return Object.freeze({
|
||||
status: 'blocked',
|
||||
resultCode: 'publisher_revocation_proposal_rejected',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async execute(
|
||||
context: Readonly<ApprovedActionHandlerExecutionContext>,
|
||||
): Promise<Readonly<ApprovedActionHandlerResult>> {
|
||||
const startedAtMs = context.execution.startedAtMs;
|
||||
if (
|
||||
context.execution.status !== 'executing' ||
|
||||
startedAtMs === null ||
|
||||
context.execution.leaseOwner !== context.fence.owner ||
|
||||
context.execution.leaseToken !== context.fence.leaseToken ||
|
||||
context.execution.version !== context.fence.version
|
||||
) {
|
||||
return Object.freeze({
|
||||
outcome: 'failed',
|
||||
resultCode: 'publisher_revocation_execution_rejected',
|
||||
});
|
||||
}
|
||||
const proposal = await this.proposals.findProposalByActionRef(
|
||||
context.dispatch.action.actionRef,
|
||||
);
|
||||
if (!proposal) {
|
||||
return Object.freeze({
|
||||
outcome: 'failed',
|
||||
resultCode: 'publisher_revocation_proposal_missing',
|
||||
});
|
||||
}
|
||||
let receipt;
|
||||
try {
|
||||
receipt = resolvePluginPackagePublisherRevocationProposal(
|
||||
proposal,
|
||||
context.dispatch,
|
||||
startedAtMs,
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof
|
||||
PluginPackagePublisherRevocationProposalBindingConflictError
|
||||
) {
|
||||
return Object.freeze({
|
||||
outcome: 'failed',
|
||||
resultCode: 'publisher_revocation_proposal_rejected',
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const result = await this.revocations.run(receipt);
|
||||
if (!result.safeToAdmit) {
|
||||
return Object.freeze({
|
||||
outcome: 'indeterminate',
|
||||
resultCode: 'publisher_revocation_convergence_incomplete',
|
||||
});
|
||||
}
|
||||
if (
|
||||
result.receiptDigest !== receipt.receiptDigest ||
|
||||
!/^[0-9a-f]{64}$/.test(result.impactDigest)
|
||||
) {
|
||||
return Object.freeze({
|
||||
outcome: 'failed',
|
||||
resultCode: 'publisher_revocation_result_rejected',
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
outcome: 'succeeded',
|
||||
resultCode: 'publisher_revocation_converged',
|
||||
resultDigest: result.impactDigest,
|
||||
});
|
||||
}
|
||||
}
|
||||
+1078
File diff suppressed because it is too large
Load Diff
+178
@@ -0,0 +1,178 @@
|
||||
// Cluster Plugin Package publisher boundary; keep transition approval authority explicit.
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { PostgresApprovalRequestRepository } from '@qinglong/cluster-postgres/approved-action';
|
||||
import {
|
||||
PostgresPluginPackagePublisherTrustTransitionProposalRepository,
|
||||
} from '@qinglong/cluster-postgres/package-executor';
|
||||
import { PostgresProjectPolicyRepository } from '@qinglong/cluster-postgres/project-policy';
|
||||
import type { PostgresPool } from '@qinglong/runtime-core';
|
||||
import { ProjectPolicyEngine } from '@qinglong/runtime-core/project-policy';
|
||||
|
||||
export const CLUSTER_PLUGIN_PACKAGE_PUBLISHER_TRUST_TRANSITION_APPROVAL_BATCH_LIMIT =
|
||||
16;
|
||||
|
||||
export interface ConsumeClusterPluginPackagePublisherTrustTransitionApprovalsOptions {
|
||||
readonly pool: PostgresPool;
|
||||
readonly now?: () => number;
|
||||
readonly limit?: number;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackagePublisherTrustTransitionApprovalSummary {
|
||||
readonly scanned: number;
|
||||
readonly consumed: number;
|
||||
readonly existing: number;
|
||||
readonly expired: number;
|
||||
readonly blocked: number;
|
||||
}
|
||||
|
||||
function stableDigest(domain: string, value: string): string {
|
||||
return createHash('sha256')
|
||||
.update(domain)
|
||||
.update('\0')
|
||||
.update(value)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function stableId(prefix: string, domain: string, value: string): string {
|
||||
return `${prefix}-${stableDigest(domain, value)}`;
|
||||
}
|
||||
|
||||
function stableAuditEventId(requestId: string): string {
|
||||
const bytes = Buffer.from(
|
||||
stableDigest(
|
||||
'qinglong/plugin-package-publisher-trust-transition-consume-audit@v1',
|
||||
requestId,
|
||||
),
|
||||
'hex',
|
||||
);
|
||||
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
|
||||
const hex = bytes.toString('hex');
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(
|
||||
12,
|
||||
16,
|
||||
)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
||||
}
|
||||
|
||||
export async function consumeClusterPluginPackagePublisherTrustTransitionApprovals(
|
||||
options: ConsumeClusterPluginPackagePublisherTrustTransitionApprovalsOptions,
|
||||
): Promise<
|
||||
Readonly<ClusterPluginPackagePublisherTrustTransitionApprovalSummary>
|
||||
> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some(
|
||||
(key) => !['pool', 'now', 'limit'].includes(key),
|
||||
) ||
|
||||
!options.pool ||
|
||||
typeof options.pool.query !== 'function' ||
|
||||
typeof options.pool.connect !== 'function' ||
|
||||
(options.now !== undefined && typeof options.now !== 'function')
|
||||
) {
|
||||
throw new TypeError(
|
||||
'publisher trust transition approval consumer options are invalid',
|
||||
);
|
||||
}
|
||||
const limit =
|
||||
options.limit ??
|
||||
CLUSTER_PLUGIN_PACKAGE_PUBLISHER_TRUST_TRANSITION_APPROVAL_BATCH_LIMIT;
|
||||
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 64) {
|
||||
throw new TypeError(
|
||||
'publisher trust transition approval consumer limit is invalid',
|
||||
);
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
const observedAtMs = now();
|
||||
if (!Number.isSafeInteger(observedAtMs) || observedAtMs < 0) {
|
||||
throw new TypeError(
|
||||
'publisher trust transition approval consumer clock is invalid',
|
||||
);
|
||||
}
|
||||
const proposals =
|
||||
new PostgresPluginPackagePublisherTrustTransitionProposalRepository(
|
||||
options.pool,
|
||||
);
|
||||
const approvals = new PostgresApprovalRequestRepository(options.pool);
|
||||
const policy = new ProjectPolicyEngine(
|
||||
new PostgresProjectPolicyRepository(options.pool),
|
||||
);
|
||||
const requests = await proposals.listApprovedRequests(limit);
|
||||
let consumed = 0;
|
||||
let existing = 0;
|
||||
let expired = 0;
|
||||
let blocked = 0;
|
||||
for (const request of requests) {
|
||||
if (observedAtMs >= request.expiresAtMs) {
|
||||
expired += 1;
|
||||
continue;
|
||||
}
|
||||
if (request.decisionMode !== 'separation_of_duty') {
|
||||
blocked += 1;
|
||||
continue;
|
||||
}
|
||||
const decision = await policy.decide({
|
||||
subject: request.requestedBy,
|
||||
projectId: request.projectId,
|
||||
permission: 'package.manage',
|
||||
});
|
||||
if (
|
||||
decision.fence === null ||
|
||||
(decision.effect !== 'allow' &&
|
||||
decision.effect !== 'require_approval')
|
||||
) {
|
||||
blocked += 1;
|
||||
continue;
|
||||
}
|
||||
const consumptionId = stableId(
|
||||
'ppttc',
|
||||
'qinglong/plugin-package-publisher-trust-transition-consumption@v1',
|
||||
request.id,
|
||||
);
|
||||
const dispatchId = stableId(
|
||||
'ppttd',
|
||||
'qinglong/plugin-package-publisher-trust-transition-dispatch@v1',
|
||||
request.id,
|
||||
);
|
||||
const result = await approvals.consume({
|
||||
requestId: request.id,
|
||||
expectedVersion: request.version,
|
||||
consumptionId,
|
||||
dispatchId,
|
||||
action: request.action,
|
||||
requestedBy: request.requestedBy,
|
||||
consumedBy: {
|
||||
type: 'system',
|
||||
id: 'cluster_package_executor',
|
||||
},
|
||||
consumedAtMs: observedAtMs,
|
||||
authorizationFence: decision.fence,
|
||||
audit: {
|
||||
eventId: stableAuditEventId(request.id),
|
||||
requestId: request.id,
|
||||
operationId: 'approval.consume',
|
||||
projectId: request.projectId,
|
||||
subject: {
|
||||
type: 'system',
|
||||
id: 'cluster_package_executor',
|
||||
},
|
||||
authenticationId: 'cluster-package-executor',
|
||||
outcome: 'allowed',
|
||||
reasons: ['publisher_trust_transition_execution'],
|
||||
fence: decision.fence,
|
||||
occurredAtMs: observedAtMs,
|
||||
},
|
||||
});
|
||||
if (result.status === 'consumed') consumed += 1;
|
||||
else existing += 1;
|
||||
}
|
||||
return Object.freeze({
|
||||
scanned: requests.length,
|
||||
consumed,
|
||||
existing,
|
||||
expired,
|
||||
blocked,
|
||||
});
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
// Cluster Plugin Package publisher boundary; keep transition execution authority explicit.
|
||||
import {
|
||||
type ApprovedActionHandler,
|
||||
type ApprovedActionHandlerExecutionContext,
|
||||
type ApprovedActionHandlerInspection,
|
||||
type ApprovedActionHandlerResult,
|
||||
} from '@qinglong/runtime-core/approved-action-dispatcher';
|
||||
import {
|
||||
PLUGIN_PACKAGE_PUBLISHER_TRUST_TRANSITION_ACTION_TYPES,
|
||||
PluginPackagePublisherTrustTransitionBindingConflictError,
|
||||
PluginPackagePublisherTrustTransitionConflictError,
|
||||
normalizePluginPackagePublisherTrustTransitionProposal,
|
||||
resolvePluginPackagePublisherTrustTransitionProposal,
|
||||
type PluginPackagePublisherTrustTransitionMode,
|
||||
type PluginPackagePublisherTrustTransitionProposalRepository,
|
||||
type PluginPackagePublisherTrustTransitionReceipt,
|
||||
} from '@qinglong/runtime-core/plugin-package-publisher-trust-transition-proposal';
|
||||
|
||||
export interface ClusterPluginPackagePublisherTrustTransitionExecutionResult {
|
||||
readonly status: 'created' | 'existing';
|
||||
readonly receipt: Readonly<PluginPackagePublisherTrustTransitionReceipt>;
|
||||
readonly head: Readonly<{
|
||||
generation: number;
|
||||
effectiveTrustDigest: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackagePublisherTrustTransitionExecutionPort {
|
||||
applyApprovedTransition(
|
||||
input: Readonly<{
|
||||
dispatch: ApprovedActionHandlerExecutionContext['dispatch'];
|
||||
executedAtMs: number;
|
||||
}>,
|
||||
): Promise<
|
||||
Readonly<ClusterPluginPackagePublisherTrustTransitionExecutionResult>
|
||||
>;
|
||||
}
|
||||
|
||||
export class ClusterPluginPackagePublisherTrustTransitionApprovedActionHandler
|
||||
implements ApprovedActionHandler
|
||||
{
|
||||
readonly actionType:
|
||||
(typeof PLUGIN_PACKAGE_PUBLISHER_TRUST_TRANSITION_ACTION_TYPES)[PluginPackagePublisherTrustTransitionMode];
|
||||
|
||||
constructor(
|
||||
readonly mode: PluginPackagePublisherTrustTransitionMode,
|
||||
readonly proposals: PluginPackagePublisherTrustTransitionProposalRepository,
|
||||
readonly transitions: ClusterPluginPackagePublisherTrustTransitionExecutionPort,
|
||||
) {
|
||||
this.actionType =
|
||||
PLUGIN_PACKAGE_PUBLISHER_TRUST_TRANSITION_ACTION_TYPES[mode];
|
||||
if (
|
||||
(mode !== 'overlap_add' && mode !== 'safe_retire') ||
|
||||
!proposals ||
|
||||
typeof proposals.findProposalByActionRef !== 'function' ||
|
||||
!transitions ||
|
||||
typeof transitions.applyApprovedTransition !== 'function'
|
||||
) {
|
||||
throw new TypeError(
|
||||
'publisher trust transition Approved Action authority is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async inspect(
|
||||
dispatch: ApprovedActionHandlerExecutionContext['dispatch'],
|
||||
): Promise<ApprovedActionHandlerInspection> {
|
||||
let proposal;
|
||||
try {
|
||||
proposal = await this.proposals.findProposalByActionRef(
|
||||
dispatch.action.actionRef,
|
||||
);
|
||||
} catch {
|
||||
return Object.freeze({
|
||||
status: 'retry',
|
||||
resultCode: 'publisher_trust_transition_proposal_unavailable',
|
||||
});
|
||||
}
|
||||
if (!proposal) {
|
||||
return Object.freeze({
|
||||
status: 'blocked',
|
||||
resultCode: 'publisher_trust_transition_proposal_missing',
|
||||
});
|
||||
}
|
||||
try {
|
||||
const normalized =
|
||||
normalizePluginPackagePublisherTrustTransitionProposal(proposal);
|
||||
if (
|
||||
normalized.actionInput.mode !== this.mode ||
|
||||
normalized.actionType !== this.actionType
|
||||
) {
|
||||
throw new PluginPackagePublisherTrustTransitionBindingConflictError();
|
||||
}
|
||||
resolvePluginPackagePublisherTrustTransitionProposal(
|
||||
normalized,
|
||||
dispatch,
|
||||
dispatch.createdAtMs,
|
||||
this.mode === 'safe_retire' ? 0 : null,
|
||||
);
|
||||
return Object.freeze({
|
||||
status: 'ready',
|
||||
actionDigest: normalized.actionDigest,
|
||||
});
|
||||
} catch {
|
||||
return Object.freeze({
|
||||
status: 'blocked',
|
||||
resultCode: 'publisher_trust_transition_proposal_rejected',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async execute(
|
||||
context: Readonly<ApprovedActionHandlerExecutionContext>,
|
||||
): Promise<Readonly<ApprovedActionHandlerResult>> {
|
||||
const startedAtMs = context.execution.startedAtMs;
|
||||
if (
|
||||
context.execution.status !== 'executing' ||
|
||||
startedAtMs === null ||
|
||||
context.execution.leaseOwner !== context.fence.owner ||
|
||||
context.execution.leaseToken !== context.fence.leaseToken ||
|
||||
context.execution.version !== context.fence.version
|
||||
) {
|
||||
return Object.freeze({
|
||||
outcome: 'failed',
|
||||
resultCode: 'publisher_trust_transition_execution_rejected',
|
||||
});
|
||||
}
|
||||
try {
|
||||
const result = await this.transitions.applyApprovedTransition({
|
||||
dispatch: context.dispatch,
|
||||
executedAtMs: startedAtMs,
|
||||
});
|
||||
if (
|
||||
result.receipt.mode !== this.mode ||
|
||||
result.receipt.mutationId !== context.dispatch.id ||
|
||||
result.head.generation !== result.receipt.currentGeneration ||
|
||||
result.head.effectiveTrustDigest !==
|
||||
result.receipt.currentTrustDigest
|
||||
) {
|
||||
return Object.freeze({
|
||||
outcome: 'failed',
|
||||
resultCode: 'publisher_trust_transition_result_rejected',
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
outcome: 'succeeded',
|
||||
resultCode:
|
||||
this.mode === 'overlap_add'
|
||||
? 'publisher_trust_overlap_added'
|
||||
: 'publisher_trust_key_retired',
|
||||
resultDigest: result.receipt.receiptDigest,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof
|
||||
PluginPackagePublisherTrustTransitionBindingConflictError ||
|
||||
error instanceof PluginPackagePublisherTrustTransitionConflictError
|
||||
) {
|
||||
return Object.freeze({
|
||||
outcome: 'failed',
|
||||
resultCode: 'publisher_trust_transition_conflict',
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
+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