UNPKG

kubricate

Version:

A TypeScript framework for building reusable, type-safe Kubernetes infrastructure — without the YAML mess.

318 lines (317 loc) 13 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.SecretsOrchestrator = void 0; var _SecretManagerEngine = /*#__PURE__*/require("./SecretManagerEngine.js"); /** * 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. */ class SecretsOrchestrator { engine; logger; // eslint-disable-next-line @typescript-eslint/no-explicit-any providerCache = /*#__PURE__*/new Map(); constructor(engine, logger) { this.engine = engine; this.logger = logger; } /** * Factory method to create a SecretsOrchestrator instance from options. */ static create(options) { const engine = new _SecretManagerEngine.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() { // 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() { 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); } logOrchestratorContext(mergeOptions) { 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)}`); } async loadSecretsFromManagers(managers) { const resolved = []; 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; } prepareEffects(resolvedSecrets) { 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. */ resolveConflictKey(effect) { return `${effect.secretType}:${effect.identifier}`; } mergePreparedEffects(effects) { const grouped = new Map(); 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 = []; 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 = 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 */ resolveProviderByName(providerName, expectedManagerName) { 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. */ resolveStrategyForLevel(level, conflictOptions) { const strict = conflictOptions?.conflict?.strict ?? false; const defaults = 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. */ validateConfig(config) { // 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. */ validateConflictOptions(conflictOptions) { 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}").`); } } } } exports.SecretsOrchestrator = SecretsOrchestrator; function formatMergeSources(group) { return group.map(g => { const keys = g.secretName ?? 'unknown'; return `SecretManager: ${g.managerName}, Provider: ${g.providerName}, Keys: [${keys}]`; }); } //# sourceMappingURL=SecretsOrchestrator.js.map