kubricate
Version:
A TypeScript framework for building reusable, type-safe Kubernetes infrastructure — without the YAML mess.
387 lines (337 loc) • 14.3 kB
text/typescript
import type { BaseLogger, BaseProvider, PreparedEffect, SecretValue } from '@kubricate/core';
import type { KubricateConfig } from '../../types.js';
import { SecretManagerEngine, type MergedSecretManager } from './SecretManagerEngine.js';
import type { ConfigConflictOptions, ConflictLevel, ConflictStrategy, SecretsOrchestratorOptions } from './types.js';
interface ResolvedSecret {
key: string;
value: SecretValue;
providerName: string;
managerName: string;
}
type PreparedEffectWithMeta = PreparedEffect & {
providerName: string;
managerName: string;
secretType: string;
identifier: string | undefined; // optional, depends on provider
};
/**
* SecretsOrchestrator
*
* @description
* Central orchestration engine responsible for:
* - Validating secret configuration and managers
* - Loading and resolving all declared secrets
* - Preparing provider-specific effects
* - Applying conflict resolution strategies (intraProvider, crossProvider, crossManager)
* - Producing a fully merged, finalized list of secret effects ready for output (e.g., YAML, JSON, etc.)
*
* @remarks
* - Acts as the internal core behind `kubricate secret apply`.
* - Ensures predictable, auditable, and conflict-safe secret generation.
* - Delegates provider-specific behavior to registered providers (e.g., mergeSecrets, prepare).
*
* @usage
* Typically called via:
*
* ```ts
* const orchestrator = SecretsOrchestrator.create(options);
* const effects = await orchestrator.apply();
* ```
*
* @throws {Error}
* If configuration, validation, or merging fails at any stage.
*/
export class SecretsOrchestrator {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private providerCache = new Map<string, any>();
constructor(
private engine: SecretManagerEngine,
private logger: BaseLogger
) {}
/**
* Factory method to create a SecretsOrchestrator instance from options.
*/
static create(options: SecretsOrchestratorOptions): SecretsOrchestrator {
const engine = new SecretManagerEngine(options);
return new SecretsOrchestrator(engine, options.logger);
}
/**
* Validates the project configuration and all registered secret managers.
*
* @remarks
* This is automatically called by commands like `kubricate secret apply` and `kubricate secret validate`.
*
* @description
* Performs full validation across:
* - Configuration schema (e.g., strictConflictMode rules, secret manager presence)
* - SecretManager instances and their attached connectors
* - Ensures all declared secrets can be loaded without error
*
* Logs important validation steps for traceability.
*
* @returns {Promise<MergedSecretManager>} A fully validated set of collected secret managers.
*
* @throws {Error}
* - If a configuration violation is detected (e.g., invalid strictConflictMode usage).
* - If a SecretManager or connector fails to validate or load secrets.
*/
async validate(): Promise<MergedSecretManager> {
// 1. Validate config options (e.g., strictConflictMode)
this.validateConfig(this.engine.options.config);
// 2. Validate secret managers and connectors
const managers = this.engine.collect();
await this.engine.validate(managers);
return managers;
}
/**
* Prepares a fully validated and merged set of provider-ready secret effects.
*
* @remarks
* This is the core orchestration method called by commands like `kubricate secret apply`.
*
* @description
* Executes the full secret orchestration lifecycle:
* - Validates project configuration and all secret managers
* - Loads and resolves all secrets across managers
* - Prepares raw provider effects for each secret
* - Merges effects according to conflict strategies (intraProvider, crossProvider, etc.)
*
* Logs context and important processing steps for debugging and traceability.
*
* @returns {Promise<PreparedEffect[]>} A list of finalized secret effects ready for output (e.g., Kubernetes manifests).
*
* @throws {Error}
* - If configuration validation fails (e.g., strictConflictMode violations).
* - If loading or preparing secrets fails.
* - If conflict resolution encounters an unrecoverable error (based on config).
*/
async apply(): Promise<PreparedEffect[]> {
const managers = await this.validate();
this.logOrchestratorContext(this.engine.options.config.secret);
// 1. Load and resolve all secrets
const resolvedSecrets = await this.loadSecretsFromManagers(managers);
// 2. Prepare raw effects using each provider
const rawEffects = this.prepareEffects(resolvedSecrets);
// 3. Merge grouped effects by provider
return this.mergePreparedEffects(rawEffects);
}
private logOrchestratorContext(mergeOptions: ConfigConflictOptions | undefined): void {
this.logger.info(`Using merge strategies:`);
this.logger.info(` - intraProvider: ${this.resolveStrategyForLevel('intraProvider', mergeOptions)}`);
this.logger.info(` - crossManager: ${this.resolveStrategyForLevel('crossManager', mergeOptions)}`);
this.logger.info(` - crossProvider: ${this.resolveStrategyForLevel('crossProvider', mergeOptions)}`);
}
private async loadSecretsFromManagers(managers: MergedSecretManager): Promise<ResolvedSecret[]> {
const resolved: ResolvedSecret[] = [];
for (const entry of Object.values(managers)) {
const secrets = entry.secretManager.getSecrets();
const loaded = await this.engine.loadSecrets(entry.secretManager, secrets);
for (const [key, value] of Object.entries(loaded)) {
const secretDef = secrets[key];
resolved.push({
key,
value,
providerName: String(secretDef.provider),
managerName: entry.name,
});
}
}
return resolved;
}
private prepareEffects(resolvedSecrets: ResolvedSecret[]): PreparedEffectWithMeta[] {
return resolvedSecrets.flatMap(secret => {
const provider = this.resolveProviderByName(secret.providerName, secret.managerName);
const effects = provider.prepare(secret.key, secret.value);
return effects.map(effect => ({
...effect,
managerName: secret.managerName,
providerName: provider.name!,
secretType: provider.secretType ?? provider.constructor.name,
identifier: provider.getEffectIdentifier?.(effect),
}));
});
}
/**
* Resolves the canonical conflict key for a given prepared secret effect.
*
* @description
* This key is used to group and detect conflicts between secrets during the orchestration phase.
*
* Normally, the conflict key includes:
* - `managerName`
* - `providerClassName` (inferred from secretType)
* - `identifier` (logical resource name)
*
* This ensures strict isolation between different SecretManagers or stacks.
*
* However, if the `crossManager` conflict strategy is explicitly configured as `'autoMerge'`,
* the orchestrator intentionally ignores the `managerName` prefix — allowing secrets from
* multiple managers to merge into the same logical resource (e.g., Kubernetes Secret, Vault path).
*
* ---
*
* Example behavior:
*
* Default (strict isolation):
* ```text
* frontend:Kubricate.OpaqueSecretProvider:app-secret
* backend:Kubricate.OpaqueSecretProvider:app-secret
* ```
* ➔ Different managers, different keys ➔ Conflict detected.
*
* CrossManager autoMerge mode:
* ```text
* Kubricate.OpaqueSecretProvider:app-secret
* ```
* ➔ Same key ➔ Secrets will be merged.
*
* ---
*
* @param effect - The prepared effect to calculate the conflict key for.
* @returns The canonical string key used for grouping and conflict resolution.
*/
private resolveConflictKey(effect: PreparedEffectWithMeta): string {
return `${effect.secretType}:${effect.identifier}`;
}
private mergePreparedEffects(effects: PreparedEffectWithMeta[]): PreparedEffect[] {
const grouped = new Map<string, PreparedEffectWithMeta[]>();
for (const effect of effects) {
const conflictKey = this.resolveConflictKey(effect);
this.logger.debug(
`[conflict:group] Grouping "${conflictKey}" from "${effect.managerName}" (${effect.providerName})`
);
if (!grouped.has(conflictKey)) grouped.set(conflictKey, []);
grouped.get(conflictKey)!.push(effect);
}
const merged: PreparedEffect[] = [];
for (const [mergeKey, group] of grouped.entries()) {
const providerNames = new Set(group.map(e => e.providerName));
const managerNames = new Set(group.map(e => e.managerName));
const level: ConflictLevel =
managerNames.size > 1 ? 'crossManager' : providerNames.size > 1 ? 'crossProvider' : 'intraProvider';
const strategy = this.resolveStrategyForLevel(level, this.engine.options.config.secret);
const providerName = group[0].providerName;
const provider = this.resolveProviderByName(providerName, group[0].managerName);
// 🔒 Enforce identifier sanity
if (!provider.getEffectIdentifier && group.length > 1) {
throw new Error(
`[conflict:error] Provider "${providerName}" must implement getEffectIdentifier() to safely merge multiple effects (identifier: "${mergeKey}")`
);
}
// 🔒 Enforce provider.allowMerge and strategy
if (group.length > 1) {
const sources = formatMergeSources(group);
if (!provider.allowMerge) {
throw new Error(
`[conflict:error] Provider "${providerName}" does not allow merging for identifier "${mergeKey}". Found in:\n - ${sources.join('\n - ')}`
);
}
if (strategy === 'error') {
throw new Error(
`[conflict:error:${level}] Duplicate resource identifier "${mergeKey}" detected in:\n - ${sources.join('\n - ')}`
);
}
if (strategy === 'overwrite') {
const dropped = sources.slice(0, -1);
const kept = sources[sources.length - 1];
this.logger.warn(
`[conflict:overwrite:${level}] Overwriting "${mergeKey}" — keeping ${kept}, dropped :\n - ${dropped.join('\n - ')}`
);
group.splice(0, group.length - 1); // keep only the last
}
// 'autoMerge' = no-op
}
if (typeof provider.mergeSecrets !== 'function') {
throw new Error(`[conflict:error] Provider "${providerName}" does not implement mergeSecrets()`);
}
merged.push(...provider.mergeSecrets(group));
}
return merged;
}
/**
* Resolves a provider instance from its name by scanning all SecretManagers.
* Caches resolved providers for performance.
*
* @throws If the provider name is not found in any manager
*/
private resolveProviderByName(providerName: string, expectedManagerName?: string): BaseProvider {
const cacheKey = expectedManagerName ? `${expectedManagerName}:${providerName}` : providerName;
if (this.providerCache.has(cacheKey)) {
return this.providerCache.get(cacheKey);
}
for (const entry of Object.values(this.engine.collect())) {
const secrets = entry.secretManager.getSecrets();
const managerName = entry.name;
this.logger.debug(`[SecretsOrchestrator] Looking for provider "${providerName}" in "${managerName}"`);
this.logger.debug(`[SecretsOrchestrator] Found secrets: ${JSON.stringify(secrets)}`);
for (const { provider } of Object.values(secrets)) {
if (provider === providerName) {
if (!expectedManagerName || managerName === expectedManagerName) {
const instance = entry.secretManager.resolveProvider(provider);
this.providerCache.set(cacheKey, instance);
return instance;
}
}
}
}
throw new Error(
`[SecretsOrchestrator] Provider "${providerName}" not found in any registered SecretManager${expectedManagerName ? ` "${expectedManagerName}"` : ''}`
);
}
/**
* Resolves the merge strategy for a given level using config or fallback defaults.
*/
private resolveStrategyForLevel(
level: ConflictLevel,
conflictOptions: ConfigConflictOptions | undefined
): ConflictStrategy {
const strict = conflictOptions?.conflict?.strict ?? false;
const defaults: Record<ConflictLevel, ConflictStrategy> = strict
? {
intraProvider: 'error', // no merging at all
crossProvider: 'error',
crossManager: 'error',
}
: {
intraProvider: 'autoMerge', // default allows merging inside provider
crossProvider: 'error',
crossManager: 'error',
};
return conflictOptions?.conflict?.strategies?.[level] ?? defaults[level];
}
/**
* Validates core secrets-related configuration inside the project config.
*
* @param config - The Kubricate project configuration object.
*
* @throws {Error} If the secret manager is missing or invalid.
*/
private validateConfig(config: KubricateConfig): void {
// Validate conflict options
this.validateConflictOptions(config.secret);
}
/**
* Validates conflict resolution options, especially when `strictConflictMode` is enabled.
*
* - If `strictConflictMode` is true, all conflict strategies must be set to 'error'.
* - Throws early if an invalid combination is detected.
*
* @param conflictOptions - The secret conflict configuration object.
*
* @throws {Error} If strict mode is enabled but a non-'error' strategy is found.
*/
private validateConflictOptions(conflictOptions: ConfigConflictOptions | undefined) {
if (!conflictOptions?.conflict?.strict) return;
for (const [level, strategy] of Object.entries(conflictOptions.conflict?.strategies ?? {})) {
if (strategy !== 'error') {
throw new Error(`[config:strictConflictMode] Strategy for "${level}" must be "error" (found "${strategy}").`);
}
}
}
}
function formatMergeSources(group: PreparedEffectWithMeta[]): string[] {
return group.map(g => {
const keys = g.secretName ?? 'unknown';
return `SecretManager: ${g.managerName}, Provider: ${g.providerName}, Keys: [${keys}]`;
});
}