mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 18:08:20 +08:00
feat(ql3): admit plugin secret action jobs
This commit is contained in:
+268
-5
@@ -15,6 +15,9 @@ import {
|
||||
isPostgresTlsDnsServername,
|
||||
loadPostgresCertificateAuthorityFile,
|
||||
loadPostgresConnectionEnvironment,
|
||||
PostgresApprovedActionExecutionRepository,
|
||||
PostgresPluginPackageSecretBindingApprovalPlanReader,
|
||||
PostgresPluginPackageSecretBindingTransitionApprovalPlanReader,
|
||||
type PostgresConnectionOptions,
|
||||
type PostgresPoolOptions,
|
||||
type PostgresSchemaReadinessReport,
|
||||
@@ -48,6 +51,12 @@ import { ProjectedPluginPackageSecretExistenceInspector } from '../secret-bindin
|
||||
import {
|
||||
runClusterPluginPackagePublisherRevocation,
|
||||
} from '../publisher/pluginPackagePublisherRevocation';
|
||||
import {
|
||||
createClusterPluginPackageKubernetesSecretActionController,
|
||||
type ClusterPluginPackageKubernetesSecretActionControllerResource,
|
||||
type PluginPackageKubernetesSecretActionControllerSummary,
|
||||
} from './pluginPackageKubernetesSecretActionController';
|
||||
import type { PluginPackageKubernetesSecretActionJobOptions } from './pluginPackageKubernetesSecretActionJob';
|
||||
|
||||
export type ClusterPluginPackageExecutorProcessEnvironment = Readonly<
|
||||
Record<string, string | undefined>
|
||||
@@ -66,6 +75,11 @@ export type ClusterPluginPackageExecutorProcessConfig =
|
||||
revocationMaxPages: number;
|
||||
dispatchId: string | null;
|
||||
secretProjectionRoot: string | null;
|
||||
kubernetesSecretActions: Readonly<{
|
||||
enabled: true;
|
||||
limit: number;
|
||||
job: Readonly<PluginPackageKubernetesSecretActionJobOptions>;
|
||||
}> | null;
|
||||
database: Readonly<{
|
||||
connection: PostgresConnectionOptions;
|
||||
pool: PostgresPoolOptions;
|
||||
@@ -78,6 +92,7 @@ export interface ClusterPluginPackageExecutorBatchResult {
|
||||
readonly secretBindingApprovals: Readonly<ClusterPluginPackageSecretBindingApprovalSummary>;
|
||||
readonly secretBindingTransitionApprovals: Readonly<ClusterPluginPackageSecretBindingTransitionApprovalSummary>;
|
||||
readonly dispatch: Readonly<ApprovedActionDispatchBatchSummary>;
|
||||
readonly secretActionJobs: Readonly<PluginPackageKubernetesSecretActionControllerSummary>;
|
||||
}
|
||||
|
||||
export type ClusterPluginPackageExecutorProcessResult =
|
||||
@@ -116,6 +131,13 @@ export interface RunClusterPluginPackageExecutorProcessOptions {
|
||||
pool: PostgresPool,
|
||||
) => Promise<PostgresSchemaReadinessReport>;
|
||||
readonly now?: () => number;
|
||||
readonly createSecretActionController?: (
|
||||
options: Parameters<
|
||||
typeof createClusterPluginPackageKubernetesSecretActionController
|
||||
>[0],
|
||||
) => Promise<
|
||||
Readonly<ClusterPluginPackageKubernetesSecretActionControllerResource>
|
||||
>;
|
||||
}
|
||||
|
||||
export class ClusterPluginPackageExecutorProcessConfigError extends TypeError {
|
||||
@@ -131,6 +153,11 @@ export class ClusterPluginPackageExecutorProcessConfigError extends TypeError {
|
||||
|
||||
const SAFE_OWNER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const SAFE_DISPATCH_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const DNS_LABEL = /^[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?$/;
|
||||
const DNS_NAME = /^(?=.{1,253}$)[a-z0-9](?:[-a-z0-9.]*[a-z0-9])?$/;
|
||||
const SECRET_KEY = /^[A-Za-z0-9._-]{1,253}$/;
|
||||
const IMAGE_DIGEST =
|
||||
/^[a-z0-9](?:[a-z0-9._:/-]{0,510}[a-z0-9])?@sha256:[0-9a-f]{64}$/;
|
||||
|
||||
function enabledValue(
|
||||
environment: ClusterPluginPackageExecutorProcessEnvironment,
|
||||
@@ -307,6 +334,179 @@ function secretProjectionRoot(
|
||||
return value;
|
||||
}
|
||||
|
||||
function requiredEnvironment(
|
||||
environment: ClusterPluginPackageExecutorProcessEnvironment,
|
||||
name: string,
|
||||
maximumLength: number,
|
||||
): string {
|
||||
const value = boundedValue(environment, name, maximumLength);
|
||||
if (value === undefined) {
|
||||
throw new ClusterPluginPackageExecutorProcessConfigError(
|
||||
`${name} is required when the Kubernetes Secret action controller is enabled`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function kubernetesSecretActions(
|
||||
environment: ClusterPluginPackageExecutorProcessEnvironment,
|
||||
): Readonly<{
|
||||
enabled: true;
|
||||
limit: number;
|
||||
job: Readonly<PluginPackageKubernetesSecretActionJobOptions>;
|
||||
}> | null {
|
||||
const enabled = environment.QL3_PLUGIN_PACKAGE_SECRET_ACTION_CONTROLLER_ENABLED;
|
||||
if (enabled === undefined || enabled === '' || enabled === 'false') return null;
|
||||
if (enabled !== 'true') {
|
||||
throw new ClusterPluginPackageExecutorProcessConfigError(
|
||||
'QL3_PLUGIN_PACKAGE_SECRET_ACTION_CONTROLLER_ENABLED must be true or false',
|
||||
);
|
||||
}
|
||||
const namespace =
|
||||
boundedValue(environment, 'QL3_PLUGIN_PACKAGE_SECRET_ACTION_NAMESPACE', 63) ??
|
||||
'qinglong3-system';
|
||||
const serviceAccountName =
|
||||
boundedValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_SECRET_ACTION_SERVICE_ACCOUNT',
|
||||
63,
|
||||
) ?? 'ql3-plugin-package-secret-action';
|
||||
const sourceSecretName =
|
||||
boundedValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_SECRET_ACTION_SOURCE_SECRET',
|
||||
63,
|
||||
) ?? 'ql3-cluster-plugin-package-values';
|
||||
const image = requiredEnvironment(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_SECRET_ACTION_IMAGE',
|
||||
640,
|
||||
);
|
||||
const postgresCaSecretName = requiredEnvironment(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_CA_SECRET',
|
||||
63,
|
||||
);
|
||||
const postgresCaKey =
|
||||
boundedValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_CA_KEY',
|
||||
253,
|
||||
) ?? 'ca.crt';
|
||||
const servername = requiredEnvironment(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_SERVERNAME',
|
||||
253,
|
||||
);
|
||||
const urlSecretName = boundedValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_URL_SECRET',
|
||||
63,
|
||||
);
|
||||
const urlSecretKey = boundedValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_URL_KEY',
|
||||
253,
|
||||
);
|
||||
const fieldNames = [
|
||||
'QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_AUTH_SECRET',
|
||||
'QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_HOST',
|
||||
'QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_PORT',
|
||||
'QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_DATABASE',
|
||||
'QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_USERNAME_KEY',
|
||||
'QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_PASSWORD_KEY',
|
||||
] as const;
|
||||
const fields = fieldNames.map((name) => boundedValue(environment, name, 253));
|
||||
const urlMode = urlSecretName !== undefined || urlSecretKey !== undefined;
|
||||
const fieldMode = fields.some((value) => value !== undefined);
|
||||
if (
|
||||
!DNS_LABEL.test(namespace) ||
|
||||
!DNS_LABEL.test(serviceAccountName) ||
|
||||
!DNS_LABEL.test(sourceSecretName) ||
|
||||
!IMAGE_DIGEST.test(image) ||
|
||||
!DNS_LABEL.test(postgresCaSecretName) ||
|
||||
!SECRET_KEY.test(postgresCaKey) ||
|
||||
!DNS_NAME.test(servername) ||
|
||||
urlMode === fieldMode
|
||||
) {
|
||||
throw new ClusterPluginPackageExecutorProcessConfigError(
|
||||
'Kubernetes Secret action Job identity is invalid',
|
||||
);
|
||||
}
|
||||
let connection: PluginPackageKubernetesSecretActionJobOptions['postgres']['connection'];
|
||||
if (urlMode) {
|
||||
if (
|
||||
urlSecretName === undefined ||
|
||||
urlSecretKey === undefined ||
|
||||
!DNS_LABEL.test(urlSecretName) ||
|
||||
!SECRET_KEY.test(urlSecretKey)
|
||||
) {
|
||||
throw new ClusterPluginPackageExecutorProcessConfigError(
|
||||
'Kubernetes Secret action PostgreSQL URL reference is invalid',
|
||||
);
|
||||
}
|
||||
connection = Object.freeze({
|
||||
mode: 'url',
|
||||
secretName: urlSecretName,
|
||||
urlKey: urlSecretKey,
|
||||
});
|
||||
} else {
|
||||
if (fields.some((value) => value === undefined)) {
|
||||
throw new ClusterPluginPackageExecutorProcessConfigError(
|
||||
'Kubernetes Secret action PostgreSQL field reference is incomplete',
|
||||
);
|
||||
}
|
||||
const [authSecretName, host, portValue, database, usernameKey, passwordKey] =
|
||||
fields as [string, string, string, string, string, string];
|
||||
const port = Number(portValue);
|
||||
if (
|
||||
!DNS_LABEL.test(authSecretName) ||
|
||||
!DNS_NAME.test(host) ||
|
||||
!Number.isSafeInteger(port) ||
|
||||
port < 1 ||
|
||||
port > 65_535 ||
|
||||
!/^[A-Za-z_][A-Za-z0-9_$-]{0,62}$/.test(database) ||
|
||||
!SECRET_KEY.test(usernameKey) ||
|
||||
!SECRET_KEY.test(passwordKey)
|
||||
) {
|
||||
throw new ClusterPluginPackageExecutorProcessConfigError(
|
||||
'Kubernetes Secret action PostgreSQL field reference is invalid',
|
||||
);
|
||||
}
|
||||
connection = Object.freeze({
|
||||
mode: 'fields',
|
||||
authSecretName,
|
||||
host,
|
||||
port,
|
||||
database,
|
||||
usernameKey,
|
||||
passwordKey,
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
enabled: true,
|
||||
limit: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_SECRET_ACTION_CONTROLLER_LIMIT',
|
||||
8,
|
||||
1,
|
||||
32,
|
||||
),
|
||||
job: Object.freeze({
|
||||
namespace,
|
||||
serviceAccountName,
|
||||
sourceSecretName,
|
||||
image,
|
||||
postgres: Object.freeze({
|
||||
connection,
|
||||
caSecretName: postgresCaSecretName,
|
||||
caKey: postgresCaKey,
|
||||
servername,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function loadClusterPluginPackageExecutorProcessConfig(
|
||||
environment: ClusterPluginPackageExecutorProcessEnvironment,
|
||||
): ClusterPluginPackageExecutorProcessConfig {
|
||||
@@ -382,6 +582,7 @@ export function loadClusterPluginPackageExecutorProcessConfig(
|
||||
),
|
||||
dispatchId,
|
||||
secretProjectionRoot: secretProjectionRoot(environment),
|
||||
kubernetesSecretActions: kubernetesSecretActions(environment),
|
||||
database: databaseConfig(environment),
|
||||
});
|
||||
}
|
||||
@@ -424,6 +625,18 @@ function emptyApprovalSummary(): Readonly<{
|
||||
});
|
||||
}
|
||||
|
||||
function emptySecretActionJobSummary(): Readonly<PluginPackageKubernetesSecretActionControllerSummary> {
|
||||
return Object.freeze({
|
||||
scanned: 0,
|
||||
created: 0,
|
||||
existing: 0,
|
||||
active: 0,
|
||||
recoveryRequired: 0,
|
||||
unavailable: 0,
|
||||
truncated: false,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runClusterPluginPackageExecutorProcess(
|
||||
options: RunClusterPluginPackageExecutorProcessOptions,
|
||||
): Promise<ClusterPluginPackageExecutorProcessResult> {
|
||||
@@ -446,6 +659,8 @@ export async function runClusterPluginPackageExecutorProcess(
|
||||
typeof options.createDispatcher !== 'function') ||
|
||||
(options.assertReady !== undefined &&
|
||||
typeof options.assertReady !== 'function') ||
|
||||
(options.createSecretActionController !== undefined &&
|
||||
typeof options.createSecretActionController !== 'function') ||
|
||||
(options.now !== undefined && typeof options.now !== 'function')
|
||||
) {
|
||||
throw new TypeError('Plugin Package executor process options are invalid');
|
||||
@@ -464,6 +679,9 @@ export async function runClusterPluginPackageExecutorProcess(
|
||||
});
|
||||
const database = await openDatabase();
|
||||
let failure: unknown;
|
||||
let secretActionControllerResource:
|
||||
| Readonly<ClusterPluginPackageKubernetesSecretActionControllerResource>
|
||||
| undefined;
|
||||
try {
|
||||
const evidence = await (
|
||||
options.assertReady ?? assertPostgresPackageExecutorSchemaReady
|
||||
@@ -531,6 +749,7 @@ export async function runClusterPluginPackageExecutorProcess(
|
||||
secretBindingApprovals: emptyApprovalSummary(),
|
||||
secretBindingTransitionApprovals: emptyApprovalSummary(),
|
||||
dispatch,
|
||||
secretActionJobs: emptySecretActionJobSummary(),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
@@ -559,6 +778,32 @@ export async function runClusterPluginPackageExecutorProcess(
|
||||
limit: config.approvalBatchSize,
|
||||
...(options.now ? { now: options.now } : {}),
|
||||
});
|
||||
let secretActionJobs = emptySecretActionJobSummary();
|
||||
if (config.kubernetesSecretActions !== null) {
|
||||
if (!secretActionControllerResource) {
|
||||
const createController =
|
||||
options.createSecretActionController ??
|
||||
createClusterPluginPackageKubernetesSecretActionController;
|
||||
secretActionControllerResource = await createController({
|
||||
executions: new PostgresApprovedActionExecutionRepository(
|
||||
database.pool,
|
||||
),
|
||||
bindingPlans:
|
||||
new PostgresPluginPackageSecretBindingApprovalPlanReader(
|
||||
database.pool,
|
||||
),
|
||||
transitionPlans:
|
||||
new PostgresPluginPackageSecretBindingTransitionApprovalPlanReader(
|
||||
database.pool,
|
||||
),
|
||||
job: config.kubernetesSecretActions.job,
|
||||
...(options.now ? { now: options.now } : {}),
|
||||
});
|
||||
}
|
||||
secretActionJobs = await secretActionControllerResource.controller.reconcile({
|
||||
limit: config.kubernetesSecretActions.limit,
|
||||
});
|
||||
}
|
||||
const dispatch = await dispatcher.dispatchBatch({
|
||||
limit: config.dispatchBatchSize,
|
||||
});
|
||||
@@ -568,9 +813,10 @@ export async function runClusterPluginPackageExecutorProcess(
|
||||
secretBindingApprovals,
|
||||
secretBindingTransitionApprovals,
|
||||
dispatch,
|
||||
secretActionJobs,
|
||||
});
|
||||
batches.push(batch);
|
||||
if (isIdleBatch(batch)) break;
|
||||
if (secretActionJobs.scanned > 0 || isIdleBatch(batch)) break;
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'completed',
|
||||
@@ -581,16 +827,33 @@ export async function runClusterPluginPackageExecutorProcess(
|
||||
failure = error;
|
||||
throw error;
|
||||
} finally {
|
||||
let disposeError: unknown;
|
||||
try {
|
||||
secretActionControllerResource?.dispose();
|
||||
} catch (error) {
|
||||
disposeError = error;
|
||||
}
|
||||
let closeError: unknown;
|
||||
try {
|
||||
await database.close();
|
||||
} catch (closeError) {
|
||||
} catch (error) {
|
||||
closeError = error;
|
||||
}
|
||||
const cleanupErrors = [disposeError, closeError].filter(
|
||||
(error) => error !== undefined,
|
||||
);
|
||||
if (cleanupErrors.length > 0) {
|
||||
if (failure !== undefined) {
|
||||
throw new AggregateError(
|
||||
[failure, closeError],
|
||||
'Plugin Package executor process failed and PostgreSQL did not close',
|
||||
[failure, ...cleanupErrors],
|
||||
'Plugin Package executor process and resource cleanup failed',
|
||||
);
|
||||
}
|
||||
throw closeError;
|
||||
if (cleanupErrors.length === 1) throw cleanupErrors[0];
|
||||
throw new AggregateError(
|
||||
cleanupErrors,
|
||||
'Plugin Package executor resource cleanup failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+403
@@ -0,0 +1,403 @@
|
||||
import type { ApprovedActionExecutionSnapshot } from '@qinglong/runtime-core/approved-action-execution';
|
||||
import {
|
||||
PLUGIN_PACKAGE_SECRET_BINDING_ACTION_TYPE,
|
||||
type PluginPackageSecretBindingApprovalPlan,
|
||||
} from '@qinglong/runtime-core/plugin-package-secret-binding-approval-plan';
|
||||
import {
|
||||
PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_ACTION_TYPE,
|
||||
type PluginPackageSecretBindingTransitionApprovalPlan,
|
||||
} from '@qinglong/runtime-core/plugin-package-secret-binding-transition-approval-plan';
|
||||
|
||||
import {
|
||||
createPluginPackageKubernetesSecretActionJob,
|
||||
type PluginPackageKubernetesSecretActionJobOptions,
|
||||
} from './pluginPackageKubernetesSecretActionJob';
|
||||
|
||||
const FIELD_MANAGER = 'qinglong-plugin-package-secret-action-controller';
|
||||
const MAX_PAGE_SIZE = 32;
|
||||
|
||||
type SecretActionApprovalPlan =
|
||||
| PluginPackageSecretBindingApprovalPlan
|
||||
| PluginPackageSecretBindingTransitionApprovalPlan;
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
export interface PluginPackageSecretActionApprovalPlanReader<T> {
|
||||
findByActionRef(actionRef: string): Promise<Readonly<T> | null>;
|
||||
}
|
||||
|
||||
export interface PluginPackageKubernetesSecretActionExecutionReader {
|
||||
listReconciliableExecutions(query: Readonly<{
|
||||
nowMs: number;
|
||||
limit: number;
|
||||
actionTypes: readonly string[];
|
||||
}>): Promise<Readonly<{
|
||||
executions: readonly Readonly<ApprovedActionExecutionSnapshot>[];
|
||||
truncated: boolean;
|
||||
}>>;
|
||||
}
|
||||
|
||||
export interface PluginPackageKubernetesSecretActionJobResource {
|
||||
readonly metadata?: Readonly<{
|
||||
name?: string;
|
||||
namespace?: string;
|
||||
labels?: Readonly<Record<string, string>>;
|
||||
annotations?: Readonly<Record<string, string>>;
|
||||
}>;
|
||||
readonly spec?: Readonly<Record<string, unknown>>;
|
||||
readonly status?: Readonly<{
|
||||
conditions?: readonly Readonly<{
|
||||
type?: string;
|
||||
status?: string;
|
||||
}>[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface PluginPackageKubernetesSecretActionJobApi {
|
||||
createNamespacedJob(
|
||||
request: Readonly<{
|
||||
namespace: string;
|
||||
body: Readonly<Record<string, unknown>>;
|
||||
fieldManager: typeof FIELD_MANAGER;
|
||||
fieldValidation: 'Strict';
|
||||
}>,
|
||||
): Promise<PluginPackageKubernetesSecretActionJobResource>;
|
||||
readNamespacedJob(
|
||||
request: Readonly<{
|
||||
name: string;
|
||||
namespace: string;
|
||||
}>,
|
||||
): Promise<PluginPackageKubernetesSecretActionJobResource>;
|
||||
}
|
||||
|
||||
export interface PluginPackageKubernetesSecretActionControllerOptions {
|
||||
readonly executions: PluginPackageKubernetesSecretActionExecutionReader;
|
||||
readonly bindingPlans: PluginPackageSecretActionApprovalPlanReader<PluginPackageSecretBindingApprovalPlan>;
|
||||
readonly transitionPlans: PluginPackageSecretActionApprovalPlanReader<PluginPackageSecretBindingTransitionApprovalPlan>;
|
||||
readonly jobs: PluginPackageKubernetesSecretActionJobApi;
|
||||
readonly job: Readonly<PluginPackageKubernetesSecretActionJobOptions>;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
export interface PluginPackageKubernetesSecretActionControllerSummary {
|
||||
readonly scanned: number;
|
||||
readonly created: number;
|
||||
readonly existing: number;
|
||||
readonly active: number;
|
||||
readonly recoveryRequired: number;
|
||||
readonly unavailable: number;
|
||||
readonly truncated: boolean;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageKubernetesSecretActionControllerResource {
|
||||
readonly controller: PluginPackageKubernetesSecretActionController;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export class PluginPackageKubernetesSecretActionControllerConflictError extends Error {
|
||||
readonly code = 'PLUGIN_PACKAGE_KUBERNETES_SECRET_ACTION_CONTROLLER_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('Kubernetes Secret action Job conflicts with the durable dispatch');
|
||||
this.name = 'PluginPackageKubernetesSecretActionControllerConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class PluginPackageKubernetesSecretActionControllerUnavailableError extends Error {
|
||||
readonly code =
|
||||
'PLUGIN_PACKAGE_KUBERNETES_SECRET_ACTION_CONTROLLER_UNAVAILABLE';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super('Kubernetes Secret action Job authority is unavailable', options);
|
||||
this.name =
|
||||
'PluginPackageKubernetesSecretActionControllerUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
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 object(value: unknown): JsonObject | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as JsonObject)
|
||||
: null;
|
||||
}
|
||||
|
||||
function expectedSubset(expected: unknown, observed: unknown): boolean {
|
||||
if (Array.isArray(expected)) {
|
||||
return (
|
||||
Array.isArray(observed) &&
|
||||
expected.length === observed.length &&
|
||||
expected.every((value, index) => expectedSubset(value, observed[index]))
|
||||
);
|
||||
}
|
||||
const expectedObject = object(expected);
|
||||
if (expectedObject) {
|
||||
const observedObject = object(observed);
|
||||
return (
|
||||
observedObject !== null &&
|
||||
Object.entries(expectedObject).every(([key, value]) =>
|
||||
expectedSubset(value, observedObject[key]),
|
||||
)
|
||||
);
|
||||
}
|
||||
return Object.is(expected, observed);
|
||||
}
|
||||
|
||||
function terminalStatus(
|
||||
job: Readonly<PluginPackageKubernetesSecretActionJobResource>,
|
||||
): 'active' | 'complete' | 'failed' {
|
||||
const conditions = job.status?.conditions ?? [];
|
||||
const complete = conditions.some(
|
||||
(condition) => condition.type === 'Complete' && condition.status === 'True',
|
||||
);
|
||||
const failed = conditions.some(
|
||||
(condition) => condition.type === 'Failed' && condition.status === 'True',
|
||||
);
|
||||
if (complete && failed) {
|
||||
throw new PluginPackageKubernetesSecretActionControllerConflictError();
|
||||
}
|
||||
if (complete) return 'complete';
|
||||
if (failed) return 'failed';
|
||||
return 'active';
|
||||
}
|
||||
|
||||
function assertObservedJob(
|
||||
expected: Readonly<Record<string, unknown>>,
|
||||
observed: Readonly<PluginPackageKubernetesSecretActionJobResource>,
|
||||
): void {
|
||||
const expectedMetadata = object(expected.metadata)!;
|
||||
const expectedSpec = object(expected.spec)!;
|
||||
const observedMetadata = observed.metadata;
|
||||
const observedPodSpec = object(
|
||||
object(object(observed.spec)?.template)?.spec,
|
||||
);
|
||||
if (
|
||||
observedMetadata?.name !== expectedMetadata.name ||
|
||||
observedMetadata?.namespace !== expectedMetadata.namespace ||
|
||||
!expectedSubset(expectedMetadata.labels, observedMetadata?.labels) ||
|
||||
!expectedSubset(expectedMetadata.annotations, observedMetadata?.annotations) ||
|
||||
!expectedSubset(expectedSpec, observed.spec) ||
|
||||
observedPodSpec === null ||
|
||||
observedPodSpec.hostNetwork === true ||
|
||||
observedPodSpec.hostPID === true ||
|
||||
observedPodSpec.hostIPC === true ||
|
||||
(Array.isArray(observedPodSpec.initContainers) &&
|
||||
observedPodSpec.initContainers.length > 0) ||
|
||||
(Array.isArray(observedPodSpec.ephemeralContainers) &&
|
||||
observedPodSpec.ephemeralContainers.length > 0)
|
||||
) {
|
||||
throw new PluginPackageKubernetesSecretActionControllerConflictError();
|
||||
}
|
||||
}
|
||||
|
||||
export class PluginPackageKubernetesSecretActionController {
|
||||
readonly #now: () => number;
|
||||
|
||||
constructor(
|
||||
private readonly options: PluginPackageKubernetesSecretActionControllerOptions,
|
||||
) {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
!options.executions ||
|
||||
typeof options.executions.listReconciliableExecutions !== 'function' ||
|
||||
!options.bindingPlans ||
|
||||
typeof options.bindingPlans.findByActionRef !== 'function' ||
|
||||
!options.transitionPlans ||
|
||||
typeof options.transitionPlans.findByActionRef !== 'function' ||
|
||||
!options.jobs ||
|
||||
typeof options.jobs.createNamespacedJob !== 'function' ||
|
||||
typeof options.jobs.readNamespacedJob !== 'function' ||
|
||||
!options.job ||
|
||||
typeof options.job !== 'object' ||
|
||||
(options.now !== undefined && typeof options.now !== 'function')
|
||||
) {
|
||||
throw new TypeError('Kubernetes Secret action controller options are invalid');
|
||||
}
|
||||
this.#now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
async reconcile(
|
||||
input: Readonly<{ limit?: number }> = {},
|
||||
): Promise<Readonly<PluginPackageKubernetesSecretActionControllerSummary>> {
|
||||
if (
|
||||
!input ||
|
||||
typeof input !== 'object' ||
|
||||
Array.isArray(input) ||
|
||||
Object.keys(input).some((key) => key !== 'limit')
|
||||
) {
|
||||
throw new TypeError('Kubernetes Secret action reconciliation is invalid');
|
||||
}
|
||||
const limit = input.limit ?? 8;
|
||||
if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_PAGE_SIZE) {
|
||||
throw new RangeError('Kubernetes Secret action reconciliation limit is invalid');
|
||||
}
|
||||
const nowMs = this.#now();
|
||||
if (!Number.isSafeInteger(nowMs) || nowMs < 0) {
|
||||
throw new RangeError('Kubernetes Secret action controller clock is invalid');
|
||||
}
|
||||
let page;
|
||||
try {
|
||||
page = await this.options.executions.listReconciliableExecutions({
|
||||
nowMs,
|
||||
limit,
|
||||
actionTypes: Object.freeze([
|
||||
PLUGIN_PACKAGE_SECRET_BINDING_ACTION_TYPE,
|
||||
PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_ACTION_TYPE,
|
||||
]),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new PluginPackageKubernetesSecretActionControllerUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
const summary = {
|
||||
scanned: page.executions.length,
|
||||
created: 0,
|
||||
existing: 0,
|
||||
active: 0,
|
||||
recoveryRequired: 0,
|
||||
unavailable: 0,
|
||||
truncated: page.truncated,
|
||||
};
|
||||
for (const snapshot of page.executions) {
|
||||
try {
|
||||
const result = await this.#reconcileOne(snapshot, nowMs);
|
||||
summary[result] += 1;
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof
|
||||
PluginPackageKubernetesSecretActionControllerConflictError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
summary.unavailable += 1;
|
||||
}
|
||||
}
|
||||
return Object.freeze({ ...summary });
|
||||
}
|
||||
|
||||
async #reconcileOne(
|
||||
snapshot: Readonly<ApprovedActionExecutionSnapshot>,
|
||||
nowMs: number,
|
||||
): Promise<'created' | 'existing' | 'active' | 'recoveryRequired'> {
|
||||
const plan = await this.#plan(snapshot);
|
||||
if (!plan) {
|
||||
throw new PluginPackageKubernetesSecretActionControllerUnavailableError();
|
||||
}
|
||||
const desired = createPluginPackageKubernetesSecretActionJob({
|
||||
dispatch: snapshot.dispatch,
|
||||
approvalPlan: plan,
|
||||
options: this.options.job,
|
||||
});
|
||||
const metadata = object(desired.metadata)!;
|
||||
const name = metadata.name as string;
|
||||
const namespace = metadata.namespace as string;
|
||||
let observed: PluginPackageKubernetesSecretActionJobResource;
|
||||
let disposition: 'created' | 'existing' | 'active' = 'active';
|
||||
try {
|
||||
observed = await this.options.jobs.readNamespacedJob({ name, namespace });
|
||||
} catch (error) {
|
||||
if (apiStatus(error) !== 404) throw error;
|
||||
if (
|
||||
snapshot.execution.status === 'executing' ||
|
||||
nowMs > plan.expiresAtMs
|
||||
) {
|
||||
return 'recoveryRequired';
|
||||
}
|
||||
try {
|
||||
observed = await this.options.jobs.createNamespacedJob({
|
||||
namespace,
|
||||
body: desired,
|
||||
fieldManager: FIELD_MANAGER,
|
||||
fieldValidation: 'Strict',
|
||||
});
|
||||
disposition = 'created';
|
||||
} catch (createError) {
|
||||
// CREATE may have succeeded even when its response was lost. Converge
|
||||
// every ambiguous failure through an exact-name GET before surfacing it.
|
||||
try {
|
||||
observed = await this.options.jobs.readNamespacedJob({
|
||||
name,
|
||||
namespace,
|
||||
});
|
||||
} catch (readAfterCreateError) {
|
||||
if (apiStatus(readAfterCreateError) === 404) throw createError;
|
||||
throw readAfterCreateError;
|
||||
}
|
||||
disposition = 'existing';
|
||||
}
|
||||
}
|
||||
assertObservedJob(desired, observed);
|
||||
const terminal = terminalStatus(observed);
|
||||
if (terminal !== 'active') return 'recoveryRequired';
|
||||
return disposition;
|
||||
}
|
||||
|
||||
#plan(
|
||||
snapshot: Readonly<ApprovedActionExecutionSnapshot>,
|
||||
): Promise<Readonly<SecretActionApprovalPlan> | null> {
|
||||
const { actionType, actionRef } = snapshot.dispatch.action;
|
||||
if (actionType === PLUGIN_PACKAGE_SECRET_BINDING_ACTION_TYPE) {
|
||||
return this.options.bindingPlans.findByActionRef(actionRef);
|
||||
}
|
||||
if (actionType === PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_ACTION_TYPE) {
|
||||
return this.options.transitionPlans.findByActionRef(actionRef);
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
type KubernetesModule = typeof import('@kubernetes/client-node', {
|
||||
with: { 'resolution-mode': 'import' }
|
||||
});
|
||||
|
||||
export async function createClusterPluginPackageKubernetesSecretActionController(
|
||||
options: Omit<
|
||||
PluginPackageKubernetesSecretActionControllerOptions,
|
||||
'jobs'
|
||||
>,
|
||||
): Promise<Readonly<ClusterPluginPackageKubernetesSecretActionControllerResource>> {
|
||||
const kubernetes = (await import('@kubernetes/client-node')) as KubernetesModule;
|
||||
const config = new kubernetes.KubeConfig();
|
||||
config.loadFromCluster();
|
||||
const jobs = config.makeApiClient(
|
||||
kubernetes.BatchV1Api,
|
||||
) as unknown as PluginPackageKubernetesSecretActionJobApi;
|
||||
let active = true;
|
||||
return Object.freeze({
|
||||
controller: new PluginPackageKubernetesSecretActionController({
|
||||
...options,
|
||||
jobs,
|
||||
}),
|
||||
dispose() {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
for (const user of config.getUsers()) {
|
||||
const mutable = user as {
|
||||
token?: string;
|
||||
certData?: string;
|
||||
keyData?: string;
|
||||
};
|
||||
mutable.token = '';
|
||||
mutable.certData = '';
|
||||
mutable.keyData = '';
|
||||
}
|
||||
config.setCurrentContext('disposed');
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user