kubricate
Version:
A TypeScript framework for building reusable, type-safe Kubernetes infrastructure — without the YAML mess.
318 lines (317 loc) • 13 kB
JavaScript
;
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