mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 02:27:44 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -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
Reference in New Issue
Block a user