All code snippets and files from the Kubricate repository, including examples and tests, are provided below. This includes configurations for different examples, shared configurations, helper functions, and core components of the Kubricate framework. The code is under license Apache-2.0, and you can find the full license text in the repository. https://github.com/thaitype/kubricate/blob/main/LICENSE ## File dump from `/home/runner/work/kubricate-website/kubricate-website/docs/v1/.vitepress/cache/kubricate` ### `examples/with-secret-manager/kubricate.config.ts` ```ts import { defineConfig } from 'kubricate'; import { secretManager } from './src/setup-secrets'; import simpleAppStack from './src/stacks'; export default defineConfig({ stacks: { ...simpleAppStack, }, secret: { secretSpec: secretManager, conflict: { strategies: { // Default conflict handling strategies // intraProvider: 'error', // crossProvider: 'error', // crossManager: 'error', }, }, }, }); ``` ### `examples/with-secret-registry/kubricate.config.ts` ```ts import { defineConfig } from 'kubricate'; import simpleAppStack from './src/compose-stacks'; import { secretRegistry } from './src/setup-secret'; export default defineConfig({ stacks: { ...simpleAppStack, }, secret: { secretSpec: secretRegistry, conflict: { strategies: { // Default conflict handling strategies // intraProvider: 'error', // crossProvider: 'error', // crossManager: 'error', }, }, }, }); ``` ### `examples/with-stack-template/kubricate.config.ts` ```ts import { defineConfig } from 'kubricate'; import { backend, frontend } from './src/MyStack'; export default defineConfig({ stacks: { frontend, backend, }, }); ``` ### `examples/with-stacks/kubricate.config.ts` ```ts import { defineConfig } from 'kubricate'; import simpleAppStack from './src/simple-app-stack'; export default defineConfig({ stacks: { ...simpleAppStack, }, }); ``` ### `tests/fixtures/shared-configs.ts` ```ts import { EnvConnector } from '@kubricate/plugin-env'; import { DockerConfigSecretProvider, OpaqueSecretProvider } from '@kubricate/plugin-kubernetes'; import { namespaceTemplate, simpleAppTemplate } from '@kubricate/stacks'; import { SecretManager, SecretRegistry, Stack } from 'kubricate'; export const frontendSecretManager = new SecretManager() .addConnector('EnvConnector', new EnvConnector()) .addProvider( 'OpaqueSecretProvider', new OpaqueSecretProvider({ name: 'secret-application', }) ) .addSecret({ name: 'my_app_key', }) .addSecret({ name: 'my_app_key_2', }); export const dockerSecretManager = new SecretManager() .addConnector('EnvConnector', new EnvConnector()) .addProvider( 'DockerConfigSecretProvider', new DockerConfigSecretProvider({ name: 'secret-application-provider', }) ) .addSecret({ name: 'DOCKER_SECRET', provider: 'DockerConfigSecretProvider', }); export const secretRegistry = new SecretRegistry() .add('frontend', frontendSecretManager) .add('docker', dockerSecretManager); export const sharedStacks = { namespace: Stack.fromTemplate(namespaceTemplate, { name: 'my-namespace' }), frontend: Stack.fromTemplate(simpleAppTemplate, { name: 'my-app', namespace: 'my-namespace', imageName: 'nginx', }), frontendWithSecretManager: Stack.fromTemplate(simpleAppTemplate, { name: 'my-app', namespace: 'my-namespace', imageName: 'nginx', }).useSecrets(frontendSecretManager, injector => { injector.secrets('my_app_key').forName('API_KEY').inject(); injector.secrets('my_app_key_2').forName('API_KEY_2').inject(); }), frontendWithSecretRegistry: Stack.fromTemplate(simpleAppTemplate, { name: 'my-app', namespace: 'my-namespace', imageName: 'nginx', }) .useSecrets(secretRegistry.get('frontend'), injector => { injector.secrets('my_app_key').forName('API_KEY').inject(); injector.secrets('my_app_key_2').forName('API_KEY_2').inject(); }) .useSecrets(secretRegistry.get('docker'), injector => { injector.secrets('DOCKER_SECRET').inject(); }), }; export const metadata = { // Disable DateTime & Version injection for snapshot testing injectManagedAt: false, injectVersion: false, }; ``` ### `tests/helpers/execute-kubricate.ts` ```ts import { execa, Options } from 'execa'; export async function executeKubricate(args: string[], options?: Options) { return execa('kubricate', args, options); } /** * Execute `kubricate generate` with options */ export async function runGenerate({ root, stdout = false, filters = [], }: { root: string; stdout?: boolean; filters?: string[]; }) { const args = ['generate', '--root', root]; if (stdout) { args.push('--stdout'); } for (const filter of filters) { args.push('--filter', filter); } return await executeKubricate(args, { reject: false }); } ``` ### `configs/config-vitest/configs/base-config.ts` ```ts import path from 'node:path'; import { defineConfig } from 'vitest/config'; export const baseConfig = defineConfig({ test: { passWithNoTests: true, coverage: { provider: 'istanbul', reporter: [ [ 'json', { file: `../coverage.json`, }, ], ], enabled: true, }, }, }); ``` ### `configs/config-vitest/configs/ui-config.ts` ```ts import { defineProject, mergeConfig } from 'vitest/config'; import { baseConfig } from './base-config.js'; export const uiConfig = mergeConfig( baseConfig, defineProject({ test: { environment: 'jsdom', }, }) ); ``` ### `configs/config-vitest/scripts/collect-json-outputs.ts` ```ts import fs from 'fs/promises'; import path from 'path'; import { glob } from 'glob'; async function collectCoverageFiles() { try { // Define the patterns to search const patterns = ['../../apps/*', '../../packages/*', '../../core/*', '../../shared']; // Define the destination directory (you can change this as needed) const destinationDir = path.join(process.cwd(), 'coverage/raw'); // Create the destination directory if it doesn't exist await fs.mkdir(destinationDir, { recursive: true }); // Arrays to collect all directories and directories with coverage.json const allDirectories = []; const directoriesWithCoverage = []; // Process each pattern for (const pattern of patterns) { // Find all paths matching the pattern const matches = await glob(pattern); // Filter to only include directories for (const match of matches) { const stats = await fs.stat(match); if (stats.isDirectory()) { allDirectories.push(match); const coverageFilePath = path.join(match, 'coverage.json'); // Check if coverage.json exists in this directory try { await fs.access(coverageFilePath); // File exists, add to list of directories with coverage directoriesWithCoverage.push(match); // Copy it to the destination with a unique name const directoryName = path.basename(match); const destinationFile = path.join(destinationDir, `${directoryName}.json`); await fs.copyFile(coverageFilePath, destinationFile); } catch (err) { // File doesn't exist in this directory, skip } } } } // Create clean patterns for display (without any "../" prefixes) const replaceDotPatterns = (str: string) => str.replace(/\.\.\//g, ''); if (directoriesWithCoverage.length > 0) { console.log(`Found coverage.json in: ${directoriesWithCoverage.map(replaceDotPatterns).join(', ')}`); } console.log(`Coverage collected into: ${path.join(process.cwd())}`); } catch (error) { console.error('Error collecting coverage files:', error); } } // Run the function collectCoverageFiles(); ``` ### `examples/with-secret-manager/src/setup-secrets.ts` ```ts import { EnvConnector } from '@kubricate/plugin-env'; import { DockerConfigSecretProvider, OpaqueSecretProvider } from '@kubricate/plugin-kubernetes'; import { SecretManager } from 'kubricate'; export const secretManager = new SecretManager() .addConnector('EnvConnector', new EnvConnector()) .addProvider( 'OpaqueSecretProvider', new OpaqueSecretProvider({ name: 'secret-application', }) ) .addProvider( 'DockerConfigSecretProvider', new DockerConfigSecretProvider({ name: 'secret-application-provider', }) ) .setDefaultConnector('EnvConnector') .setDefaultProvider('OpaqueSecretProvider') .addSecret({ name: 'my_app_key', }) .addSecret({ name: 'my_app_key_2', }) .addSecret({ name: 'DOCKER_SECRET', provider: 'DockerConfigSecretProvider', }); ``` ### `examples/with-secret-manager/src/shared-config.ts` ```ts export const config = { namespace: 'my-namespace', }; ``` ### `examples/with-secret-manager/src/stacks.ts` ```ts import { namespaceTemplate, simpleAppTemplate } from '@kubricate/stacks'; import { Stack } from 'kubricate'; import { secretManager } from './setup-secrets'; import { config } from './shared-config'; import { cronJobTemplate } from './stack-templates/cronJobTemplate'; const namespace = Stack.fromTemplate(namespaceTemplate, { name: config.namespace, }); const myApp = Stack.fromTemplate(simpleAppTemplate, { namespace: config.namespace, imageName: 'nginx', name: 'my-app', }) .useSecrets(secretManager, c => { c.secrets('my_app_key').forName('ENV_APP_KEY').inject(); c.secrets('my_app_key_2').forName('ENV_APP_KEY_2').inject(); c.secrets('DOCKER_SECRET').inject(); }) .override({ service: { apiVersion: 'v1', kind: 'Service', spec: { type: 'LoadBalancer', }, }, }); const cronJob = Stack.fromTemplate(cronJobTemplate, { name: 'my-cron-job', }).useSecrets(secretManager, c => { c.secrets('my_app_key') .forName('ENV_APP_KEY') .inject('env', { targetPath: 'spec.jobTemplate.spec.template.spec.containers[0].env', }) .intoResource('cronJob'); }); export default { namespace, myApp, cronJob }; ``` ### `examples/with-secret-registry/src/setup-secrets.ts` ```ts import { EnvConnector } from '@kubricate/plugin-env'; import { OpaqueSecretProvider } from '@kubricate/plugin-kubernetes'; import { SecretManager, SecretRegistry } from 'kubricate'; const frontendSecretManager = new SecretManager() .addConnector('EnvConnector', new EnvConnector()) .addProvider( 'OpaqueSecretProvider', new OpaqueSecretProvider({ name: 'secret-frontend', }) ) .addSecret({ name: 'frontend_app_key', }); const backendSecretManager = new SecretManager() .addConnector('EnvConnector', new EnvConnector()) .addProvider( 'OpaqueSecretProvider', new OpaqueSecretProvider({ name: 'secret-backend', }) ) .addSecret({ name: 'backend_app_key', }); export const secretRegistry = new SecretRegistry() .add('frontend', frontendSecretManager) .add('backend', backendSecretManager); ``` ### `examples/with-secret-registry/src/shared-config.ts` ```ts /** * Shared configuration for the infrastructure. */ export const config = { namespace: 'my-namespace', }; ``` ### `examples/with-secret-registry/src/stacks.ts` ```ts import { namespaceTemplate, simpleAppTemplate } from '@kubricate/stacks'; import { Stack } from 'kubricate'; import { secretRegistry } from './setup-secrets'; import { config } from './shared-config'; const namespace = Stack.fromTemplate(namespaceTemplate, { name: config.namespace, }); const frontend = Stack.fromTemplate(simpleAppTemplate, { namespace: config.namespace, imageName: 'nginx', name: 'my-frontend', }) .useSecrets(secretRegistry.get('frontend'), c => { c.secrets('frontend_app_key').forName('ENV_APP_KEY').inject(); }) .override({ service: { apiVersion: 'v1', kind: 'Service', spec: { type: 'LoadBalancer', }, }, }); const backend = Stack.fromTemplate(simpleAppTemplate, { namespace: config.namespace, imageName: 'nginx', name: 'my-backend', }).useSecrets(secretRegistry.get('backend'), c => { c.secrets('backend_app_key').forName('ENV_APP_KEY_2').inject(); }); export default { namespace, frontend, backend }; ``` ### `examples/with-stack-template/src/stacks.ts` ```ts import { Stack } from 'kubricate'; import { namespaceTemplate } from './stack-templates/namespaceTemplate'; export const frontend = Stack.fromTemplate(namespaceTemplate, { name: 'frontend-namespace', }); export const backend = Stack.fromTemplate(namespaceTemplate, { name: 'backend-namespace', }); ``` ### `examples/with-stacks/src/simple-app-stack.ts` ```ts import { namespaceTemplate, simpleAppTemplate } from '@kubricate/stacks'; import { Stack } from 'kubricate'; const namespace = Stack.fromTemplate(namespaceTemplate, { name: 'my-namespace', }); const myApp = Stack.fromTemplate(simpleAppTemplate, { imageName: 'nginx', name: 'my-app', }).override({ service: { apiVersion: 'v1', kind: 'Service', spec: { type: 'LoadBalancer', }, }, }); export default { namespace, myApp }; ``` ### `packages/core/src/BaseConnector.ts` ```ts import type { BaseLogger } from './logger.js'; import type { SecretValue } from './types.js'; /** * BaseConnector is the interface for secret connectors, * responsible for resolving secrets from sources such as * environment variables, cloud secret managers, etc. * * Connectors are read-only and should not persist data to any provider. */ export interface BaseConnector { /** * Optional configuration used during initialization. */ config: Config; logger?: BaseLogger; /** * Pre-load and validate a list of secret names. * Should fail fast if required secrets are missing or invalid. * * These names must correspond to top-level keys. * * This method is required before calling `get()`. */ load(names: string[]): Promise; /** * Return a secret by name after it has been loaded. * Throws if the secret was not previously loaded via `load()`. */ get(name: string): SecretValue; /** * Set the working directory for the connector. * This is useful for connectors that need to read files from a specific directory. * * For example, the EnvConnector may need to read a .env file from a specific path. * This method is optional and may not be implemented by all connectors. * If not implemented, it will be a no-op. * @param path The path to the working directory. */ setWorkingDir?(path: string | undefined): void; /** * Get the working directory for the connector. * This is useful for connectors that need to read files from a specific directory. * * For example, the EnvConnector may need to read a .env file from a specific path. * This method is optional and may not be implemented by all connectors. * If not implemented, it will return undefined. */ getWorkingDir?(): string | undefined; } ``` ### `packages/core/src/BaseProvider.ts` ```ts import type { BaseLogger } from './logger.js'; import type { SecretInjectionStrategy, SecretValue } from './types.js'; export interface BaseProvider< Config extends object = object, SupportedStrategies extends SecretInjectionStrategy['kind'] = SecretInjectionStrategy['kind'], > { /** * The name of the provider. * This is used to identify the provider in the config and logs. */ name: string | undefined; config: Config; logger?: BaseLogger; /** * prepare() is used to provision secret values into the cluster or remote backend. * It is only called during `kubricate secret apply`. * * It should return the full secret resource (e.g., Kubernetes Secret, Vault payload). */ prepare(name: string, value: SecretValue): PreparedEffect[]; /** * getInjectionPayload() is used to return runtime resource values (e.g., container.env). * This is used during manifest generation (`kubricate generate`) and must be pure. */ getInjectionPayload(injectes: ProviderInjection[]): unknown; /** * Return the Kubernetes path this provider expects for a given strategy. * This is used to generate the target path in the manifest for injection. */ getTargetPath(strategy: SecretInjectionStrategy): string; /** * Kubernetes resource kind this provider expects for a given strategy. * * e.g. `Deployment`, `StatefulSet`, `DaemonSet`, etc. */ readonly targetKind: string; readonly supportedStrategies: SupportedStrategies[]; mergeSecrets?(effects: PreparedEffect[]): PreparedEffect[]; /** * Each provider then defines how its own effects are uniquely identified (for conflict detection). * * Optional method to uniquely identify effects emitted by this provider * Used for detecting provider-level conflicts across providers. * * If undefined, no cross-provider conflict check will be performed. */ getEffectIdentifier?(effect: PreparedEffect): string; /** * Defines the target resource type (used for grouping/conflict) * * @deprecated the framework will use Provider Class name instead */ readonly secretType?: string; /** * Whether this provider allows merging (default = false) */ readonly allowMerge?: boolean; } export type PreparedEffect = CustomEffect | KubectlEffect; export interface BaseEffect { type: Type; value: T; // Metadata for the effect, refactor later providerName: string | undefined; secretName?: string; // Use for diagnostics } // eslint-disable-next-line @typescript-eslint/no-empty-object-type, @typescript-eslint/no-explicit-any export interface CustomEffect extends BaseEffect<'custom', T> {} /** * KubectlEffect is used to apply a value to a resource using kubectl. * This will apply automatically to the resource when it is created. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-object-type export interface KubectlEffect extends BaseEffect<'kubectl', T> {} export interface ProviderInjection { /** * A stable identifier for the provider instance. */ providerId: string; /** * Provider Instance use for get injectionPayload */ provider: BaseProvider; /** * Target resource ID in the composer which the secret will be injected. */ resourceId: ResourceId; /** * Target path in the resource where the secret will be injected. * This is used to deep-merge the value into the resource. * Refer to lodash get (Gets the value at path of object.) for more details. * https://lodash.com/docs/4.17.15#get * * This is a dot-separated path to the property in the resource where the value should be applied. */ path: Path; /** * Extra metadata passed during injection. */ meta?: { secretName: string; targetName: string; }; } ``` ### `packages/core/src/helper.ts` ```ts export type StackTemplate> = { name: string; create: (input: TInput) => TResourceMap; }; /** * Defines a stack factory that creates a stack of resources based on the provided input. * * @param name - The name of the stack. * @param factory - A function that takes an input and returns a map of resources. * @returns A stack factory function. */ export function defineStackTemplate>( name: string, factory: (input: TInput) => TResourceMap ): StackTemplate { return { name, create: factory, }; } ``` ### `packages/core/src/index.ts` ```ts export * from './logger.js'; export * from './BaseProvider.js'; export * from './BaseConnector.js'; export * from './types.js'; export * from './helper.js'; ``` ### `packages/core/src/logger.ts` ```ts export type LogLevel = 'silent' | 'error' | 'warn' | 'info' | 'debug'; export interface BaseLogger { level: LogLevel; log(message: string): void; info(message: string): void; warn(message: string): void; error(message: string): void; debug(message: string): void; } ``` ### `packages/core/src/types.ts` ```ts export type PrimitiveSecretValue = string | number | boolean | null | undefined; /** * /** * SecretValue represents the expected format for secret values loaded by a BaseConnector * and consumed by a BaseProvider. * * A SecretValue can be either: * - A single primitive (e.g., token, password, string literal) * - A flat object of key-value pairs, where each value is a primitive * * All values must be serializable to string (e.g., for Kubernetes Secret encoding). * Nested objects, arrays, or non-serializable types are not supported. */ export type SecretValue = PrimitiveSecretValue | Record; export interface BaseSecretInjectionStrategy { /** * Override the default target path for the secret injection. * * Moreover, each provider has a default target path for the secret injection. * By using BaseProvider.getTargetPath() */ targetPath?: string; } export type SecretInjectionStrategy = | ({ kind: 'env'; containerIndex?: number } & BaseSecretInjectionStrategy) | ({ kind: 'volume'; mountPath: string; containerIndex?: number } & BaseSecretInjectionStrategy) | ({ kind: 'annotation' } & BaseSecretInjectionStrategy) | ({ kind: 'imagePullSecret' } & BaseSecretInjectionStrategy) | ({ kind: 'envFrom'; containerIndex?: number } & BaseSecretInjectionStrategy) | { kind: 'plugin'; action?: string; args?: unknown[]; [key: string]: unknown }; ``` ### `packages/kubernetes-models/src/helper.ts` ```ts // eslint-disable-next-line @typescript-eslint/no-explicit-any type AnyClass = new (...args: any[]) => any; interface TypeMeta { apiVersion: string; kind: string; } type WithTypeMeta = T extends Omit ? U : T; /** * Converts a Kubernetes model class (from `kubernetes-models` package) * into a plain JSON-compatible object. * * This function instantiates the given Kubernetes model class with the provided config, * uses `.toJSON()` to extract the raw object representation, and then deep-clones * it into a plain object. This removes class methods and prototype chains, * making the object ready for serialization, diffing, or further transformation. * * ⚠️ This is designed specifically for use with `kubernetes-models` package. * It requires the class instance to implement `.toJSON()` method. * * @template T - A Kubernetes model class constructor * @param type - The constructor of a Kubernetes model (e.g. `Deployment`, `Service`) * @param config - The configuration object for the model constructor * @returns A deep-cloned plain object representing the Kubernetes resource * * @throws {Error} If the created instance does not implement `.toJSON()` * * @example * ```ts * import { Deployment } from 'kubernetes-models/apps/v1'; * import { kubeModel } from '@kubricate/kubernetes-models'; * * const deployment = kubeModel(Deployment, { * metadata: { name: 'nginx' }, * spec: { replicas: 2, template: { ... } } * }); * ``` */ export function kubeModel( type: T, config: ConstructorParameters[0] ): NonNullable[0]>> { const instance = new type(config); if (instance === undefined) { throw new Error(`[kubeModel] ${type.name} returned undefined. Ensure the constructor is correctly implemented.`); } if (typeof instance.toJSON !== 'function') { throw new Error( `[kubeModel] ${type.name} does not implement .toJSON(). This function only supports kubernetes-models.` ); } if (typeof instance.validate === 'function') { instance.validate(); } return structuredClone(instance.toJSON()); } ``` ### `packages/kubernetes-models/src/index.ts` ```ts export * from './helper.js'; ``` ### `packages/kubricate/src/cli.ts` ```ts import { cliEntryPoint } from './cli-interfaces/entrypoint.js'; import { handlerError } from './internal/error.js'; import { ConsoleLogger } from './internal/logger.js'; import { version } from './version.js'; // Set up the CLI entry point // This is the main entry point for the CLI application. cliEntryPoint(process.argv, { version, scriptName: 'kbr', }).catch(err => { handlerError(err, new ConsoleLogger('silent'), 99); }); ``` ### `packages/kubricate/src/config.ts` ```ts import type { KubricateConfig } from './types.js'; export function defineConfig(config: KubricateConfig): KubricateConfig { return config; } ``` ### `packages/kubricate/src/index.ts` ```ts export * from './config.js'; export * from './stack/index.js'; export * from './secret/index.js'; export * from './types.js'; ``` ### `packages/kubricate/src/types.ts` ```ts import type { ProjectGenerateOptions } from './commands/generate/types.js'; import type { ProjectSecretOptions } from './secret/types.js'; import type { BaseStack } from './stack/BaseStack.js'; import { ResourceComposer } from './stack/ResourceComposer.js'; export type FunctionLike = (...args: Params) => Return; export type AnyFunction = FunctionLike; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type AnyClass = { new (...args: any[]): any }; /** * Accept any type of key, including string, number, or symbol, Like `keyof any`. * This is useful for generic programming where the key type is not known in advance. * It allows for more flexibility in defining data structures and algorithms that can work with different key types. */ export type AnyKey = string | number | symbol; /** * Check is the type is never, return true if the type is never, false otherwise. */ export type IsNever = [T] extends [never] ? true : false; /** * FallbackIfNever checks if the type T is never, and if so, returns the fallback type. * Otherwise, it returns the original type T. */ export type FallbackIfNever = IsNever extends true ? Fallback : T; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type InferConfigureComposerFunc = T extends (...args: any[]) => ResourceComposer ? R : never; /** * Any String for literal types without losing autocompletion. */ export type AnyString = string & {}; /** * Metadata configuration for project-wide resource generation behavior. */ export interface ProjectMetadataOptions { /** * Whether to automatically inject standard Kubricate metadata * (such as labels and annotations) into every generated resource. * If `true`, Kubricate will inject fields like `stack-id`, `stack-name`, `version`, and `managed-at`. * Defaults to `true` if omitted. * * @default true */ inject?: boolean; /** * Whether to inject the 'managed-at' annotation into each generated resource. * If false, Kubricate will omit the 'kubricate.thaitype.dev/managed-at' field. * * Defaults to `true`. * * @default true */ injectManagedAt?: boolean; /** * Whether to inject the 'resource-hash' annotation into each generated resource. * * When enabled, Kubricate will calculate a stable hash of the resource content * (excluding dynamic fields like 'managed-at') and inject it into * the annotation 'kubricate.thaitype.dev/resource-hash'. * * Useful for GitOps and drift detection tools to track changes in resource specifications. * * Defaults to `true` if omitted. * * @default true */ injectResourceHash?: boolean; /** * Whether to inject the 'version' annotation into each generated resource. * * When enabled, Kubricate will inject the CLI framework version * (e.g., `0.17.0`) into the annotation 'kubricate.thaitype.dev/version'. * * Useful for tracking which Kubricate version was used to generate the manifest, * which can assist in debugging, auditing, or reproducing environments. * * Defaults to `true` if omitted. * * @default true */ injectVersion?: boolean; } export interface KubricateConfig { stacks?: Record; metadata?: ProjectMetadataOptions; /** * Secrets configuration */ secret?: ProjectSecretOptions; generate?: ProjectGenerateOptions; } ``` ### `packages/kubricate/src/version.ts` ```ts import { readFileSync } from 'node:fs'; import path, { join } from 'node:path'; import { fileURLToPath } from 'node:url'; function getPackageVersion(packageJsonPath: string) { let version = '0.0.0'; try { // Read "Pure ESM package": https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c const filename = fileURLToPath(import.meta.url); // ESM style like __filename in CommonJS const dirname = path.dirname(filename); // ESM style like __dirname in CommonJS version = JSON.parse(readFileSync(join(dirname, packageJsonPath), 'utf-8')).version; } catch { console.warn('Could not read version from package.json'); } return version; } export const version = getPackageVersion('../../package.json'); ``` ### `packages/plugin-env/src/EnvConnector.ts` ```ts import path from 'node:path'; import { config as loadDotenv } from 'dotenv'; import { type BaseConnector, type BaseLogger, type SecretValue } from '@kubricate/core'; import { maskingValue } from './utilts.js'; export interface EnvConnectorConfig { /** * The prefix to use for environment variables. * @default `KUBRICATE_SECRET_` */ prefix?: string; /** * populate process.env with the contents of a .env file * @default `true` */ allowDotEnv?: boolean; /** * Whether to perform case-insensitive lookups for environment variables. * If true, the connector will match environment variable names in a case-insensitive manner. * @default `false` */ caseInsensitive?: boolean; /** * The working directory to load the .env file from. * This is useful for loading .env files from different directories. * * @default `process.cwd()` */ workingDir?: string; } /** * EnvConnector is a BaseConnector implementation that reads secrets * from process.env, optionally loading from a .env file and supporting * configurable prefixes and case-insensitive lookups. */ export class EnvConnector implements BaseConnector { public config: EnvConnectorConfig; private prefix: string; private secrets = new Map(); private caseInsensitive: boolean; public logger?: BaseLogger; private workingDir?: string; constructor(config?: EnvConnectorConfig) { this.config = config ?? {}; this.prefix = config?.prefix ?? 'KUBRICATE_SECRET_'; this.caseInsensitive = config?.caseInsensitive ?? false; this.workingDir = config?.workingDir; } /** * Set the working directory for loading .env files. * @param path The path to the working directory. */ setWorkingDir(path: string): void { this.workingDir = path; } /** * Get the working directory for loading .env files. * @returns The path to the working directory. */ getWorkingDir(): string | undefined { return this.workingDir; } getEnvFilePath(): string { return path.join(this.workingDir ?? process.cwd(), '.env'); } normalizeName(name: string): string { return this.caseInsensitive ? name.toLowerCase() : name; } /** * Load secrets from environment variables. * @param names The names of the secrets to load. * @throws Will throw an error if a secret is not found or if the name is invalid. */ async load(names: string[]): Promise { if (this.config.allowDotEnv ?? true) { loadDotenv({ path: this.getEnvFilePath() }); this.logger?.debug(`Loaded .env file from\n ${this.getEnvFilePath()}`); } for (const name of names) { this.logger?.debug(`Loading secret: ${name}`); const expectedKey = this.prefix + name; const matchKey = this.caseInsensitive ? Object.keys(process.env).find(k => this.normalizeName(k) === this.normalizeName(expectedKey)) : expectedKey; if (!matchKey || !process.env[matchKey]) { throw new Error(`Missing environment variable: ${expectedKey}`); } const storeKey = this.normalizeName(name); this.secrets.set(storeKey, this.tryParseSecretValue(process.env[matchKey])); this.logger?.debug(`Loaded secret: ${name} -> ${storeKey}`); this.logger?.debug(`Value: ${maskingValue(process.env[matchKey]!)} `); } } tryParseSecretValue(value: string): SecretValue { try { const parsed = JSON.parse(value); if ( typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) && Object.values(parsed).every( v => typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' || v === null ) ) { return parsed; // ✅ Valid flat object } return value; // fallback: keep original string } catch { return value; // Not JSON, use raw string } } /** * Get the value of a secret. * @param name The name of the secret to get. * @returns The value of the secret. * @throws Will throw an error if the secret is not loaded. */ get(name: string): SecretValue { const key = this.caseInsensitive ? name.toLowerCase() : name; if (!this.secrets.has(key)) { throw new Error(`Secret '${name}' not loaded. Did you call load()?`); } return this.secrets.get(key)!; } } ``` ### `packages/plugin-env/src/index.ts` ```ts export * from './EnvConnector.js'; ``` ### `packages/plugin-env/src/utilts.ts` ```ts export function maskingValue(value: string, length = 4): string { length = Math.floor(length); if (length < 0) { throw new Error('Length must be a non-negative integer'); } if (value.length <= length) { return value + '*'.repeat(length - value.length); } return value.slice(0, length) + '*'.repeat(value.length - length); } ``` ### `packages/plugin-kubernetes/src/DockerConfigSecretProvider.ts` ```ts import { Base64 } from 'js-base64'; import { z } from 'zod'; import type { BaseLogger, BaseProvider, PreparedEffect, ProviderInjection, SecretInjectionStrategy, SecretValue, } from '@kubricate/core'; import { createKubernetesMergeHandler } from './merge-utils.js'; import { parseZodSchema } from './utils.js'; export const dockerRegistrySecretSchema = z.object({ username: z.string(), password: z.string(), registry: z.string(), }); export interface DockerConfigSecretProviderConfig { /** * Name of the Kubernetes Secret. */ name: string; /** * Namespace for the secret. * @default 'default' */ namespace?: string; } type SupportedStrategies = 'imagePullSecret'; /** * DockerConfigSecretProvider is a provider for Kubernetes that creates a Docker config secret */ export class DockerConfigSecretProvider implements BaseProvider { name: string | undefined; readonly secretType = 'Kubernetes.Secret.DockerConfigSecret'; injectes: ProviderInjection[] = []; logger?: BaseLogger; readonly targetKind = 'Deployment'; readonly supportedStrategies: SupportedStrategies[] = ['imagePullSecret']; constructor(public config: DockerConfigSecretProviderConfig) {} setInjects(injectes: ProviderInjection[]): void { this.injectes = injectes; } getTargetPath(strategy: SecretInjectionStrategy): string { if (strategy.kind === 'imagePullSecret') { if (strategy.targetPath) { return strategy.targetPath; } return `spec.template.spec.imagePullSecrets`; } throw new Error(`[DockerConfigSecretProvider] Unsupported injection strategy: ${strategy.kind}`); } getInjectionPayload(): Array<{ name: string }> { return [{ name: this.config.name }]; } getEffectIdentifier(effect: PreparedEffect): string { const meta = effect.value?.metadata ?? {}; return `${meta.namespace ?? 'default'}/${meta.name}`; } /** * Merge provider-level effects into final applyable resources. * Used to deduplicate (e.g. K8s secret name + ns). */ mergeSecrets(effects: PreparedEffect[]): PreparedEffect[] { const merge = createKubernetesMergeHandler(); return merge(effects); } prepare(name: string, value: SecretValue): PreparedEffect[] { const parsedValue = parseZodSchema(dockerRegistrySecretSchema, value); const dockerConfig = { auths: { [parsedValue.registry]: { username: parsedValue.username, password: parsedValue.password, auth: Base64.encode(`${parsedValue.username}:${parsedValue.password}`), }, }, }; return [ { secretName: name, providerName: this.name, type: 'kubectl', value: { apiVersion: 'v1', kind: 'Secret', metadata: { name: this.config.name, namespace: this.config.namespace ?? 'default', }, type: 'kubernetes.io/dockerconfigjson', data: { '.dockerconfigjson': Base64.encode(JSON.stringify(dockerConfig)), }, }, }, ]; } } ``` ### `packages/plugin-kubernetes/src/OpaqueSecretProvider.ts` ```ts import { Base64 } from 'js-base64'; import type { BaseLogger, BaseProvider, PreparedEffect, ProviderInjection, SecretInjectionStrategy, } from '@kubricate/core'; import { createKubernetesMergeHandler } from './merge-utils.js'; export interface OpaqueSecretProviderConfig { /** * The name of the secret to use. */ name: string; /** * The namespace of the secret to use. * * @default 'default' */ namespace?: string; } /** * EnvVar represents an environment variable present in a Container. * * Ported from import { IEnvVar } from 'kubernetes-models/v1/EnvVar'; import ProviderInjection from '@kubricate/core'; */ export interface EnvVar { /** * Name of the environment variable. Must be a C_IDENTIFIER. */ name: string; /** * Variable references $(VAR_NAME) are expanded using the previously defined environment variables in the container and any service environment variables. If a variable cannot be resolved, the reference in the input string will be unchanged. Double $$ are reduced to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". Escaped references will never be expanded, regardless of whether the variable exists or not. Defaults to "". */ value?: string; /** * Source for the environment variable's value. Cannot be used if value is not empty. */ valueFrom?: { /** * Selects a key of a secret in the pod's namespace */ secretKeyRef?: { /** * The key of the secret to select from. Must be a valid secret key. */ key: string; /** * Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names */ name?: string; /** * Specify whether the Secret or its key must be defined */ optional?: boolean; }; }; } type SupportedStrategies = 'env'; /** * OpaqueSecretProvider is a provider that uses Kubernetes secrets to inject secrets into the application. * It uses the Kubernetes API to create a secret with the given name and value. * The secret is created in the specified namespace. * * @see https://kubernetes.io/docs/concepts/configuration/secret/ */ export class OpaqueSecretProvider implements BaseProvider { readonly allowMerge = true; readonly secretType = 'Kubernetes.Secret.Opaque'; name: string | undefined; logger?: BaseLogger; readonly targetKind = 'Deployment'; readonly supportedStrategies: SupportedStrategies[] = ['env']; constructor(public config: OpaqueSecretProviderConfig) {} getTargetPath(strategy: SecretInjectionStrategy): string { if (strategy.kind === 'env') { if (strategy.targetPath) { return strategy.targetPath; } const index = strategy.containerIndex ?? 0; return `spec.template.spec.containers[${index}].env`; } throw new Error(`[OpaqueSecretProvider] Unsupported injection strategy: ${strategy.kind}`); } getEffectIdentifier(effect: PreparedEffect): string { const meta = effect.value?.metadata ?? {}; return `${meta.namespace ?? 'default'}/${meta.name}`; } getInjectionPayload(injectes: ProviderInjection[]): EnvVar[] { return injectes.map(inject => { const name = inject.meta?.targetName ?? inject.meta?.secretName; const key = inject.meta?.secretName; if (!name || !key) { throw new Error('[OpaqueSecretProvider] Invalid injection metadata: name or key is missing.'); } return { name, valueFrom: { secretKeyRef: { name: this.config.name, key, }, }, }; }); } /** * Merge provider-level effects into final applyable resources. * Used to deduplicate (e.g. K8s secret name + ns). */ mergeSecrets(effects: PreparedEffect[]): PreparedEffect[] { const merge = createKubernetesMergeHandler(); return merge(effects); } prepare(name: string, value: string): PreparedEffect[] { const encoded = Base64.encode(value); return [ { secretName: name, providerName: this.name, type: 'kubectl', value: { apiVersion: 'v1', kind: 'Secret', metadata: { name: this.config.name, namespace: this.config.namespace ?? 'default', }, type: 'Opaque', data: { [name]: encoded, }, }, }, ]; } } ``` ### `packages/plugin-kubernetes/src/index.ts` ```ts export * from './OpaqueSecretProvider.js'; export * from './DockerConfigSecretProvider.js'; export * from './utils.js'; export * from './merge-utils.js'; ``` ### `packages/plugin-kubernetes/src/merge-utils.ts` ```ts import type { PreparedEffect } from '@kubricate/core'; /** * Creates a reusable handler to merge multiple Kubernetes Secret effects. * Will group by Secret `metadata.name` + `namespace` and merge `.data`. * Throws error if duplicate keys are found within the same Secret. */ export function createKubernetesMergeHandler(): (effects: PreparedEffect[]) => PreparedEffect[] { return function mergeKubeSecrets(effects: PreparedEffect[]): PreparedEffect[] { const grouped: Record = {}; for (const effect of effects) { if (effect.type !== 'kubectl' || effect.value.kind !== 'Secret') continue; const name = effect.value.metadata?.name; const namespace = effect.value.metadata?.namespace ?? 'default'; const key = `${namespace}/${name}`; if (!grouped[key]) { grouped[key] = { ...effect, value: { ...effect.value, data: { ...effect.value.data }, }, }; continue; } const existing = grouped[key]; for (const [k, v] of Object.entries(effect.value.data ?? {})) { if (existing.value.data?.[k]) { throw new Error( `[conflict:k8s] Conflict detected: key "${k}" already exists in Secret "${name}" in namespace "${namespace}"` ); } existing.value.data[k] = v; } } return Object.values(grouped); }; } ``` ### `packages/plugin-kubernetes/src/utils.ts` ```ts import { ZodError, ZodSchema } from 'zod'; import { fromZodError } from 'zod-validation-error'; export function parseZodSchema(schema: ZodSchema, data: unknown): T { try { return schema.parse(data); } catch (error: unknown) { if (error instanceof ZodError) { throw new Error(`Validation error: ${fromZodError(error).message}`); } throw error; } } ``` ### `packages/stacks/src/NamespaceStack.ts` ```ts import { Namespace } from 'kubernetes-models/v1'; import { BaseStack, ResourceComposer } from 'kubricate'; interface INamespaceStack { name: string; } function configureComposer(data: INamespaceStack) { return new ResourceComposer().addClass({ id: 'namespace', type: Namespace, config: { metadata: { name: data.name, }, }, }); } /** * @deprecated Use `namespaceStackTemplate` instead. */ export class NamespaceStack extends BaseStack { constructor() { super(); } from(data: INamespaceStack) { const composer = configureComposer(data); this.setComposer(composer); return this; } } ``` ### `packages/stacks/src/SimpleAppStack.ts` ```ts import { Deployment } from 'kubernetes-models/apps/v1/Deployment'; import { Service } from 'kubernetes-models/v1/Service'; import { joinPath } from '@kubricate/toolkit'; import { BaseStack, ResourceComposer } from 'kubricate'; interface ISimpleAppStack { name: string; namespace?: string; imageName: string; replicas?: number; imageRegistry?: string; port?: number; } function configureComposer(data: ISimpleAppStack) { const port = data.port || 80; const replicas = data.replicas || 1; const imageRegistry = data.imageRegistry || ''; const metadata = { name: data.name, namespace: data.namespace }; const labels = { app: data.name }; return new ResourceComposer() .addClass({ id: 'deployment', type: Deployment, config: { metadata, spec: { replicas: replicas, selector: { matchLabels: labels, }, template: { metadata: { labels, }, spec: { containers: [ { image: joinPath(imageRegistry, data.imageName), name: data.name, ports: [{ containerPort: port }], }, ], }, }, }, }, }) .addClass({ id: 'service', type: Service, config: { metadata, spec: { selector: labels, type: 'ClusterIP', ports: [ { port, targetPort: port, }, ], }, }, }); } /** * @deprecated Use `simpleAppStackTemplate` instead. */ export class SimpleAppStack extends BaseStack { constructor() { super(); } override from(data: ISimpleAppStack) { const composer = configureComposer(data); this.setComposer(composer); return this; } } ``` ### `packages/stacks/src/index.ts` ```ts export * from './namespaceTemplate.js'; export * from './NamespaceStack.js'; export * from './simpleAppTemplate.js'; export * from './SimpleAppStack.js'; ``` ### `packages/stacks/src/namespaceTemplate.ts` ```ts import { Namespace } from 'kubernetes-models/v1'; import { defineStackTemplate } from '@kubricate/core'; import { kubeModel } from '@kubricate/kubernetes-models'; export interface INamespaceStack { name: string; } export const namespaceTemplate = defineStackTemplate('Namespace', (data: INamespaceStack) => { return { namespace: kubeModel(Namespace, { metadata: { name: data.name, }, }), }; }); ``` ### `packages/stacks/src/simpleAppTemplate.ts` ```ts import { Deployment } from 'kubernetes-models/apps/v1/Deployment'; import type { IContainer } from 'kubernetes-models/v1'; import { Service } from 'kubernetes-models/v1/Service'; import { defineStackTemplate } from '@kubricate/core'; import { kubeModel } from '@kubricate/kubernetes-models'; import { joinPath } from '@kubricate/toolkit'; export interface ISimpleAppStack { name: string; namespace?: string; imageName: string; replicas?: number; imageRegistry?: string; port?: number; env?: IContainer['env']; } export const simpleAppTemplate = defineStackTemplate('SimpleApp', (data: ISimpleAppStack) => { const port = data.port ?? 80; const replicas = data.replicas ?? 1; const imageRegistry = data.imageRegistry ?? ''; const metadata = { name: data.name, namespace: data.namespace }; const labels = { app: data.name }; return { deployment: kubeModel(Deployment, { metadata, spec: { replicas, selector: { matchLabels: labels, }, template: { metadata: { labels, }, spec: { containers: [ { image: joinPath(imageRegistry, data.imageName), name: data.name, ports: [{ containerPort: port }], env: data.env, }, ], }, }, }, }), service: kubeModel(Service, { metadata, spec: { selector: labels, type: 'ClusterIP', ports: [ { port, targetPort: port, }, ], }, }), }; }); ``` ### `packages/toolkit/src/cors.ts` ```ts /** * CORS configuration for Contour's HTTPProxy. Specifies the cross-origin policy to apply to the VirtualHost. * * From: * ``` * import type { IHTTPProxy } from '@kubernetes-models/contour/projectcontour.io/v1'; * type CorsConfig = NonNullable['corsPolicy']; * ``` */ export type CorsPolicy = { /** * Specifies whether the resource allows credentials. */ allowCredentials?: boolean; /** * AllowHeaders specifies the content for the \*access-control-allow-headers\* header. */ allowHeaders?: Array; /** * AllowMethods specifies the content for the \*access-control-allow-methods\* header. */ allowMethods: Array; /** * AllowOrigin specifies the origins that will be allowed to do CORS requests. Allowed values include "\*" which signifies any origin is allowed, an exact origin of the form "scheme://host[:port]" (where port is optional), or a valid regex pattern. Note that regex patterns are validated and a simple "glob" pattern (e.g. \*.foo.com) will be rejected or produce unexpected matches when applied as a regex. */ allowOrigin: Array; /** * AllowPrivateNetwork specifies whether to allow private network requests. See https://developer.chrome.com/blog/private-network-access-preflight. */ allowPrivateNetwork?: boolean; /** * ExposeHeaders Specifies the content for the \*access-control-expose-headers\* header. */ exposeHeaders?: Array; /** * MaxAge indicates for how long the results of a preflight request can be cached. MaxAge durations are expressed in the Go [Duration format](https://godoc.org/time#ParseDuration). Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h". Only positive values are allowed while 0 disables the cache requiring a preflight OPTIONS check for all cross-origin requests. */ maxAge?: string; }; export type CorsPreset = | { type: 'public'; } | { type: 'strict'; /** * One or more frontend domains allowed to call this API. * Must match the origin of the browser-based frontend (e.g., https://app.example.com). * * Required when `strict` mode is used. */ origin: string | string[]; } | { type: 'custom'; /** * Fully custom CORS config passed directly to Contour's HTTPProxy. * Use this if you need advanced control over origins, headers, or credentials. */ config: CorsPolicy; }; /** * Return Cors Policy in HTTPProxy Contour's format based on the given preset. */ export function resolveCors(preset?: CorsPreset): CorsPolicy | undefined { if (!preset) return undefined; const commonHeaders = ['Authorization', 'Content-Type']; const commonMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']; switch (preset.type) { case 'public': return { // Wide-open CORS. Suitable for public APIs with no auth. allowOrigin: ['*'], allowMethods: commonMethods, allowHeaders: commonHeaders, allowCredentials: false, }; case 'strict': // Locked-down CORS. Suitable for production frontends. return { allowOrigin: Array.isArray(preset.origin) ? preset.origin : [preset.origin], allowMethods: commonMethods, allowHeaders: commonHeaders, allowCredentials: true, }; case 'custom': // Developer-supplied config return preset.config; default: return undefined; } } ``` ### `packages/toolkit/src/index.ts` ```ts export * from './cors.js'; export * from './resource-allocator.js'; export * from './resource-metadata.js'; export * from './types.js'; export * from './utils.js'; ``` ### `packages/toolkit/src/resource-allocator.ts` ```ts /** * Preset modes for resource allocation. * - `conservative`: Prioritizes stability and efficiency with lower `requests` and limited `limits`. * - `optimized`: A balanced approach that optimizes resource usage without overcommitting. * - `aggressive`: Maximizes performance by using all allocated resources with high limits. */ export type PresetType = 'conservative' | 'optimized' | 'aggressive'; /** * Input resource configuration. */ interface ResourceConfig { /** * Number of CPU cores (e.g., 0.6 for 600m). */ cpu: number; /** * Memory size in GiB (e.g., 1.0 for 1024Mi). */ memory: number; } /** * Computed Kubernetes resource requests and limits. */ interface ComputedResources { requests: { cpu: string; memory: string }; limits: { cpu: string; memory: string }; } /** * A class that calculates Kubernetes resource requests and limits based on predefined allocation strategies. * * @default preset, `conservative` */ export class ResourceAllocator { /** * Preset configurations defining scaling factors for requests and limits. */ private static readonly PRESET_CONFIGS: Record = { /** * `conservative` mode: * - `requests` = 50% of input. * - `limits` = 100% of input. * - Best for workloads needing high stability and low resource contention. */ conservative: { requestFactor: 0.5, limitFactor: 1.0 }, /** * `optimized` mode: * - `requests` = 70% of input. * - `limits` = 130% of input. * - Recommended for general workloads that require a balance of efficiency and performance. */ optimized: { requestFactor: 0.7, limitFactor: 1.3 }, /** * `aggressive` mode: * - `requests` = 100% of input. * - `limits` = 200% of input. * - Best for high-performance workloads that need maximum resource usage. */ aggressive: { requestFactor: 1.0, limitFactor: 2.0 }, }; /** * Initializes the ResourceAllocator with a preset allocation mode. * @param preset The resource allocation strategy (`conservative`, `optimized`, or `aggressive`). */ constructor(private preset: PresetType = 'conservative') {} /** * Computes the resource requests and limits based on the selected preset. * @param input The input CPU (in cores) and memory (in GiB). * @returns An object containing `requests` and `limits` in Kubernetes format. */ computeResources(input: ResourceConfig): ComputedResources { const { requestFactor, limitFactor } = ResourceAllocator.PRESET_CONFIGS[this.preset]; return { requests: { cpu: this.formatCPU(input.cpu * requestFactor), memory: this.formatMemory(input.memory * requestFactor), }, limits: { cpu: this.formatCPU(input.cpu * limitFactor), memory: this.formatMemory(input.memory * limitFactor), }, }; } /** * Converts CPU cores into Kubernetes milliCPU (m). * @param cpuCores Number of CPU cores. * @returns The formatted CPU string in milliCPU (e.g., "600m"). */ private formatCPU(cpuCores: number): string { return `${Math.round(cpuCores * 1000)}m`; // Convert cores to milliCPU (m) } /** * Converts memory from GiB to Kubernetes MiB. * @param memoryGiB Memory size in GiB. * @returns The formatted memory string in MiB (e.g., "1024Mi"). */ private formatMemory(memoryGiB: number): string { return `${Math.round(memoryGiB * 1024)}Mi`; // Convert GiB to MiB } } ``` ### `packages/toolkit/src/resource-metadata.ts` ```ts import type { AnyString } from './types.js'; /** * ResourceSuffix is a map of resource types to their suffixes. */ export const resourceSuffix = { // Core API namespace: 'ns', pod: 'pod', service: 'svc', configMap: 'cm', secret: 'secret', persistentVolume: 'pv', persistentVolumeClaim: 'pvc', // Workloads deployment: 'deploy', statefulSet: 'sts', daemonSet: 'ds', replicaSet: 'rs', job: 'job', cronJob: 'cronjob', // Networking ingress: 'ing', networkPolicy: 'netpol', httpProxy: 'proxy', // ← Contour certificate: 'cert', // ← cert-manager clusterIssuer: 'cluster-issuer', // ← cert-manager // RBAC role: 'role', roleBinding: 'rb', clusterRole: 'cr', clusterRoleBinding: 'crb', serviceAccount: 'sa', // Storage storageClass: 'sc', volumeSnapshot: 'vs', // CRDs & Operators customResourceDefinition: 'crd', operator: 'operator', } as const; /** * Factory function to create metadata for resources. */ export const createMetadata = (namespace: 'default' | AnyString) => (name: string, suffix: keyof typeof resourceSuffix, metadata?: Record) => { const resolvedNamespace = namespace === 'default' ? 'default' : `${namespace}-${resourceSuffix.namespace}`; return { name: `${name}-${resourceSuffix[suffix]}`, namespace: resolvedNamespace, ...metadata, }; }; ``` ### `packages/toolkit/src/types.ts` ```ts /** * Any String for literal types without losing autocompletion. */ export type AnyString = string & {}; ``` ### `packages/toolkit/src/utils.ts` ```ts export function joinPath(...paths: string[]): string { return paths .filter(path => path !== '') .map(path => path.replace(/^\/|\/$/g, '')) .join('/'); } /** * Merges metadata for Kubernetes resources. * * This function is may deprecated in the future. It should have better way to merge metadata. * Pull Request is accepted. */ export function mergeMetadata(key: 'annotations' | 'labels', input: Record) { const result: Record = {}; result[key] = input; if (Object.keys(input).length === 0) { return undefined; } return result; } ``` ### `tests/fixtures/generate-output-flat/kubricate.config.ts` ```ts import { defineConfig } from 'kubricate'; import { metadata, sharedStacks } from '../shared-configs'; export default defineConfig({ stacks: { namespace: sharedStacks.namespace, frontend: sharedStacks.frontend, }, generate: { outputMode: 'flat', }, metadata, }); ``` ### `tests/fixtures/generate-output-resource/kubricate.config.ts` ```ts import { defineConfig } from 'kubricate'; import { metadata, sharedStacks } from '../shared-configs'; export default defineConfig({ stacks: { namespace: sharedStacks.namespace, frontend: sharedStacks.frontend, }, generate: { outputMode: 'resource', }, metadata, }); ``` ### `tests/fixtures/generate-output-stack/kubricate.config.ts` ```ts import { defineConfig } from 'kubricate'; import { metadata, sharedStacks } from '../shared-configs'; export default defineConfig({ stacks: { namespace: sharedStacks.namespace, frontend: sharedStacks.frontend, }, generate: { outputMode: 'stack', }, metadata, }); ``` ### `tests/fixtures/generate-output-with-secret/kubricate.config.ts` ```ts import { defineConfig } from 'kubricate'; import { metadata, sharedStacks } from '../shared-configs'; export default defineConfig({ stacks: { namespace: sharedStacks.namespace, frontend: sharedStacks.frontendWithSecretRegistry, }, generate: { outputMode: 'flat', }, metadata, }); ``` ### `tests/fixtures/generate-with-secret-manager/kubricate.config.ts` ```ts import { defineConfig } from 'kubricate'; import { frontendSecretManager, metadata, sharedStacks } from '../shared-configs'; export default defineConfig({ secret: { secretSpec: frontendSecretManager, }, stacks: { namespace: sharedStacks.namespace, frontend: sharedStacks.frontendWithSecretManager, }, generate: { outputMode: 'flat', }, metadata, }); ``` ### `tests/fixtures/generate-with-secret-registry/kubricate.config.ts` ```ts import { defineConfig } from 'kubricate'; import { metadata, secretRegistry, sharedStacks } from '../shared-configs'; export default defineConfig({ secret: { secretSpec: secretRegistry, }, stacks: { namespace: sharedStacks.namespace, frontend: sharedStacks.frontendWithSecretRegistry, }, generate: { outputMode: 'flat', }, metadata, }); ``` ### `examples/with-secret-manager/src/stack-templates/cronJobTemplate.ts` ```ts import { CronJob } from 'kubernetes-models/batch/v1'; import { defineStackTemplate } from '@kubricate/core'; import { kubeModel } from '@kubricate/kubernetes-models'; export interface MyInput { name: string; } /** * This cannot be used in real world, because the cronjob is not configured. */ export const cronJobTemplate = defineStackTemplate('CronJob', (data: MyInput) => { return { cronJob: kubeModel(CronJob, { metadata: { name: data.name, }, }), }; }); ``` ### `examples/with-stack-template/src/stack-templates/namespaceTemplate.ts` ```ts import { Namespace } from 'kubernetes-models/v1'; import { defineStackTemplate } from '@kubricate/core'; import { kubeModel } from '@kubricate/kubernetes-models'; interface MyInput { name: string; } export const namespaceTemplate = defineStackTemplate('Namespace', (data: MyInput) => { return { namespace: kubeModel(Namespace, { metadata: { name: data.name }, }), }; }); ``` ### `packages/kubricate/src/cli-interfaces/entrypoint.ts` ```ts import c from 'ansis'; import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; import type { LogLevel } from '@kubricate/core'; import { MARK_INFO } from '../internal/constant.js'; import { ConsoleLogger } from '../internal/logger.js'; import { generateCommand } from './generate.js'; import { secretCommand } from './secret/index.js'; export interface CliEntryPointOptions { version: string; scriptName: string; } type YargsWithHelper = { showHelp: () => void; }; const errorHelper = (msg: string | undefined, yargs: YargsWithHelper) => { if (msg?.includes('Unknown argument')) { const unknownArg = msg.match(/Unknown argument: (.+)/)?.[1]; console.error(c.red(`\n✖ Error: Unknown option "${unknownArg}".\n`)); console.info(c.cyan(`${MARK_INFO} Check the help for a list of available options.\n`)); yargs.showHelp(); console.log('\n'); return `Unknown option "${unknownArg}"`; } // Fallback to default error handling if (msg) { console.error(msg); } else { console.error('Unknown error occurred'); } console.log('\n'); }; export function cliEntryPoint(argv: string[], options: CliEntryPointOptions): Promise { return new Promise((resolve, reject) => { yargs(hideBin(argv)) .scriptName(options.scriptName) .usage('$0 ') .version(options.version) .option('root', { type: 'string', describe: 'Root directory' }) .option('config', { type: 'string', describe: 'Config file path' }) .option('verbose', { type: 'boolean', describe: 'Enable verbose output' }) .option('silent', { type: 'boolean', describe: 'Suppress all output' }) .option('dry-run', { type: 'boolean', describe: 'Dry run mode (Not Stable Yet)' }) .middleware(argv => { let level: LogLevel = 'info'; if (argv.silent) level = 'silent'; else if (argv.verbose) level = 'debug'; argv.logger = new ConsoleLogger(level); argv.version = options.version; }) .command(generateCommand) .command(secretCommand) .help() .alias('h', 'help') .alias('v', 'version') .demandCommand(1, '') .fail((msg, err, yargs) => { if (!msg && !err) { errorHelper(msg, yargs); return resolve(); } if (msg) { return reject(new Error(errorHelper(msg, yargs))); } if (err) { errorHelper(msg, yargs); return reject(err); } }) .strict() .parse(); }); } ``` ### `packages/kubricate/src/cli-interfaces/generate.ts` ```ts import type { ArgumentsCamelCase, CommandModule } from 'yargs'; import { ConfigLoader } from '../commands/ConfigLoader.js'; import { GenerateCommand, type GenerateCommandOptions } from '../commands/generate/index.js'; import { handlerError } from '../internal/error.js'; import { ConsoleLogger } from '../internal/logger.js'; import type { GlobalConfigOptions } from '../internal/types.js'; export const generateCommand: CommandModule = { command: 'generate', describe: 'Generate a stack into yaml files', builder: yargs => yargs .option('outDir', { type: 'string', describe: 'Output directory', default: 'output', }) .option('stdout', { type: 'boolean', describe: 'Output to stdout', default: false, }) .option('filter', { type: 'string', describe: 'Filter stacks or resources by ID (e.g., myStack or myStack.resource)', array: true, }), handler: async (argv: ArgumentsCamelCase) => { const logger = argv.stdout ? new ConsoleLogger('silent') : (argv.logger ?? new ConsoleLogger('info')); try { if (argv.stdout === false && argv.filter) { throw new Error('"--filter" option is allowed only when using with "--stdout" option'); } const configLoader = new ConfigLoader(argv, logger); const { config } = await configLoader.initialize({ commandOptions: argv, subject: 'generate', }); await new GenerateCommand(argv, logger).execute(config); } catch (error) { handlerError(error, logger); } }, }; ``` ### `packages/kubricate/src/commands/ConfigLoader.ts` ```ts import path from 'node:path'; import c from 'ansis'; import { type BaseLogger } from '@kubricate/core'; import { getConfig, getMatchConfigFile } from '../internal/load-config.js'; import type { GlobalConfigOptions } from '../internal/types.js'; import { validateId, verboseCliConfig, type Subcommand } from '../internal/utils.js'; import { SecretsOrchestrator } from '../secret/index.js'; import type { KubricateConfig } from '../types.js'; interface InitializeOptions { subject: Subcommand; commandOptions: CommandOptions; processConfig?: (config: KubricateConfig, commandOptions: CommandOptions, logger: BaseLogger) => BaseLogger; } export class ConfigLoader { constructor( protected options: GlobalConfigOptions, protected logger: BaseLogger ) {} /** * Initialize everything needed to run the command */ // public async initialize>(options: InitializeOptions) { // const { subject, commandOptions, processConfig } = options; // verboseCliConfig(commandOptions, this.logger, subject); // const config = await this.load(); // let logger = commandOptions.logger as BaseLogger | undefined ?? new ConsoleLogger(); // console.log(`this.logger Level: ${this.logger.level}`); // console.log(`logger Level: ${logger.level}`); // if (processConfig) { // logger = processConfig(config, commandOptions, logger); // } // this.setLogger(logger); // console.log(`this.logger Level: ${this.logger.level}`); // console.log(`logger Level: ${logger.level}`); // this.showVersion(); // const orchestrator = await this.prepare(config); // return { // config, // orchestrator, // } // } public async initialize>(options: InitializeOptions) { const { subject, commandOptions } = options; verboseCliConfig(commandOptions, this.logger, subject); const config = await this.load(); this.showVersion(); const orchestrator = await this.prepare(config); return { config, orchestrator, }; } public showVersion() { this.logger.log('\n' + c.bold(c.blue`kubricate`) + ` v${this.options.version}\n`); } public setLogger(logger: BaseLogger) { this.logger = logger; } protected injectLogger(config: KubricateConfig) { for (const stack of Object.values(config.stacks ?? {})) { stack.injectLogger(this.logger); } } private validateStackId(config: KubricateConfig | undefined) { if (!config) return; for (const stackId of Object.keys(config.stacks ?? {})) { validateId(stackId, 'stackId'); } } protected handleDeprecatedOptions(config: KubricateConfig | undefined): KubricateConfig { if (!config) return {}; if (!config.secret) return config; const { secret } = config; if (secret.manager && secret.registry) { throw new Error(`[config.secret] Cannot define both "manager" and "registry". Use "secretSpec" instead.`); } if (secret.manager || secret.registry) { this.logger.warn(`[config.secret] 'manager' and 'registry' are deprecated. Please use 'secretSpec' instead.`); } if (secret.manager) { config.secret = { ...secret, secretSpec: secret.manager }; } else if (secret.registry) { config.secret = { ...secret, secretSpec: secret.registry }; } delete config.secret.manager; delete config.secret.registry; return config; } public async load(): Promise { const logger = this.logger; logger.debug('Initializing secrets orchestrator...'); let config: KubricateConfig | undefined; logger.debug('Loading configuration...'); config = await getConfig(this.options); config = this.handleDeprecatedOptions(config); if (!config) { logger.error(`No config file found matching '${getMatchConfigFile()}'`); logger.error(`Please ensure a config file exists in the root directory:\n ${this.options.root}`); logger.error(`If your config is located elsewhere, specify it using:\n --root `); logger.error(`Or specify a config file using:\n --config `); logger.error(`Exiting...`); throw new Error('No config file found'); } this.validateStackId(config); logger.debug('Validated Stack Ids'); logger.debug('Configuration loaded: ' + JSON.stringify(config, null, 2)); return config; } public async prepare(config: KubricateConfig) { const logger = this.logger; logger.debug('Injecting logger into stacks...'); this.injectLogger(config); logger.debug('Injected logger into stacks.'); logger.debug('Creating secrets orchestrator...'); const workingDir = this.options.root ? path.resolve(this.options.root) : undefined; const orchestrator = SecretsOrchestrator.create({ config, logger, effectOptions: { workingDir }, }); logger.debug('Secrets orchestrator created.'); return orchestrator; } } ``` ### `packages/kubricate/src/commands/MetadataInjector.ts` ```ts import { createHash } from 'node:crypto'; import { cloneDeep } from 'lodash-es'; import { LABELS } from './constants.js'; export interface MetadataInjectorOptions { type: 'stack' | 'secret'; kubricateVersion: string; managedAt?: string; // Stack fields stackId?: string; stackName?: string; resourceId?: string; // Secret fields secretManagerId?: string; secretManagerName?: string; /** * Inject Options to enable/disable to the labels and annotations injected into the resource. */ inject?: { managedAt?: boolean; resourceHash?: boolean; version?: boolean; }; } export class MetadataInjector { constructor(private readonly options: MetadataInjectorOptions) {} inject(resource: Record): Record { if (typeof resource !== 'object' || resource == null) { return resource; } const metadata = this.ensureMetadata(resource); metadata.labels ??= {}; metadata.annotations ??= {}; metadata.labels[LABELS.kubricate] = 'true'; if (this.options.type === 'stack') { metadata.labels[LABELS.stackId] = this.options.stackId!; metadata.annotations[LABELS.stackName] = this.options.stackName!; metadata.labels[LABELS.resourceId] = this.options.resourceId!; } else if (this.options.type === 'secret') { metadata.labels[LABELS.secretManagerId] = this.options.secretManagerId!; metadata.annotations[LABELS.secretManagerName] = this.options.secretManagerName!; } if (this.options.inject?.version) { metadata.annotations[LABELS.version] = this.options.kubricateVersion; } if (this.options.inject?.resourceHash) { metadata.annotations[LABELS.resourceHash] = this.calculateHash(resource); } if (this.options.inject?.managedAt) { metadata.annotations[LABELS.managedAt] = this.options.managedAt ?? new Date().toISOString(); } return resource; } private ensureMetadata(resource: Record) { if (!('metadata' in resource)) { resource.metadata = {}; } // eslint-disable-next-line @typescript-eslint/no-explicit-any const metadata = resource.metadata as Record; metadata.labels ??= {}; metadata.annotations ??= {}; return metadata; } private calculateHash(resource: Record): string { const cleaned = this.cleanForHash(resource); const sorted = this.sortKeysRecursively(cleaned); const serialized = JSON.stringify(sorted); return createHash('sha256').update(serialized).digest('hex'); } private cleanForHash(resource: Record): Record { const clone = cloneDeep(resource); if (clone.metadata && typeof clone.metadata === 'object') { const metadata = clone.metadata as Record; delete metadata.creationTimestamp; delete metadata.resourceVersion; delete metadata.uid; delete metadata.selfLink; delete metadata.generation; delete metadata.managedFields; } return clone; } private sortKeysRecursively(obj: unknown): unknown { if (Array.isArray(obj)) { return obj.map(item => this.sortKeysRecursively(item)); } if (obj && typeof obj === 'object') { return Object.keys(obj) .sort() .reduce((acc, key) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any (acc as any)[key] = this.sortKeysRecursively((obj as any)[key]); return acc; // eslint-disable-next-line @typescript-eslint/no-explicit-any }, {} as any); } return obj; } } ``` ### `packages/kubricate/src/commands/SecretCommand.ts` ```ts import c from 'ansis'; import { type BaseLogger } from '@kubricate/core'; import type { KubectlExecutor } from '../executor/kubectl-executor.js'; import { MARK_CHECK } from '../internal/constant.js'; import type { GlobalConfigOptions } from '../internal/types.js'; import type { SecretsOrchestrator } from '../secret/index.js'; // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface SecretCommandOptions extends GlobalConfigOptions {} export class SecretCommand { constructor( protected options: GlobalConfigOptions, protected logger: BaseLogger, protected kubectl: KubectlExecutor ) {} async validate(orchestrator: SecretsOrchestrator) { this.logger.info('Validating secrets configuration...'); await orchestrator.validate(); this.logger.log(c.green`${MARK_CHECK} All secret managers validated successfully.`); } async apply(orchestrator: SecretsOrchestrator) { await orchestrator.validate(); const effects = await orchestrator.apply(); if (effects.length === 0) { this.logger.warn(`No secrets to apply.`); return; } for (const effect of effects) { if (effect.type === 'kubectl') { const name = effect.value?.metadata?.name ?? 'unnamed'; this.logger.info(`Applying secret: ${name}`); if (this.options.dryRun) { this.logger.log( c.yellow`${MARK_CHECK} [DRY RUN] Would apply: ${name} with kubectl using payload: ${JSON.stringify(effect.value)}` ); } else { await this.kubectl.apply(effect.value); } this.logger.log(c.green`${MARK_CHECK} Applied: ${name}`); } } this.logger.log(c.green`${MARK_CHECK} All secrets applied successfully.`); } } ``` ### `packages/kubricate/src/commands/constants.ts` ```ts export const FRAMEWORK_LABEL = 'kubricate.thaitype.dev'; export const LABELS = { kubricate: FRAMEWORK_LABEL, version: FRAMEWORK_LABEL + '/version', managedAt: FRAMEWORK_LABEL + '/managed-at', stackId: FRAMEWORK_LABEL + '/stack-id', stackName: FRAMEWORK_LABEL + '/stack-name', resourceId: FRAMEWORK_LABEL + '/resource-id', secretManagerId: FRAMEWORK_LABEL + '/secret-manager-id', secretManagerName: FRAMEWORK_LABEL + '/secret-manager-name', resourceHash: FRAMEWORK_LABEL + '/resource-hash', }; ``` ### `packages/kubricate/src/executor/execa-executor.ts` ```ts // execa-executor.ts import { execa } from 'execa'; export class ExecaExecutor { async run(command: string, args: string[]): Promise { await execa(command, args, { stdio: 'inherit' }); } } ``` ### `packages/kubricate/src/executor/kubectl-executor.ts` ```ts import crypto from 'node:crypto'; // kubectl-executor.ts import { writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import type { BaseLogger } from '@kubricate/core'; import type { ExecaExecutor } from './execa-executor.js'; export class KubectlExecutor { constructor( private readonly kubectlPath: string, private readonly logger: BaseLogger, private readonly execa: ExecaExecutor ) {} async apply(resource: object) { const tempPath = this.createTempFilePath(); await writeFile(tempPath, JSON.stringify(resource), 'utf8'); this.logger.info(`Applying secret resource with kubectl: ${tempPath}`); try { await this.execa.run(this.kubectlPath, ['apply', '-f', tempPath]); this.logger.log('✅ Applied secret via kubectl'); } catch (err) { this.logger.error(`❌ kubectl apply failed: ${(err as Error).message}`); throw err; } } private createTempFilePath(): string { const id = crypto.randomUUID(); return path.join(tmpdir(), `kubricate-secret-${id}.json`); } } ``` ### `packages/kubricate/src/internal/constant.ts` ```ts import c from 'ansis'; export const MARK_CHECK = c.green('✔'); export const MARK_INFO = c.blue('ℹ'); export const MARK_ERROR = c.red('✖'); export const MARK_WARNING = c.yellow('!'); export const MARK_BULLET = '•'; export const MARK_NODE = '⬢'; export const MARK_TREE_BRANCH = '│'; export const MARK_TREE_LEAF = '├──'; export const MARK_TREE_END = '└──'; export const MARK_TREE_SPACE = ' '; ``` ### `packages/kubricate/src/internal/error.ts` ```ts import type { BaseLogger } from '@kubricate/core'; import { ConsoleLogger } from './logger.js'; export function handlerError(error: unknown, logger: BaseLogger | undefined, exitCode = 3): void { if (logger === undefined) { logger = new ConsoleLogger('error'); } if (error instanceof Error) { logger.error(`Error: ${error.message}`); if (logger.level === 'debug') { logger.error('Error stack trace:'); logger.error(error.stack ?? 'Unknown error stack'); } } else { logger.error(`Error: ${error}`); } process.exit(exitCode); } ``` ### `packages/kubricate/src/internal/load-config.ts` ```ts import c from 'ansis'; import { loadConfig } from 'unconfig'; import { type BaseLogger } from '@kubricate/core'; import type { KubricateConfig } from '../types.js'; import { MARK_CHECK } from './constant.js'; import { SilentLogger } from './logger.js'; import type { GlobalConfigOptions } from './types.js'; export const DEFAULT_CONFIG_NAME = 'kubricate.config'; // Allow all JS/TS file extensions except JSON export const DEFAULT_CONFIG_EXTENSIONS = ['mts', 'cts', 'ts', 'mjs', 'cjs', 'js']; export function getMatchConfigFile(): string { return `${DEFAULT_CONFIG_NAME}.{${DEFAULT_CONFIG_EXTENSIONS.join(',')}}`; } export async function getConfig( options: GlobalConfigOptions, logger: BaseLogger = new SilentLogger() ): Promise { const result = await loadConfig({ cwd: options.root, sources: [ { files: options.config || DEFAULT_CONFIG_NAME, // Allow all JS/TS file extensions except JSON extensions: DEFAULT_CONFIG_EXTENSIONS, }, ], merge: false, }); if (result.sources.length) logger.log(c.green`${MARK_CHECK} Config loaded from ${result.sources.join(', ')}`); return result.config; } ``` ### `packages/kubricate/src/internal/logger.ts` ```ts import c from 'ansis'; import type { BaseLogger, LogLevel } from '@kubricate/core'; import { MARK_ERROR, MARK_INFO, MARK_WARNING } from './constant.js'; export class ConsoleLogger implements BaseLogger { constructor(public level: LogLevel = 'debug') {} private shouldLog(target: LogLevel) { if (this.level === 'silent') return false; // silent disables all except manual error() const levels: LogLevel[] = ['error', 'warn', 'info', 'debug']; return levels.indexOf(target) <= levels.indexOf(this.level); } log(message: string) { if (this.shouldLog('info')) console.log(message); } info(message: string) { if (this.shouldLog('info')) console.info(`${MARK_INFO} ${message}`); } warn(message: string) { if (this.shouldLog('warn')) console.warn(c.yellow(`${MARK_WARNING} ${message}`)); } error(message: string) { console.error(c.red(`${MARK_ERROR} ${message}`)); } debug(message: string) { if (this.shouldLog('debug')) console.debug(c.dim(`[debug] ${message}`)); } } export class SilentLogger implements BaseLogger { level: LogLevel = 'silent'; log() {} info() {} warn() {} error() {} debug() {} } ``` ### `packages/kubricate/src/internal/types.ts` ```ts import type { ConsoleLogger } from './logger.js'; export interface GlobalConfigOptions { /** * Working directory to load the config from. * This is the directory where the config file is located. * If not specified, the current working directory will be used. * * @default process.cwd() */ root?: string; /** * Config file name to load. * If not specified, the default config file name will be used. * * @default 'kubricate.config' */ config?: string; verbose?: boolean; silent?: boolean; /** * Enable verbose output. * This will enable debug logging and show more information in the output. * If not specified, the default log level will be used. * * @default ConsoleLogger.LogLevel.INFO */ logger?: ConsoleLogger; /** * Version of the CLI. */ version?: string; /** * Dry run mode. * If set to true, the CLI will not execute any commands, * but will print what would be done. * * @default false */ dryRun?: boolean; } ``` ### `packages/kubricate/src/internal/utils.ts` ```ts import type { BaseLogger } from '@kubricate/core'; import type { BaseStack } from '../stack/BaseStack.js'; import type { ResourceEntry } from '../stack/ResourceComposer.js'; import type { KubricateConfig } from '../types.js'; import { getMatchConfigFile } from './load-config.js'; export function getClassName(obj: unknown): string { // eslint-disable-next-line @typescript-eslint/no-explicit-any return obj && typeof obj === 'object' ? (obj as any).constructor.name : 'Unknown'; } /** * Utility functions for type validation. * * @param value - The value to check. * @param type - The expected type of the value. * @throws TypeError if the value is not of the expected type. */ export function validateString(value: unknown): asserts value is string { if (typeof value !== 'string') { throw new TypeError(`Expected a string, but received: ${typeof value}`); } } export function getStackName(stack: BaseStack): string { const stackName = stack.getName(); if (stackName) { return stackName; } return getClassName(stack); } export interface StackInfo { name: string; type: string; kinds: { id: string; kind: string; }[]; } export function extractKindFromResourceEntry(entry: ResourceEntry): string { if (entry.entryType === 'class') { return String(entry.type?.name); } if (entry.config.kind) { return entry.config.kind as string; } return 'Unknown'; } export function extractStackInfo(name: string, stack: BaseStack): StackInfo { const composer = stack.getComposer(); if (!composer) { throw new Error(`Stack ${name} does not have a composer.`); } return { name, type: getStackName(stack), kinds: Object.entries(composer._entries).map(([id, entry]) => { return { id, kind: extractKindFromResourceEntry(entry), }; }), }; } export function extractStackInfoFromConfig(config: KubricateConfig): StackInfo[] { const stacks = Object.entries(config.stacks || {}).map(([name, stack]) => extractStackInfo(name, stack)); return stacks; } // export type AllCliConfigs = Partial; export type Subcommand = 'generate' | 'secret validate' | 'secret apply'; export function verboseCliConfig(options: Record, logger: BaseLogger, subcommand?: Subcommand): void { logger.debug(`Verbose for global config: `); if (!options.config) { logger.debug(`No config file provided. Falling back to default: '${getMatchConfigFile()}'`); } else { logger.debug(`Using config file: ${options.config}`); } logger.debug(`Root directory: ${options.root}`); logger.debug(`Logger: ${getClassName(options.logger)}`); logger.debug(`Silent mode: ${options.silent}`); logger.debug(`Verbose mode: ${options.verbose}`); if (subcommand) { logger.debug(`--------------------------\n`); logger.debug(`Verbose for command specific config: `); } if (subcommand === 'generate') logger.debug(`[generate] Output directory: ${options.outDir}`); } /** * Validate Stack Id or Resource Id * @param input * @returns {string} - The sanitized string. * * @ref https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set * The limit characters for labels is 63. */ export function validateId(input: string, subject = 'id'): void { const regex = /^[a-zA-Z0-9_-]+$/; if (!regex.test(input)) { throw new Error( `Invalid ${subject} "${input}". ` + `Only letters (a-z, A-Z), numbers (0-9), hyphens (-), and underscores (_) are allowed.` ); } if (input.length > 63) { throw new Error(`Invalid ${subject} "${input}". Must not exceed 63 characters.`); } } ``` ### `packages/kubricate/src/secret/SecretInjectionBuilder.ts` ```ts import type { BaseProvider, SecretInjectionStrategy } from '@kubricate/core'; import type { BaseStack } from '../stack/BaseStack.js'; import type { FallbackIfNever } from '../types.js'; /** * Extract only the strategy types allowed for this provider */ type ExtractAllowedKinds = Extract< SecretInjectionStrategy, { kind: Kinds } >; /** * SecretInjectionBuilder provides a fluent API to define how a secret should be injected into a resource. * * @example * injector.secrets('MY_SECRET') * .inject({ kind: 'env', containerIndex: 0 }) * .intoResource('my-deployment'); // Optional */ export class SecretInjectionBuilder { private strategy?: SecretInjectionStrategy; private resourceIdOverride?: string; /** * The injected name override (used when `.forName(...)` is called). * * This will appear in the final manifest, such as an env var name or volume mount name. * If not provided, the original secretName will be used. */ private targetName?: string; constructor( private readonly stack: BaseStack, private readonly secretName: string, private readonly provider: BaseProvider, private readonly ctx: { defaultResourceId?: string; secretManagerId: number; providerId: string } ) {} /** * Override the name to be injected into the target manifest. * * This is useful when the name used inside the resource (e.g., env var name) * should differ from the registered secret name in the SecretManager. * * If not provided, the original secret name will be used. * * Example: * .secrets('MY_SECRET').forName('API_KEY').inject({ kind: 'env' }); * * Output: * - name: API_KEY * valueFrom: * secretKeyRef: * name: secret-application * key: MY_SECRET * * @param name The name to use in the final manifest (e.g., environment variable name). */ forName(name: string): this { this.targetName = name; return this; } /** * Define how this secret should be injected into the Kubernetes resource. * * 👉 You can call `.inject(strategy)` with a specific strategy, or use `.inject()` with no arguments * if the provider only supports **one** strategy kind (e.g. `'env'`). * * This method is **type-safe** and enforces allowed `kind` values per provider via TypeScript inference. * * @example * // Explicit strategy: * injector.secrets('APP_SECRET').inject('env', { containerIndex: 0 }); * * // Implicit (default strategy): * injector.secrets('APP_SECRET').inject(); // uses first provider-supported default */ inject(): this; inject( kind?: ExtractAllowedKinds['kind'], strategyOptions?: Omit, SecretInjectionStrategy>, 'kind'> ): this; inject( kind?: ExtractAllowedKinds['kind'], strategyOptions?: Omit, SecretInjectionStrategy>, 'kind'> ): this { if (kind === undefined) { // no arguments provided if (this.provider.supportedStrategies.length !== 1) { throw new Error( `[SecretInjectionBuilder] inject() requires a strategy because provider supports multiple strategies: ${this.provider.supportedStrategies.join(', ')}` ); } const defaultKind = this.provider.supportedStrategies[0]; this.strategy = this.resolveDefaultStrategy(defaultKind); } else { this.strategy = { kind, ...strategyOptions, } as SecretInjectionStrategy; } return this; } /** * Resolves a default injection strategy based on the `kind` supported by the provider. * This allows `.inject()` to be used without arguments when the provider supports exactly one kind. * * Each kind has its own defaults: * - `env` → `{ kind: 'env', containerIndex: 0 }` * - `imagePullSecret` → `{ kind: 'imagePullSecret' }` * - `annotation` → `{ kind: 'annotation' }` * * If the kind is unsupported for defaulting, an error is thrown. */ resolveDefaultStrategy(kind: SecretInjectionStrategy['kind']): SecretInjectionStrategy { let strategy: SecretInjectionStrategy; if (kind === 'env') { strategy = { kind: 'env', containerIndex: 0 }; } else if (kind === 'imagePullSecret') { strategy = { kind: 'imagePullSecret' }; } else if (kind === 'annotation') { strategy = { kind: 'annotation' }; } else { throw new Error(`[SecretInjectionBuilder] inject() with no args is not implemented for kind="${kind}" yet`); } return strategy; } /** * Explicitly define the resource ID that defined in the composer e.g. 'my-deployment', 'my-job' to inject into. */ intoResource(resourceId: string): this { this.resourceIdOverride = resourceId; return this; } /** * Resolve and register the final injection into the stack. * Should be called by SecretsInjectionContext after the injection chain ends. */ resolveInjection(): void { if (!this.strategy) { throw new Error(`No injection strategy defined for secret: ${this.secretName}`); } // resolve resourceId const resourceId = this.resolveResourceId(); // Get the target path for this injection const path = this.provider.getTargetPath(this.strategy); // Register the injection into the stack this.stack.registerSecretInjection({ provider: this.provider, providerId: this.ctx.providerId, resourceId, path, meta: { secretName: this.secretName, targetName: this.targetName ?? this.secretName, }, }); } /** * Resolve which resource ID to inject into. * Priority: .intoResource(...) > setDefaultResourceId(...) > infer from provider.targetKind */ private resolveResourceId(): string { // Determine resourceId from override if (this.resourceIdOverride) return this.resourceIdOverride; // Determine resourceId from default if (this.ctx.defaultResourceId) return this.ctx.defaultResourceId; // Auto-resolve resourceId using targetKind // This is the default behavior if no resourceId is provided const kind = this.provider.targetKind; const composer = this.stack.getComposer(); if (!composer) { throw new Error( `[SecretInjectionBuilder] No resource composer found in stack. ` + `Make sure .from(...) is called before using .useSecrets(...)` ); } const helperMessage = `Please specify a resourceId explicitly \n` + ` → Use .intoResource(...) to specify a resource ID explicitly,\n` + ` → or call setDefaultResourceId(...) in SecretsInjectionContext.`; const resourceId = composer.findResourceIdsByKind(kind); if (resourceId.length === 0) { throw new Error( `[SecretInjectionBuilder] Could not resolve resourceId from provider.targetKind="${kind}".\n` + helperMessage ); } else if (resourceId.length > 1) { throw new Error( `[SecretInjectionBuilder] Multiple resourceIds found for provider.targetKind="${kind}".\n` + helperMessage ); } return resourceId[0]; } } ``` ### `packages/kubricate/src/secret/SecretManager.ts` ```ts import type { Pipe, Tuples, Unions } from 'hotscript'; import type { BaseConnector, BaseLogger, BaseProvider, PreparedEffect, SecretValue } from '@kubricate/core'; import { validateString } from '../internal/utils.js'; import type { AnyKey, FallbackIfNever } from '../types.js'; export interface SecretManagerEffect { name: string; value: SecretValue; effects: PreparedEffect[]; } type ExtractWithDefault = Pipe]> extends undefined ? Input : Default; // type A = ExtractWithDefault<'A' | 'B', 'C'>; // C /** * SecretOptions defines the structure of a secret entry in the SecretManager. * It includes the name of the secret, the connector to use for loading it, * and the provider to use for resolving it. */ export interface SecretOptions< NewSecret extends string = string, Connector extends AnyKey = AnyKey, Provider extends AnyKey = AnyKey, > { /** * Name of the secret to be added. * This name must be unique within the SecretManager instance. */ name: NewSecret; /** * Connector instance to use for loading the secret. * If not provided, the default connector will be used. */ connector?: Connector; /** * Key of a registered provider instance. * If not provided, the default provider will be used. */ provider?: Provider; } /** * SecretManager is a type-safe registry for managing secret providers and their secrets. * * - Register provider definitions (with type-safe config). * - Register named provider instances based on those definitions. * - Add secrets referencing the registered providers. */ export class SecretManager< /** * Connector instances that have been registered. */ // eslint-disable-next-line @typescript-eslint/no-empty-object-type ConnectorInstances extends Record = {}, /** * Instances of providers that have been registered. * Keys are provider names, values are typically unique string identifiers. */ // eslint-disable-next-line @typescript-eslint/no-empty-object-type ProviderInstances extends Record = {}, /** * Secret entries added to the registry. * Keys are secret names, values are their associated string identifiers. */ SecretEntries extends Record< string, { provider: keyof ProviderInstances; } // eslint-disable-next-line @typescript-eslint/no-empty-object-type > = {}, /** * Default provider to use if no specific provider is specified. */ DefaultProvider extends AnyKey = never, > { /** * Internal runtime storage for secret values (not type-safe). * Intended for use in actual secret resolution or access. */ private _secrets: Record = {}; private _providers: Record = {}; private _connectors: Record = {}; private _defaultProvider: keyof ProviderInstances | undefined; private _defaultConnector: keyof ConnectorInstances | undefined; logger?: BaseLogger; constructor() {} /** * Registers a new provider instance using a valid provider name * * @param provider - The unique name of the provider (e.g., 'Kubernetes.Secret'). * @param instance - Configuration specific to the provider type. * @returns A SecretManager instance with the provider added. */ addProvider( provider: NewProviderKey, instance: NewProvider ) { if (this._providers[provider]) { throw new Error(`Provider ${provider} already exists`); } // Set the name of the provider instance instance.name = provider; this._providers[provider] = instance; return this as SecretManager< ConnectorInstances, ProviderInstances & Record, SecretEntries, // If the default provider is never, use the new provider key // Otherwise, use the existing default provider FallbackIfNever >; } /** * Sets the default provider for the SecretManager. * This provider will be used if no specific provider is specified when adding a secret. * * Providers support multiple instances, so this is a way to set a default. * * @param provider - The unique name of the provider (e.g., 'Kubernetes.Secret'). * @returns A SecretManager instance with the provider added. */ setDefaultProvider(provider: NewDefaultProvider) { this._defaultProvider = provider; return this as SecretManager; } /** * Adds a new connector instance using a valid connector name * * @param connector - The unique name of the connector (e.g., 'EnvConnector'). * @param instance - Configuration specific to the connector type. * @returns A SecretManager instance with the connector added. */ addConnector(connector: NewConnector, instance: BaseConnector) { if (this._connectors[connector]) { throw new Error(`Connector ${connector} already exists`); } this._connectors[connector] = instance; return this as SecretManager< ConnectorInstances & Record, ProviderInstances, SecretEntries, DefaultProvider >; } /** * Sets the default connector for the SecretManager. * This connector will be used if no specific connector is specified when adding a secret. * * Connectors support multiple instances, so this is a way to set a default. * * @param connector - The unique name of the connector (e.g., 'EnvConnector'). * @returns A SecretManager instance with the connector added. */ setDefaultConnector(connector: keyof ConnectorInstances) { this._defaultConnector = connector; return this as SecretManager; } /** * Adds a new secret to the registry and links it to an existing provider. * * @param optionsOrName - SecretOptions * @returns A new SecretManager instance with the secret added. */ addSecret( optionsOrName: NewSecret | SecretOptions ) { if (typeof optionsOrName === 'string') { if (this._secrets[optionsOrName]) { throw new Error(`Secret ${optionsOrName} already exists`); } this._secrets[optionsOrName] = { name: optionsOrName, }; } else { if (this._secrets[optionsOrName.name]) { throw new Error(`Secret ${optionsOrName.name} already exists`); } this._secrets[optionsOrName.name] = optionsOrName; } return this as SecretManager< ConnectorInstances, ProviderInstances, SecretEntries & Record< NewSecret, { provider: ExtractWithDefault; } >, DefaultProvider >; } /** * @interal Internal method to get the current secrets in the manager. * This is not intended for public use. * * @returns The current secrets in the registry. */ public getSecrets() { // Ensure that all secrets have a provider and connector set // before returning the secrets. this.build(); return this._secrets; } /** * @internal Internal method to prepare secrets for use. * This is not intended for public use. * * When a secret is added, it may not have a provider or connector set. * This method ensures that all secrets have a provider and connector set. */ private prepareSecrets() { for (const secret of Object.values(this._secrets)) { if (!secret.provider) { secret.provider = this._defaultProvider; } if (!secret.connector) { secret.connector = this._defaultConnector; } } } /** * @internal Internal method to get the current providers in the manager. * This is not intended for public use. * * @param key - The unique name of the connector (e.g., 'EnvConnector'). * @returns The connector instance associated with the given key. * @throws Error if the connector is not found. */ getConnector(key?: AnyKey): BaseConnector { validateString(key); if (!this._connectors[key]) { throw new Error(`Connector ${key} not found`); } return this._connectors[key] as BaseConnector; } /** * @internal Internal method to get the current providers in the manager. * This is not intended for public use. * * @param key - The unique name of the provider (e.g., 'Kubernetes.Secret'). * @returns The provider instance associated with the given key. * @throws Error if the provider is not found. */ getProvider(key: AnyKey | undefined): BaseProvider { validateString(key); if (!this._providers[key]) { throw new Error(`Provider ${key} not found`); } return this._providers[key] as BaseProvider; } /** * @internal Internal method to build the SecretManager. * This is not intended for public use. * * Post processing step to ensure that all secrets is ready for use. */ build() { if (Object.keys(this._connectors).length === 0) { throw new Error('No connectors registered'); } if (Object.keys(this._providers).length === 0) { throw new Error('No providers registered'); } if (Object.keys(this._secrets).length === 0) { throw new Error('No secrets registered'); } if (!this._defaultProvider && Object.keys(this._providers).length > 1) { throw new Error('No default provider set, and multiple providers registered'); } if (!this._defaultConnector && Object.keys(this._connectors).length > 1) { throw new Error('No default connector set, and multiple connectors registered'); } if (!this._defaultProvider) { this._defaultProvider = Object.keys(this._providers)[0] as keyof ProviderInstances; } if (!this._defaultConnector) { this._defaultConnector = Object.keys(this._connectors)[0] as keyof ConnectorInstances; } this.prepareSecrets(); this.logger?.debug('SecretManager[build] All secrets have a provider and connector set'); this.logger?.debug('SecretManager[build] All configurations are valid'); this.logger?.debug(`Default provider: ${String(this._defaultProvider)}`); this.logger?.debug(`Default connector: ${String(this._defaultConnector)}`); return this; } resolveProvider(provider?: AnyKey): BaseProvider { return provider ? this.getProvider(provider) : this.getProvider(this._defaultProvider); } resolveConnector(connector?: AnyKey): BaseConnector { return connector ? this.getConnector(connector) : this.getConnector(this._defaultConnector); } getConnectors() { return this._connectors; } getProviders() { return this._providers; } getDefaultProvider() { return this._defaultProvider; } getDefaultConnector() { return this._defaultConnector; } /** * @internal Internal method to get the current providers in the manager. * This is not intended for public use. * * Prepares secrets using registered connectors and providers. * This does not perform any side-effects like `kubectl`. * * @returns An array of SecretManagerEffect objects, each containing the name, value, and effects of the secret. * @throws Error if a connector or provider is not found for a secret. */ async prepare(): Promise { this.build(); const secrets = this.getSecrets(); const resolved: Record = {}; const loadedKeys = new Set(); for (const secret of Object.values(secrets)) { const connector = this.resolveConnector(secret.connector); if (!loadedKeys.has(secret.name)) { await connector.load([secret.name]); resolved[secret.name] = connector.get(secret.name); loadedKeys.add(secret.name); } } return Object.values(secrets).map(secret => { const provider = this.resolveProvider(secret.provider); return { name: secret.name, // TODO: Consider to remove the value from provider, due to only `secret apply` is using it value: resolved[secret.name], effects: provider.prepare(secret.name, resolved[secret.name]), }; }); } /** * Resolves the registered provider instance for a given secret name. * This method is used during the secret injection planning phase (e.g., `useSecrets`) * and does not resolve or load secret values. * * @param secretName - The name of the secret to resolve. * @returns The BaseProvider associated with the secret. * @throws If the secret is not registered or has no provider. */ resolveProviderFor(secretName: string): { providerInstance: BaseProvider; providerId: string; } { this.build(); const secret = this._secrets[secretName]; if (!secret) { throw new Error(`Secret "${secretName}" is not registered.`); } return { providerInstance: this.resolveProvider(secret.provider), providerId: String(secret.provider), }; } /** * Resolves the actual secret value and its associated provider for a given secret name. * This method is used at runtime when the secret is being applied (e.g., `secret apply`). * It loads the value from the appropriate connector and returns both the value and the provider. * * @param secretName - The name of the secret to resolve and load. * @returns An object containing the resolved provider and loaded secret value. * @throws If the secret is not registered or its connector/provider cannot be found. */ async resolveSecretValueForApply(secretName: string): Promise<{ provider: BaseProvider; value: SecretValue; }> { const secret = this._secrets[secretName]; if (!secret) { throw new Error(`Secret "${secretName}" is not registered.`); } const connector = this.resolveConnector(secret.connector); await connector.load([secret.name]); const value = connector.get(secret.name); const provider = this.resolveProvider(secret.provider); return { provider, value }; } } ``` ### `packages/kubricate/src/secret/SecretRegistry.ts` ```ts import { validateString } from '../internal/utils.js'; import type { AnySecretManager } from './types.js'; /** * SecretRegistry * * @description * Central registry to globally manage all declared SecretManager instances within a project. * * - Provides a single authoritative map of all available SecretManagers. * - Allows consistent conflict resolution across the entire project (not per-stack). * - Decouples SecretManager lifecycle from consumer frameworks (e.g., Stacks in Kubricate). * - Enables flexible, multi-environment setups (e.g., staging, production, DR plans). * * @remarks * - **Conflict detection** during secret orchestration (apply/plan) operates *only* at the registry level. * - **Stacks** or **consumers** simply reference SecretManagers; they are not responsible for conflict handling. * - Synthing and Kubricate treat the registry as the **single source of truth** for all secret orchestration workflows. * * --- * * # Example * * ```ts * const registry = new SecretRegistry() * .register('frontend', frontendManager) * .register('backend', backendManager); * * const manager = registry.get('frontend'); * ``` * * */ // eslint-disable-next-line @typescript-eslint/no-empty-object-type export class SecretRegistry = {}> { private registry = new Map(); /** * Register a named SecretManager into the registry. * * @param name - Unique name for the secret manager. * @param manager - SecretManager instance to register. * @returns {SecretRegistry} for chaining * * @throws {Error} if a duplicate name is registered */ add(name: Name, manager: NewSecretManager) { if (this.registry.has(name)) { throw new Error(`[SecretRegistry] Duplicate secret manager name: "${name}"`); } this.registry.set(name, manager); return this as unknown as SecretRegistry>; } /** * Retrieve a SecretManager by its registered name. * * @param name - The name of the secret manager. * @returns {SecretManager} * * @throws {Error} if the name is not found */ get(name: Name) { validateString(name); const manager = this.registry.get(name); if (!manager) { throw new Error(`[SecretRegistry] Secret manager not found for name: "${name}"`); } return manager as SecretManagerStore[Name]; } /** * Return all registered secret managers as an object. * * Used internally by the orchestrator. */ list(): Record { const result: Record = {}; for (const [name, manager] of this.registry.entries()) { result[name] = manager; } return result; } // /** // * Get the default SecretManager if exactly one is registered. // * // * @returns {SecretManager} // * // * @throws {Error} if there are zero or multiple managers // */ // getDefault(): SecretManager { // const managers = Array.from(this.registry.values()); // if (managers.length === 1) { // return managers[0]; // } // throw new Error('[SecretRegistry] Cannot resolve default manager — multiple managers are registered.'); // } } ``` ### `packages/kubricate/src/secret/SecretsInjectionContext.ts` ```ts import type { BaseProvider } from '@kubricate/core'; import type { BaseStack } from '../stack/BaseStack.js'; import { SecretInjectionBuilder } from './SecretInjectionBuilder.js'; import type { SecretManager } from './SecretManager.js'; import type { AnySecretManager, ExtractSecretManager } from './types.js'; export type ExtractProviderKeyFromSecretManager< SM extends AnySecretManager, Key extends keyof ExtractSecretManager['secretEntries'], > = ExtractSecretManager['secretEntries'][Key] extends { provider: infer P } ? P : never; export type GetProviderKindFromConnector = ProviderKey extends string ? // eslint-disable-next-line @typescript-eslint/no-explicit-any ExtractSecretManager['providerInstances'][ProviderKey] extends BaseProvider ? Instance : never : never; export type GetProviderKinds< SM extends AnySecretManager, Key extends keyof ExtractSecretManager['secretEntries'], > = GetProviderKindFromConnector>; export class SecretsInjectionContext { private defaultResourceId: string | undefined; private builders: SecretInjectionBuilder[] = []; constructor( private stack: BaseStack, private manager: SM, private secretManagerId: number ) {} /** * Set the default resourceId to use when no explicit resource is defined in a secret injection. */ setDefaultResourceId(id: string): void { this.defaultResourceId = id; } /** * Start defining how a secret will be injected into a resource. * This only resolves the provider, not the actual secret value. * * @param secretName - The name of the secret to inject. * @returns A SecretInjectionBuilder for chaining inject behavior. */ secrets< NewKey extends keyof ExtractSecretManager['secretEntries'] = keyof ExtractSecretManager['secretEntries'], ProviderKinds extends GetProviderKinds = GetProviderKinds, >(secretName: NewKey): SecretInjectionBuilder { const { providerInstance, providerId } = this.manager.resolveProviderFor(String(secretName)); const builder = new SecretInjectionBuilder(this.stack, String(secretName), providerInstance, { defaultResourceId: this.defaultResourceId, secretManagerId: this.secretManagerId, providerId, }); this.builders.push(builder); return builder as unknown as SecretInjectionBuilder; } resolveAll(): void { for (const builder of this.builders) { builder.resolveInjection(); } } } ``` ### `packages/kubricate/src/secret/index.ts` ```ts export * from './SecretManager.js'; export * from './connectors/index.js'; export * from './providers/index.js'; export * from './types.js'; export * from './orchestrator/index.js'; export * from './SecretsInjectionContext.js'; export * from './SecretRegistry.js'; ``` ### `packages/kubricate/src/secret/types.ts` ```ts import type { AnyKey } from '../types.js'; import type { ConfigConflictOptions } from './orchestrator/types.js'; import type { SecretManager } from './SecretManager.js'; import type { SecretRegistry } from './SecretRegistry.js'; /** ponents from a SecretManager instance. */ export type ExtractSecretManager = { // eslint-disable-next-line @typescript-eslint/no-explicit-any connectorInstances: Registry extends SecretManager ? LI : never; // eslint-disable-next-line @typescript-eslint/no-explicit-any providerInstances: Registry extends SecretManager ? PI : never; // eslint-disable-next-line @typescript-eslint/no-explicit-any secretEntries: Registry extends SecretManager ? SE : never; }; /** * Represents any type of SecretManager for type extraction purposes. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export type AnySecretManager = SecretManager; /** * Represents the options for environment variables in a Kubernetes deployment. */ export interface EnvOptions { /** * Environment variable name */ name: string; /** * Environment variable value */ value?: string; /** * Environment variable value from a secret */ secretRef?: EnvSecretRef; } export interface SecretManagerRegistrationOptions { /** * Using default secret manager for the SecretRegistry * * @deprecated Use `secretSpec` instead, which most support both SecretManager and SecretRegistry */ manager?: AnySecretManager; /** * Register a secret manager from a secret registry. * * @deprecated Use `secretSpec` instead, which most support both SecretManager and SecretRegistry */ registry?: SecretRegistry; /** * Register a secret manager or secret registry. */ secretSpec?: AnySecretManager | SecretRegistry; } export type ProjectSecretOptions = SecretManagerRegistrationOptions & ConfigConflictOptions; ``` ### `packages/kubricate/src/stack/BaseStack.ts` ```ts import type { BaseConnector, BaseLogger, BaseProvider, ProviderInjection } from '@kubricate/core'; import { SecretsInjectionContext } from '../secret/index.js'; import type { AnySecretManager, EnvOptions } from '../secret/types.js'; import type { AnyKey, FunctionLike, InferConfigureComposerFunc } from '../types.js'; import { ResourceComposer } from './ResourceComposer.js'; export interface UseSecretsOptions { env?: EnvOptions[]; injectes?: ProviderInjection[]; } export type SecretManagerId = number; /** * BaseStack is the base class for all stacks. * * @note BaseStack fields and methods need to be public, type inference is not working with private fields when using with `createSt` */ export abstract class BaseStack< // eslint-disable-next-line @typescript-eslint/no-explicit-any ConfigureComposerFunc extends FunctionLike = FunctionLike, SecretManager extends AnySecretManager = AnySecretManager, > { public _composer!: ReturnType; public _secretManagers: Record = {}; public _targetInjects: ProviderInjection[] = []; public readonly _defaultSecretManagerId = 'default'; public logger?: BaseLogger; /** * The name of the stack. * This is used to identify the stack, generally used with Stack. */ public _name?: string; /** * Registers a secret injection to be processed during stack build/render. */ registerSecretInjection(inject: ProviderInjection): void { this._targetInjects.push(inject); } /** * Retrieves all registered secret injections. */ getTargetInjects() { return this._targetInjects; } useSecrets( secretManager: NewSecretManager, builder: (injector: SecretsInjectionContext) => void ): this { if (!secretManager) { throw new Error(`Cannot BaseStack.useSecrets, secret manager is not provided.`); } const secretManagerNextId = Object.keys(this._secretManagers).length; this._secretManagers[secretManagerNextId] = secretManager as unknown as SecretManager; const ctx = new SecretsInjectionContext(this, secretManager, secretManagerNextId); builder(ctx); // invoke builder ctx.resolveAll(); return this; } /** * Get the secret manager instance. * @param id The ID of the secret manager. defaults to 'default'. * @returns The secret manager instance. */ getSecretManager(id: number) { if (!this._secretManagers[id]) { throw new Error( `Secret manager with ID ${id} is not defined. Make sure to use the 'useSecrets' method to define it, and call before 'from' method in the stack.` ); } return this._secretManagers[id]; } /** * Get all secret managers in the stack. * @returns The secret managers in the stack. */ getSecretManagers() { return this._secretManagers; } /** * Configure the stack with the provided data. * @param data The configuration data for the stack. * @returns The Kubricate Composer instance. */ abstract from(data: unknown): unknown; override(data: Partial>) { this._composer.override(data); return this; } /** * Build the stack and return the resources. * @returns The resources in the stack. */ build() { this.logger?.debug('BaseStack.build: Starting to build the stack.'); type InjectionKey = string; const injectGroups = new Map< InjectionKey, { providerId: string; provider: BaseProvider; resourceId: string; path: string; injects: ProviderInjection[]; } >(); for (const inject of this._targetInjects) { const key = `${inject.providerId}:${inject.resourceId}:${inject.path}`; if (!injectGroups.has(key)) { injectGroups.set(key, { providerId: inject.providerId, provider: inject.provider, resourceId: inject.resourceId, path: inject.path, injects: [], }); } injectGroups.get(key)!.injects.push(inject); } for (const { providerId, provider, resourceId, path, injects } of injectGroups.values()) { const payload = provider.getInjectionPayload(injects); this.logger?.debug(`BaseStack.build: Injecting value into resource:`); this.logger?.debug( JSON.stringify( { providerId, resourceId, path, payload, }, null, 2 ) ); this._composer.inject(resourceId, path, payload); this.logger?.debug( `BaseStack.build: Injected secrets from provider "${providerId}" into resource "${resourceId}" at path "${path}".` ); } return this._composer.build(); // for (const targetInject of this._targetInjects) { // const provider = targetInject.provider; // const injectsForProvider = this._targetInjects.filter(i => i.provider === provider); // const targetValue = provider.getInjectionPayload(injectsForProvider); // this.logger?.debug(`BaseStack.build: Injecting value: ${JSON.stringify(targetValue, null, 2)}`); // this._composer.inject(targetInject.resourceId, targetInject.path, targetValue); // this.logger?.debug(` // BaseStack.build: Injected secrets into provider "${provider.constructor.name}" with ID "${targetInject.resourceId}" at path: ${targetInject.path}.`); // } // return this._composer.build(); } public setComposer(composer: ReturnType) { this._composer = composer; } getComposer(): ReturnType | undefined { return this._composer; } /** * Get the resources from the composer. * @returns The resources from the composer. */ get resources() { return this._composer; } getName() { return this._name; } setName(name: string) { this._name = name; } /** * @internal * This method is used to inject the logger into the stack. * It is called by the orchestrator to inject the logger into all components of the stack. * * Inject a logger instance into all components of the stack e.g. secret managers, connector, providers, etc. * This is useful for logging purposes and debugging. * @param logger The logger instance to be injected. */ injectLogger(logger: BaseLogger) { this.logger = logger; if (typeof this.getSecretManagers === 'function') { const managers = this.getSecretManagers(); for (const secretManager of Object.values(managers)) { // Inject into SecretManager secretManager.logger = logger; // Inject into each connector for (const connector of Object.values(secretManager.getConnectors())) { (connector as BaseConnector).logger = logger; } // Inject into each provider for (const provider of Object.values(secretManager.getProviders())) { (provider as BaseProvider).logger = logger; } } } } } ``` ### `packages/kubricate/src/stack/ResourceComposer.ts` ```ts import type { Call, Objects } from 'hotscript'; import { cloneDeep, get, isPlainObject, merge, set } from 'lodash-es'; import { validateId } from '../internal/utils.js'; import type { AnyClass } from '../types.js'; export interface ResourceEntry { type?: AnyClass; config: Record; /** * The kind of resource. This is used to determine how to handle the resource. * - `class`: A class that will be instantiated with the config. * - `object`: An object that will be used as is. * - `instance`: An instance of a class that will be used as is. */ entryType: 'class' | 'object' | 'instance'; } // eslint-disable-next-line @typescript-eslint/no-empty-object-type export class ResourceComposer = {}> { _entries: Record = {}; _override: Record = {}; inject(resourceId: string, path: string, value: unknown) { const composed = cloneDeep(this._entries[resourceId]); if (!composed) { throw new Error(`Cannot inject, resource with ID ${resourceId} not found.`); } if (!(composed.entryType === 'object' || composed.entryType === 'class')) { throw new Error(`Cannot inject, resource with ID ${resourceId} is not an object or class.`); } const existingValue = get(composed.config, path); if (existingValue === undefined) { // No value yet — safe to set directly set(composed.config, path, value); this._entries[resourceId] = composed; return; } if (Array.isArray(existingValue) && Array.isArray(value)) { // Append array elements (e.g. env vars, volumeMounts) const mergedArray = [...existingValue, ...value]; set(composed.config, path, mergedArray); this._entries[resourceId] = composed; return; } if (isPlainObject(existingValue) && isPlainObject(value)) { // Deep merge objects const mergedObject = merge({}, existingValue, value); set(composed.config, path, mergedObject); this._entries[resourceId] = composed; return; } // Fallback: do not overwrite primitive or incompatible types throw new Error( `Cannot inject, resource "${resourceId}" already has a value at path "${path}". ` + `Existing: ${JSON.stringify(existingValue)}. New value: ${JSON.stringify(value)}` ); } build(): Record { const result: Record = {}; for (const resourceId of Object.keys(this._entries)) { validateId(resourceId, 'resourceId'); const { type, entryType: kind } = this._entries[resourceId]; const { config } = this._entries[resourceId]; if (kind === 'instance') { result[resourceId] = config; continue; } const mergedConfig = merge({}, config, this._override ? this._override[resourceId] : {}); if (kind === 'object') { result[resourceId] = mergedConfig; continue; } if (!type) continue; // Create the resource result[resourceId] = new type(mergedConfig); } return result; } /** * Add a resource to the composer, extracting the type and data from the arguments. * * @deprecated This method is deprecated and will be removed in the future. Use `addClass` instead. */ add(params: { id: Id; type: T; config: ConstructorParameters[0] }) { this._entries[params.id] = { type: params.type, config: params.config, entryType: 'class', }; return this as ResourceComposer[0]>>; } /** * Add a resource to the composer, extracting the type and data from the arguments. */ addClass(params: { id: Id; type: T; config: ConstructorParameters[0]; }) { this._entries[params.id] = { type: params.type, config: params.config, entryType: 'class', }; return this as ResourceComposer[0]>>; } /** * Add an object to the composer directly. Using this method will support overriding the resource. */ addObject(params: { id: Id; config: T }) { this._entries[params.id] = { config: params.config as Record, entryType: 'object', }; return this as ResourceComposer>; } /** * Add an instance to the composer directly. Using this method will not support overriding the resource. * * @deprecated This method is deprecated and will be removed in the future. Use `addObject` instead for supporting overrides. */ addInstance(params: { id: Id; config: T }) { this._entries[params.id] = { config: params.config as Record, entryType: 'instance', }; return this as ResourceComposer>; } public override(overrideResources: Call) { this._override = overrideResources; return this; } /** * @interal Find all resource IDs of a specific kind. * This method is useful for filtering resources based on their kind. */ findResourceIdsByKind(kind: string): string[] { const resourceIds: string[] = []; const buildResources: unknown[] = Object.values(this.build()); const entryIds = Object.keys(this._entries); for (let i = 0; i < buildResources.length; i++) { const resource = buildResources[i]; const resourceId = entryIds[i]; if ( typeof resource === 'object' && resource !== null && 'kind' in resource && typeof resource.kind === 'string' ) { if (resource.kind.toLowerCase() === kind.toLowerCase()) { resourceIds.push(resourceId); } } } return resourceIds; } } ``` ### `packages/kubricate/src/stack/Stack.ts` ```ts import type { StackTemplate } from '@kubricate/core'; import { BaseStack } from './BaseStack.js'; import { ResourceComposer } from './ResourceComposer.js'; import { buildComposerFromObject, type ResourceManifest } from './utils.js'; /** * A function that takes input data and returns a `ResourceComposer` with resource entries. * * @template Data - The input type for the stack * @template Entries - The structure of the composed resource map */ export type ConfigureComposerFunction> = ( data: Data ) => ResourceComposer; /** * Represents a runtime stack built from a pure template. * * The `Stack` class extends `BaseStack` and adds support for dynamic creation * from input data and orchestration features like secret injection and CLI deployment. * * @template Data - The input type used to build the stack * @template Entries - The resource structure returned by the builder */ export class Stack> extends BaseStack< ConfigureComposerFunction > { constructor(public builder: ConfigureComposerFunction) { super(); } /** * Converts a `StackTemplate` and input into a runtime `Stack` instance. * * This function is the bridge between static template definition (via `defineStackTemplate`) * and runtime execution. It wraps the resulting resources into a `ResourceComposer` * and binds metadata (like stack name). * * @template TInput - Input type for the stack template * @template TResourceMap - Output resource map from the stack template * * @param factory - A `StackTemplate` containing the stack's name and builder function * @param input - Input values required to create the resource map * @returns A fully-initialized `Stack` ready for use * * @example * ```ts * const appStack = Stack.fromTemplate(AppStack, { * name: 'nginx', * image: 'nginx:latest', * }); * * ``` */ static fromTemplate>( factory: StackTemplate, input: TInput ): Stack { const builder = (data: TInput) => buildComposerFromObject(factory.create(data) as Record); const stack = new Stack(builder); stack.setName(factory.name); stack.from(input); return stack; } /** * Creates a `Stack` from a plain static resource map. * * This is useful for simple, fixed configurations—like defining a namespace or * other declarative resources—without the need for a separate template or logic. * * Unlike `fromTemplate`, this method does not require an input schema * and is suited for fully static definitions. * * @template TResources - The resource structure of the static resource map * * @param name - The name of the stack (used for identification and CLI metadata) * @param resources - A plain object representing Kubernetes resources (not instances) * @returns A `Stack` instance populated with the given resources * * @example * ```ts * const stack = Stack.fromStatic('DefaultNS', { * namespace: { * metadata: { name: 'default' }, * }, * }); * * stack.deploy(); * ``` */ static fromStatic>>( name: string, resources: TResources ): Stack { const builder = () => buildComposerFromObject(resources); const stack = new Stack(builder); stack.setName(name); return stack.from(undefined) as Stack; } /** * Populates this stack instance with input data by executing its internal builder. * * This is used internally by `fromTemplate` or can be used directly when constructing * `Stack` manually via constructor (not recommended). * * @param data - Input values for the stack * @returns This stack instance */ override from(data: Data) { const composer = this.builder(data); this.setComposer(composer); return this; } } /** * Factory function to create a `Stack` instance manually. * * @deprecated Use `defineStackTemplate` together with `Stack.fromTemplate` instead. * * This method was previously used to define a stack template along with a `from` method, * but it mixes type definition with runtime logic. For better separation of concerns, * define your stack using `defineStackTemplate(...)` and then instantiate it using `Stack.fromTemplate(...)`. * * @example * ```ts * // ❌ Deprecated way * const legacyStack = createStack('MyStack', builderFn).from(input); * * // ✅ Recommended way * const MyStackTemplate = defineStackTemplate('MyStack', builderFn); * const stack = Stack.fromTemplate(MyStackTemplate, input); * ``` */ // eslint-disable-next-line @typescript-eslint/no-empty-object-type export function createStack = {}>( name: string, builder: ConfigureComposerFunction ) { return { from(data: Data) { const stack = new Stack(builder); stack.setName(name); return stack.from(data); }, }; } ``` ### `packages/kubricate/src/stack/index.ts` ```ts export * from './BaseStack.js'; export * from './ResourceComposer.js'; export * from './Stack.js'; ``` ### `packages/kubricate/src/stack/utils.ts` ```ts import { ResourceComposer } from './ResourceComposer.js'; export type ResourceManifest = Record; export function buildComposerFromObject>(resources: T): ResourceComposer { const composer = new ResourceComposer(); for (const [id, config] of Object.entries(resources)) { composer.addObject({ id, config }); } return composer; } ``` ### `packages/kubricate/src/cli-interfaces/secret/apply.ts` ```ts import type { CommandModule } from 'yargs'; import { ConfigLoader } from '../../commands/ConfigLoader.js'; import { SecretCommand, type SecretCommandOptions } from '../../commands/SecretCommand.js'; import { ExecaExecutor } from '../../executor/execa-executor.js'; import { KubectlExecutor } from '../../executor/kubectl-executor.js'; import { handlerError } from '../../internal/error.js'; import { ConsoleLogger } from '../../internal/logger.js'; import type { GlobalConfigOptions } from '../../internal/types.js'; export const secretApplyCommand: CommandModule = { command: 'apply', describe: 'Apply secrets to the target provider (e.g., kubectl)', handler: async argv => { const logger = argv.logger ?? new ConsoleLogger(); try { const executor = new KubectlExecutor('kubectl', logger, new ExecaExecutor()); const configLoader = new ConfigLoader(argv, logger); const { orchestrator } = await configLoader.initialize({ subject: 'secret apply', commandOptions: argv, }); await new SecretCommand(argv, logger, executor).apply(orchestrator); } catch (error) { handlerError(error, logger); } }, }; ``` ### `packages/kubricate/src/cli-interfaces/secret/index.ts` ```ts export * from './secret.js'; ``` ### `packages/kubricate/src/cli-interfaces/secret/secret.ts` ```ts import type { CommandModule } from 'yargs'; import { secretApplyCommand } from './apply.js'; import { secretValidateCommand } from './validate.js'; export const secretCommand: CommandModule = { command: 'secret [command]', describe: 'Manage secrets with SecretManager', builder: yargs => yargs // Add subcommands .command(secretValidateCommand) .command(secretApplyCommand) // Show help if no subcommand is provided .demandCommand(1, ''), handler: () => {}, }; ``` ### `packages/kubricate/src/cli-interfaces/secret/validate.ts` ```ts import type { CommandModule } from 'yargs'; import { ConfigLoader } from '../../commands/ConfigLoader.js'; import { SecretCommand, type SecretCommandOptions } from '../../commands/SecretCommand.js'; import { ExecaExecutor } from '../../executor/execa-executor.js'; import { KubectlExecutor } from '../../executor/kubectl-executor.js'; import { handlerError } from '../../internal/error.js'; import { ConsoleLogger } from '../../internal/logger.js'; import type { GlobalConfigOptions } from '../../internal/types.js'; export const secretValidateCommand: CommandModule = { command: 'validate', describe: 'Validate secret manager configuration', handler: async argv => { const logger = argv.logger ?? new ConsoleLogger(); try { const executor = new KubectlExecutor('kubectl', logger, new ExecaExecutor()); const configLoader = new ConfigLoader(argv, logger); const { orchestrator } = await configLoader.initialize({ subject: 'secret validate', commandOptions: argv, }); await new SecretCommand(argv, logger, executor).validate(orchestrator); } catch (error) { handlerError(error, logger); } }, }; ``` ### `packages/kubricate/src/commands/generate/GenerateCommand.ts` ```ts import path from 'node:path'; import c from 'ansis'; import { merge } from 'lodash-es'; import type { BaseLogger } from '@kubricate/core'; import { MARK_CHECK, MARK_NODE, MARK_TREE_END, MARK_TREE_LEAF } from '../../internal/constant.js'; import type { GlobalConfigOptions } from '../../internal/types.js'; import { extractStackInfoFromConfig, type StackInfo } from '../../internal/utils.js'; import type { KubricateConfig } from '../../types.js'; import { GenerateRunner, type RenderedFile } from './GenerateRunner.js'; import { Renderer } from './Renderer.js'; import type { ProjectGenerateOptions } from './types.js'; export interface GenerateCommandOptions extends GlobalConfigOptions { outDir: string; /** * Output into stdout * * When set, the generated files will be printed to stdout instead of being written to disk. */ stdout: boolean; /** * Filter stacks or resources by ID (e.g., myStack or myStack.resource) * * Empty if not specified, all stacks will be included. */ filter?: string[]; } export class GenerateCommand { constructor( protected options: GenerateCommandOptions, protected logger: BaseLogger ) {} resolveDefaultGenerateOptions(config: KubricateConfig) { const defaultOptions: Required = { outputDir: 'output', outputMode: 'stack', cleanOutputDir: true, }; const result = merge({}, defaultOptions, config.generate); return result; } async execute(config: KubricateConfig) { const logger = this.logger; const generateOptions = this.resolveDefaultGenerateOptions(config); logger.info('Generating stacks for Kubernetes...'); const renderedFiles = this.getRenderedFiles(config, generateOptions.outputMode); const runner = new GenerateRunner(this.options, generateOptions, renderedFiles, this.logger); this.showStacks(config); this.logger.log(''); await runner.run(); logger.log(c.green`${MARK_CHECK} Done!`); } getRenderedFiles(config: KubricateConfig, outputMode: ProjectGenerateOptions['outputMode']) { const renderer = new Renderer(config, this.logger); const rendered = renderer.renderStacks(config); const files: Record = {}; const renderedFiles: RenderedFile[] = []; for (const r of rendered) { const outPath = renderer.resolveOutputPath(r, outputMode, this.options.stdout); if (!files[outPath]) files[outPath] = []; files[outPath].push(r.content); } for (const [filePath, contents] of Object.entries(files)) { const relatePath = path.join(this.options.outDir, filePath); renderedFiles.push({ filePath: relatePath, originalPath: filePath, content: contents.join('\n') }); } if (this.options.filter) { return this.filterResources(renderedFiles, this.options.filter); } return renderedFiles; } filterResources(renderedFiles: RenderedFile[], filters: string[]): RenderedFile[] { if (filters.length === 0) return renderedFiles; const filterSet = new Set(filters); const matchedFilters = new Set(); const stackIds = new Set(); const fullResourceIds = new Set(); const filtered = renderedFiles.filter(file => { const originalPath = file.originalPath; // e.g., myApp.deployment const [stackId] = originalPath.split('.'); stackIds.add(stackId); fullResourceIds.add(originalPath); const matched = filterSet.has(stackId) || filterSet.has(originalPath); if (matched) { if (filterSet.has(stackId)) matchedFilters.add(stackId); if (filterSet.has(originalPath)) matchedFilters.add(originalPath); } return matched; }); const unmatchedFilters = filters.filter(f => !matchedFilters.has(f)); if (unmatchedFilters.length > 0) { const stacksList = Array.from(stackIds).sort().join('\n - '); const resourcesList = Array.from(fullResourceIds).sort().join('\n - '); const stacksSection = stackIds.size > 0 ? ` • Stacks: \n` + ` - ${stacksList}\n` : ''; const resourcesSection = fullResourceIds.size > 0 ? ` • Resources: \n` + ` - ${resourcesList}\n` : ''; throw new Error( `The following filters did not match any resource: ${unmatchedFilters.join(', ')}.\n\n` + `Available filters:\n` + stacksSection + resourcesSection + `\nPlease check your --filter values and try again.` ); } return filtered; } showStacks(config: KubricateConfig) { const logger = this.logger; const stacksLength = Object.keys(config.stacks ?? {}).length; if (!config.stacks || stacksLength === 0) { throw new Error('No stacks found in config'); } logger.info(`Found ${stacksLength} stacks in config:`); const renderListTree = (kinds: StackInfo['kinds']) => { const lastIndex = kinds.length - 1; for (let i = 0; i < kinds.length; i++) { const kind = kinds[i]; const marker = i === lastIndex ? MARK_TREE_END : MARK_TREE_LEAF; logger.log(c.blue` ${marker} ${kind.kind}` + c.dim` (id: ${kind.id})`); } }; for (const stack of extractStackInfoFromConfig(config)) { logger.log(c.blue` ${MARK_NODE} ${stack.name}` + c.dim` (type: ${stack.type})`); renderListTree(stack.kinds); logger.log(''); } } } ``` ### `packages/kubricate/src/commands/generate/GenerateRunner.ts` ```ts import fs from 'node:fs'; import path from 'node:path'; import type { BaseLogger } from '@kubricate/core'; import { MARK_BULLET, MARK_CHECK } from '../../internal/constant.js'; import type { GenerateCommandOptions } from './GenerateCommand.js'; import type { ProjectGenerateOptions } from './types.js'; export interface RenderedFile { filePath: string; originalPath: string; content: string; } export class GenerateRunner { constructor( public readonly options: GenerateCommandOptions, public readonly generateOptions: Required, private readonly renderedFiles: RenderedFile[], protected readonly logger: BaseLogger ) {} async run() { if (this.generateOptions.cleanOutputDir) { this.cleanOutputDir(path.join(this.options.root ?? '', this.generateOptions.outputDir)); } const stats = { written: 0 }; this.logger.info(`Rendering with output mode "${this.generateOptions.outputMode}"`); if (this.generateOptions.cleanOutputDir) { this.logger.info(`Cleaning output directory: ${this.options.outDir}`); } this.logger.log(`\nGenerating stacks...`); for (const file of this.renderedFiles) { this.processOutput(file, stats); } if (stats.written === 0) { this.logger.warn(`No files generated.`); return; } this.logger.log( `\n${MARK_CHECK} Generated ${stats.written} file${stats.written > 1 ? 's' : ''} into "${this.generateOptions.outputDir}/"` ); } private cleanOutputDir(dir: string) { if (fs.existsSync(dir)) { fs.rmSync(dir, { recursive: true, force: true }); } } private ensureDir(filePath: string) { const dir = path.dirname(filePath); fs.mkdirSync(dir, { recursive: true }); } private processOutput(file: RenderedFile, stats: { written: number }) { if (this.options.stdout) { console.log(file.content); return; } const outputPath = path.join(this.options.root ?? '', file.filePath); this.ensureDir(outputPath); fs.writeFileSync(outputPath, file.content); this.logger.log(`${MARK_BULLET} Written: ${outputPath}`); stats.written++; } } ``` ### `packages/kubricate/src/commands/generate/Renderer.ts` ```ts import path from 'node:path'; import c from 'ansis'; import { cloneDeep, merge } from 'lodash-es'; import { stringify as yamlStringify } from 'yaml'; import type { BaseLogger } from '@kubricate/core'; import { getClassName } from '../../internal/utils.js'; import type { KubricateConfig, ProjectMetadataOptions } from '../../types.js'; import { version } from '../../version.js'; import { MetadataInjector } from '../MetadataInjector.js'; import type { ProjectGenerateOptions } from './types.js'; export interface RenderedResource { id: string; stackId: string; stackName: string; kind: string; name: string; content: string; } const defaultMetadata: Required = { inject: true, injectManagedAt: true, injectResourceHash: true, injectVersion: true, }; interface KubernetesMetadata { kind?: string; metadata?: { name?: string }; } export class Renderer { public readonly metadata: Required; constructor( globalOptions: KubricateConfig, private readonly logger: BaseLogger ) { this.metadata = merge({}, defaultMetadata, globalOptions.metadata); } injectMetadata( resources: Record, options: { stackId?: string; stackName?: string } ): Record { const createInjector = (resourceId: string) => new MetadataInjector({ type: 'stack', kubricateVersion: version, managedAt: new Date().toISOString(), stackId: options.stackId, stackName: options.stackName, resourceId, inject: { managedAt: this.metadata.injectManagedAt, resourceHash: this.metadata.injectResourceHash, version: this.metadata.injectVersion, }, }); const output: Record = {}; for (const [resourceId, resource] of Object.entries(resources)) { const clone = cloneDeep(resource); if (clone && typeof clone !== 'object') { this.logger.warn(c.yellow('Warning: Resource is not an object, skipping metadata injection.')); continue; } const injector = createInjector(resourceId); injector.inject(clone as Record); output[resourceId] = clone; } return output; } renderStacks(config: KubricateConfig): RenderedResource[] { const output: RenderedResource[] = []; if (!config.stacks || Object.keys(config.stacks ?? {}).length === 0) { throw new Error('No stacks found in config'); } for (const [stackId, stack] of Object.entries(config.stacks)) { let builtResources: Record = {}; const stackName = stack.getName() ?? getClassName(stack) ?? 'unknown'; if (this.metadata.inject === true) { builtResources = this.injectMetadata(stack.build(), { stackId, stackName, }); } else { builtResources = stack.build(); this.logger.debug(`Warning: Metadata injection is disabled, skipping metadata injection`); } for (const [resourceId, resource] of Object.entries(builtResources as Record)) { const kind = resource?.kind || 'UnknownKind'; const name = resource?.metadata?.name || 'unnamed'; const content = yamlStringify(resource) + '---\n'; output.push({ stackName, kind, name, content, id: resourceId, stackId }); } } return output; } resolveOutputPath(resource: RenderedResource, mode: ProjectGenerateOptions['outputMode'], stdout: boolean): string { if (stdout) { // Create canonical name for the resource groped by stackId and resourceId return `${resource.stackId}.${resource.id}`; } switch (mode) { case 'flat': return 'stacks.yml'; case 'stack': return `${resource.stackId}.yml`; case 'resource': return path.join(resource.stackId, `${resource.kind}_${resource.id}.yml`); } throw new Error(`Unknown output mode: ${mode}`); } } ``` ### `packages/kubricate/src/commands/generate/index.ts` ```ts export * from './GenerateCommand.js'; export * from './types.js'; ``` ### `packages/kubricate/src/commands/generate/types.ts` ```ts export interface ProjectGenerateOptions { /** * The directory where all generated manifest files will be written. * Relative to the project root. * * @default 'output'` */ outputDir?: string; /** * Controls the structure of the generated output. * * - 'flat': All resources from all stacks in a single file (e.g. `stacks.yaml`) * - 'stack': One file per stack (e.g. `AppStack.yaml`, `CronStack.yaml`) * - 'resource': One folder per stack, each resource in its own file (e.g. `AppStack/Deployment_web.yaml`) * * @default 'stack' */ outputMode?: 'flat' | 'stack' | 'resource'; /** * If true, removes all previously generated files in the output directory before generating. * * Prevents stale or orphaned files when renaming stacks or switching output modes. * * @default true */ cleanOutputDir?: boolean; } ``` ### `packages/kubricate/src/secret/connectors/InMemoryConnector.ts` ```ts import type { BaseConnector } from '@kubricate/core'; /** * InMemoryConnector is a simple in-memory connector for secrets. * For testing purposes only. */ export class InMemoryConnector implements BaseConnector { private loaded: Set = new Set(); constructor(public config: Record) {} async load(names: string[]) { for (const name of names) { if (!(name in this.config)) throw new Error(`Missing secret: ${name}`); this.loaded.add(name); } } get(name: string): string { if (!this.loaded.has(name)) throw new Error(`Secret ${name} not loaded`); return this.config[name]; } } ``` ### `packages/kubricate/src/secret/connectors/index.ts` ```ts export * from './InMemoryConnector.js'; ``` ### `packages/kubricate/src/secret/orchestrator/SecretManagerEngine.ts` ```ts import type { PreparedEffect, SecretValue } from '@kubricate/core'; import { SecretManager, type SecretOptions } from '../SecretManager.js'; import { SecretRegistry } from '../SecretRegistry.js'; import type { SecretsOrchestratorOptions } from './types.js'; export type StackName = string; export type SecretManagerName = string; /** * MergedSecretManager maps SecretManager instances at the project level. * * For now, Kubricate supports only one SecretManager per project (via config.secrets.manager), * so this structure holds exactly one manager under the 'default' key. */ export interface MergedSecretManager { [secretManagerName: string]: { /** * The name of the secret manager. * This is used to identify the secret manager in the project. */ name: string; /** * The secret manager instance. */ secretManager: SecretManager; }; } export interface EffectsOptions { workingDir?: string; } /** * SecretManagerEngine orchestrates loading, validating, and preparing secrets * across all stacks defined in the Kubricate config. */ export class SecretManagerEngine { constructor(public readonly options: SecretsOrchestratorOptions) {} protected normalizeSecretSpec(input: SecretManager | SecretRegistry): SecretRegistry { // eslint-disable-next-line @typescript-eslint/no-explicit-any const isManager = (val: any): val is SecretManager => // Check if the value is an instance of SecretManager val instanceof SecretManager || // Check the SecretManager interface (typeof val?.getSecrets === 'function' && typeof val?.resolveProvider === 'function' && typeof val?.resolveConnector === 'function'); return isManager(input) ? new SecretRegistry().add('default', input) : input; } /** * Collect all SecretManager instances from the project config. * * @throws {Error} If both `manager` and `registry` are defined simultaneously. * @returns {MergedSecretManager} */ collect(): MergedSecretManager { const { config, logger } = this.options; logger.info('Collecting secret managers...'); const secretSpec = config.secret?.secretSpec; if (!secretSpec) { throw new Error( '[config] No secret manager or secret registry found. Please define "secret.secretSpec" in kubricate.config.ts.' ); } const result: MergedSecretManager = {}; const normalized = this.normalizeSecretSpec(secretSpec); for (const [name, manager] of Object.entries(normalized.list())) { result[name] = { name, secretManager: manager, }; } logger.debug(`Collected ${Object.keys(result).length} secret manager(s)`); return result; } /** * Validate all connectors by attempting to load secrets and resolve their values. * Will throw if any secret can't be loaded. */ async validate(managers: MergedSecretManager): Promise { const { logger } = this.options; logger.info('Validating secret managers...'); for (const entry of Object.values(managers)) { const secrets = entry.secretManager.getSecrets(); await this.loadSecrets(entry.secretManager, secrets); } logger.debug('Secret managers validated successfully'); } /** * Prepare all effects (from providers) based on loaded secret values */ async prepareEffects(managers: MergedSecretManager): Promise { const effects: PreparedEffect[] = []; for (const entry of Object.values(managers)) { const secrets = entry.secretManager.getSecrets(); const resolved = await this.loadSecrets(entry.secretManager, secrets); for (const name of Object.keys(secrets)) { const provider = entry.secretManager.resolveProvider(secrets[name].provider); effects.push(...provider.prepare(name, resolved[name])); } } return effects; } /** * Load secrets from connectors, optionally returning the loaded values. * If `returnValues` is false, this acts as a validation-only step. */ public async loadSecrets( secretManager: SecretManager, secrets: Record ): Promise> { const { effectOptions } = this.options; const resolved: Record = {}; const loaded = new Set(); for (const name of Object.keys(secrets)) { if (loaded.has(name)) continue; const connector = secretManager.resolveConnector(secrets[name].connector); // Set working directory into connector if not already set if (connector.getWorkingDir?.() === undefined && connector.setWorkingDir) { connector.setWorkingDir(effectOptions.workingDir); } // Load the secret await connector.load([name]); // Get the secret value // Throws if the secret was not previously loaded via `load()` resolved[name] = connector.get(name); loaded.add(name); } return resolved; } } ``` ### `packages/kubricate/src/secret/orchestrator/SecretsOrchestrator.ts` ```ts 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(); 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} 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 { // 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} 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 { 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 { 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(); 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 = 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}]`; }); } ``` ### `packages/kubricate/src/secret/orchestrator/index.ts` ```ts export * from './SecretsOrchestrator.js'; ``` ### `packages/kubricate/src/secret/orchestrator/types.ts` ```ts import type { BaseLogger } from '@kubricate/core'; import type { KubricateConfig } from '../../types.js'; export interface SecretsOrchestratorOptions { config: KubricateConfig; effectOptions: EffectsOptions; logger: BaseLogger; } export interface EffectsOptions { workingDir?: string; } /** * Defines how secret conflict resolution is handled at different orchestration levels. * * ⚡ Key Concept: * Synthing and Kubricate only detect conflicts at the **logical object graph** level — not runtime cluster conflicts. * * Conflict resolution occurs **before** output is materialized (e.g., Kubernetes manifests, GitHub matrices). * * --- * * 🎯 Available conflict strategies: * - `'overwrite'` — Always prefer the latest value (no error; optionally logs dropped values). * - `'error'` — Immediately throw an error on conflict (safe default for production). * - `'autoMerge'` — Shallow merge object structures if supported (fallback to latest value otherwise). * */ export type ConflictStrategy = 'overwrite' | 'error' | 'autoMerge'; /** * Defines the levels where secret conflicts can occur during orchestration. * * These keys correspond to fine-grained areas inside the secret graph. */ export type ConflictLevel = keyof NonNullable['strategies']>; /** * Full configuration for controlling **secret conflict handling** behavior. * * --- * * Important Behavior: * - **intraProvider** (default: `'autoMerge'`) allows shallow merging within the same provider resource. * - **crossProvider** (default: `'error'`) forbids silent collisions between different providers. * - **crossManager** (default: `'error'`) forbids collisions across different SecretManagers. * * Note: * - If stricter behavior is needed, `strict: true` will enforce all levels to `'error'` mode. * - Manifest-level validation (e.g., Kubernetes metadata.name conflicts) is handled separately after orchestration. * */ export interface ConfigConflictOptions { conflict?: { strategies?: { /** * Conflict resolution for multiple secrets targeting the **same provider instance**. * * Example: two environment variables injected into the same Kubernetes Secret. * * @default 'autoMerge' */ intraProvider?: ConflictStrategy; /** * Conflict resolution across **different providers inside the same SecretManager**. * * Example: collision between an EnvSecretProvider and VaultSecretProvider both trying to generate the same logical resource. * * @default 'error' */ crossProvider?: ConflictStrategy; /** * Conflict resolution across **different SecretManagers** inside the system. * * Example: frontendManager and backendManager both creating a Kubernetes Secret named 'app-credentials'. * * (This is mainly relevant for Kubricate where stacks manage multiple managers; Synthing stays framework-agnostic.) * * @default 'error' */ crossManager?: ConflictStrategy; }; /** * Enforces **strict conflict validation** globally across all levels. * * When enabled: * - All conflict levels (`intraProvider`, `crossProvider`, `crossManager`) are forcibly treated as `'error'`. * - Any attempt to relax conflict (e.g., `'autoMerge'`) will cause a configuration validation error. * * Recommended for production environments requiring strict secret isolation and no ambiguity in deployment artifacts. * * @default false */ strict?: boolean; }; } ``` ### `packages/kubricate/src/secret/providers/InMemoryProvider.ts` ```ts import type { BaseProvider, CustomEffect, PreparedEffect, ProviderInjection, SecretInjectionStrategy, SecretValue, } from '@kubricate/core'; import { createMergeHandler } from './merge-utils.js'; export interface InMemoryProviderConfig { name?: string; } type SupportedStrategies = 'env'; export class InMemoryProvider implements BaseProvider { name: string | undefined; injectes: ProviderInjection[] = []; readonly allowMerge = true; readonly secretType = 'Kubricate.InMemory'; readonly supportedStrategies: SupportedStrategies[] = ['env']; readonly targetKind = 'Deployment'; public config: InMemoryProviderConfig; constructor(config: InMemoryProviderConfig = {}) { this.config = config; } setInjects(injectes: ProviderInjection[]): void { this.injectes = injectes; } getTargetPath(strategy: SecretInjectionStrategy): string { if (strategy.kind === 'env') { const index = strategy.containerIndex ?? 0; return `spec.template.spec.containers[${index}].env`; } throw new Error(`[InMemoryProvider] Unsupported strategy: ${strategy.kind}`); } getInjectionPayload(): unknown { return this.injectes.map(inject => ({ name: inject.meta?.targetName, valueFrom: { secretKeyRef: { name: this.config.name ?? 'in-memory', key: inject.meta?.secretName, }, }, })); } getEffectIdentifier(effect: PreparedEffect): string { return effect.value?.storeName; } /** * Merge provider-level effects into final applyable resources. * Used to deduplicate (e.g. K8s secret name + ns). */ mergeSecrets(effects: PreparedEffect[]): PreparedEffect[] { const merge = createMergeHandler(); return merge(effects); } /** * Prepare the secret value for in-memory storage. */ prepare(name: string, value: SecretValue): PreparedEffect[] { return [ { secretName: name, providerName: this.name, type: 'custom', value: { storeName: this.config.name ?? 'in-memory', rawData: { [name]: value, }, }, }, ] satisfies CustomEffect[]; } } ``` ### `packages/kubricate/src/secret/providers/index.ts` ```ts export * from './InMemoryProvider.js'; ``` ### `packages/kubricate/src/secret/providers/merge-utils.ts` ```ts import type { PreparedEffect } from '@kubricate/core'; /** * Creates a reusable handler to merge multiple Raw Secret effects. * Will group by Secret `storeName` and merge `.data`. * Throws error if duplicate keys are found within the same store. */ export function createMergeHandler(): (effects: PreparedEffect[]) => PreparedEffect[] { return function mergeKubeSecrets(effects: PreparedEffect[]): PreparedEffect[] { const grouped: Record = {}; for (const effect of effects) { if (effect.type !== 'custom') continue; const name = effect.value.storeName; const key = name; if (!grouped[key]) { grouped[key] = { ...effect, value: { ...effect.value, rawData: { ...effect.value.rawData }, }, }; continue; } const existing = grouped[key]; for (const [key, value] of Object.entries(effect.value.rawData ?? {})) { if (existing.value.rawData?.[key]) { throw new Error(`[conflict:in-memory] Conflict detected: key "${key}" already exists in Secret "${name}"`); } existing.value.rawData[key] = value; } } return Object.values(grouped); }; } ```