# Secrets Loader - Import and merge encrypted YAML secrets into Nickel configs { # Type for a secrets configuration SecretsConfig = { # Source YAML file path (can be SOPS-encrypted) source_path | std.string | doc "Path to YAML secrets file (relative or absolute)", # Environment (dev, staging, prod) - determines which secrets file to load environment | std.string | doc "Environment: dev, staging, or prod" = "dev", # Whether to merge with defaults merge_defaults | std.bool | doc "Merge with default configuration" = true, }, # Load secrets from a YAML file # If file ends with .enc, it will be decrypted via vault-service at runtime load = fun source_path => let contents = std.string.trim ( # Import the YAML file as a string # At deployment time, this will be decrypted SOPS file std.json.stringify { path = source_path } ) in # Parse YAML as record structure # Assumes file is in format: # key1: # nested: value # key2: value contents, # Load environment-specific secrets # Tries: secrets.{env}.yaml → secrets.yaml → {} load_env = fun base_path environment => let env_path = $"($base_path)/secrets.($environment).yaml" in let default_path = $"($base_path)/secrets.yaml" in { env_specific = env_path, default = default_path, environment = environment, }, # Merge secrets into configuration template # Replaces placeholder values with actual secrets merge = fun template secrets => let replace_placeholders = fun obj => std.record.map ( fun _key value => if std.string.is_string value then # Check if value is a placeholder like "${secret:database.password}" if value |> std.string.starts_with "${secret:" then let secret_path = ( value |> std.string.drop_prefix "${secret:" |> std.string.drop_suffix "}" ) in # Navigate to secret_path in secrets record # e.g., "database.password" → secrets.database.password std.record.get_path (std.string.split "." secret_path) secrets else value else if std.record.is_record value then replace_placeholders value else value ) obj in replace_placeholders template, # Extract secrets by path pattern # Useful for selecting subset of secrets extract_by_path = fun secrets pattern => let matches_pattern = fun key => key |> std.string.contains pattern in secrets |> std.record.to_array |> std.array.filter (fun {key, _value} => matches_pattern key) |> std.array.fold_left (fun acc item => acc & { (item.key) = item.value } ) {}, # Validate secrets structure against schema validate_secrets = fun secrets required_keys => let missing = ( required_keys |> std.array.filter (fun key => not (std.record.has_field key secrets) ) ) in if std.array.length missing > 0 then { valid = false, missing_keys = missing, error = $"Missing required secret keys: {std.string.join \", \" missing}", } else { valid = true, missing_keys = [], error = null, }, # Resolve secrets from multiple sources (environment variables, files, defaults) resolve = fun secret_specs => let resolved = fun spec => let env_var = $"PROVISIONING_SECRET_{spec.key}" in { key = spec.key, source = ( if (std.string.from_env env_var | std.string.is_empty) then "file" else "env" ), value = ( std.string.from_env env_var | (fun env_val => if std.string.is_empty env_val then spec.default_value else env_val ) ), } in secret_specs |> std.array.map resolved |> std.array.fold_left (fun acc item => acc & { (item.key) = item.value } ) {}, }