provisioning-core/nulib/domain/config/interpolation/core.nu

344 lines
13 KiB
Text
Raw Normal View History

# Configuration interpolation - Substitutes variables and patterns in config
# NUSHELL 0.109 COMPLIANT - Using reduce --fold (Rule 3), do-complete (Rule 5), each (Rule 8)
# helpers/environment star-import was dead — dropped (ADR-025 Phase 3 Layer 2).
# Main interpolation entry point - interpolates all patterns in configuration
export def interpolate-config [config: record]: nothing -> record {
let base_result = (do { $config | get paths.base } | complete)
let base_path = if $base_result.exit_code == 0 { $base_result.stdout } else { "" }
if ($base_path | is-not-empty) {
# Convert config to JSON, apply all interpolations, convert back
let json_str = ($config | to json)
let interpolated_json = (interpolate-all-patterns $json_str $config)
($interpolated_json | from json)
} else {
$config
}
}
# Interpolate a single string value with configuration context
export def interpolate-string [text: string, config: record]: nothing -> string {
# Basic interpolation for {{paths.base}} pattern
if ($text | str contains "{{paths.base}}") {
let base_path = (get-config-value $config "paths.base" "")
($text | str replace --all "{{paths.base}}" $base_path)
} else {
$text
}
}
# Get a nested configuration value using dot notation
export def get-config-value [config: record, path: string, default_value: any]: nothing -> any {
let path_parts = ($path | split row ".")
# Navigate to the value using the path
let result = ($path_parts | reduce --fold $config {|part, current|
let access_result = (do { $current | get $part } | complete)
if $access_result.exit_code == 0 { $access_result.stdout } else { null }
})
if ($result | is-empty) { $default_value } else { $result }
}
# Apply all interpolation patterns to JSON string (Rule 3: using reduce --fold for sequence)
def interpolate-all-patterns [json_str: string, config: record]: nothing -> string {
# Apply each interpolation pattern in sequence using reduce --fold
# This ensures patterns are applied in order and mutations are immutable
let patterns = [
{name: "paths.base", fn: {|s, c| interpolate-base-path $s ($c | get paths.base | default "") }}
{name: "env", fn: {|s, c| interpolate-env-variables $s}}
{name: "datetime", fn: {|s, c| interpolate-datetime $s}}
{name: "git", fn: {|s, c| interpolate-git-info $s}}
{name: "sops", fn: {|s, c| interpolate-sops-config $s $c}}
{name: "providers", fn: {|s, c| interpolate-provider-refs $s $c}}
{name: "advanced", fn: {|s, c| interpolate-advanced-features $s $c}}
]
$patterns | reduce --fold $json_str {|pattern, result|
do { ($pattern.fn | call $result $config) } | complete | if $in.exit_code == 0 { $in.stdout } else { $result }
}
}
# Interpolate base path pattern
def interpolate-base-path [text: string, base_path: string]: nothing -> string {
if ($text | str contains "{{paths.base}}") {
($text | str replace --all "{{paths.base}}" $base_path)
} else {
$text
}
}
# Interpolate environment variables with security validation (Rule 8: using reduce --fold)
def interpolate-env-variables [text: string]: nothing -> string {
# Safe environment variables list (security allowlist)
let safe_env_vars = [
"HOME" "USER" "HOSTNAME" "PWD" "SHELL"
"PROVISIONING" "PROVISIONING_WORKSPACE_PATH" "PROVISIONING_INFRA_PATH"
"PROVISIONING_SOPS" "PROVISIONING_KAGE"
]
# Apply each env var substitution using reduce --fold (Rule 3: no mutable variables)
let with_env = ($safe_env_vars | reduce --fold $text {|env_var, result|
let pattern = $"\\{\\{env\\.($env_var)\\}\\}"
let env_result = (do { $env | get $env_var } | complete)
let env_value = if $env_result.exit_code == 0 { $env_result.stdout } else { "" }
if ($env_value | is-not-empty) {
($result | str replace --regex $pattern $env_value)
} else {
$result
}
})
# Handle conditional environment variables
interpolate-conditional-env $with_env
}
# Handle conditional environment variable interpolation
def interpolate-conditional-env [text: string]: nothing -> string {
let conditionals = [
{pattern: "{{env.HOME || \"/tmp\"}}", value: {|| ($env.HOME? | default "/tmp")}}
{pattern: "{{env.USER || \"unknown\"}}", value: {|| ($env.USER? | default "unknown")}}
]
$conditionals | reduce --fold $text {|cond, result|
if ($result | str contains $cond.pattern) {
let value = (($cond.value | call))
($result | str replace --all $cond.pattern $value)
} else {
$result
}
}
}
# Interpolate date and time values
def interpolate-datetime [text: string]: nothing -> string {
let current_date = (date now | format date "%Y-%m-%d")
let current_timestamp = (date now | format date "%s")
let iso_timestamp = (date now | format date "%Y-%m-%dT%H:%M:%SZ")
let with_date = ($text | str replace --all "{{now.date}}" $current_date)
let with_timestamp = ($with_date | str replace --all "{{now.timestamp}}" $current_timestamp)
($with_timestamp | str replace --all "{{now.iso}}" $iso_timestamp)
}
# Interpolate git information (defaults to "unknown" to avoid hanging)
def interpolate-git-info [text: string]: nothing -> string {
let patterns = [
{pattern: "{{git.branch}}", value: "unknown"}
{pattern: "{{git.commit}}", value: "unknown"}
{pattern: "{{git.origin}}", value: "unknown"}
]
$patterns | reduce --fold $text {|p, result|
($result | str replace --all $p.pattern $p.value)
}
}
# Interpolate SOPS configuration references
def interpolate-sops-config [text: string, config: record]: nothing -> string {
let sops_key_result = (do { $config | get sops.age_key_file } | complete)
let sops_key_file = if $sops_key_result.exit_code == 0 { $sops_key_result.stdout } else { "" }
let with_key = if ($sops_key_file | is-not-empty) {
($text | str replace --all "{{sops.key_file}}" $sops_key_file)
} else {
$text
}
let sops_cfg_result = (do { $config | get sops.config_path } | complete)
let sops_config_path = if $sops_cfg_result.exit_code == 0 { $sops_cfg_result.stdout } else { "" }
if ($sops_config_path | is-not-empty) {
($with_key | str replace --all "{{sops.config_path}}" $sops_config_path)
} else {
$with_key
}
}
# Interpolate cross-section provider references
def interpolate-provider-refs [text: string, config: record]: nothing -> string {
let providers_to_check = [
{pattern: "{{providers.aws.region}}", path: "providers.aws.region"}
{pattern: "{{providers.default}}", path: "providers.default"}
{pattern: "{{providers.upcloud.zone}}", path: "providers.upcloud.zone"}
]
$providers_to_check | reduce --fold $text {|prov, result|
let value_result = (do {
let parts = ($prov.path | split row ".")
if ($parts | length) == 2 {
$config | get ($parts | first) | get ($parts | last)
} else {
$config | get ($parts | first) | get ($parts | get 1) | get ($parts | last)
}
} | complete)
let value = if $value_result.exit_code == 0 { $value_result.stdout } else { "" }
if ($value | is-not-empty) {
($result | str replace --all $prov.pattern $value)
} else {
$result
}
}
}
# Interpolate advanced features (function calls, environment-aware paths)
def interpolate-advanced-features [text: string, config: record]: nothing -> string {
let base_path_result = (do { $config | get paths.base } | complete)
let base_path = if $base_path_result.exit_code == 0 { $base_path_result.stdout } else { "" }
let with_path_join = if ($text | str contains "{{path.join(paths.base") {
# Simple regex-based path.join replacement
($text | str replace --regex "\\{\\{path\\.join\\(paths\\.base,\\s*\"([^\"]+)\"\\)\\}\\}" $"($base_path)/$1")
} else {
$text
}
# Replace environment-aware paths
let current_env_result = (do { $config | get current_environment } | complete)
let current_env = if $current_env_result.exit_code == 0 { $current_env_result.stdout } else { "dev" }
($with_path_join | str replace --all "{{paths.base.\${env}}}" $"{{paths.base}}.($current_env)")
}
# Validate interpolation patterns and detect issues
export def validate-interpolation [
config: record
--detailed = false
]: nothing -> record {
let json_str = ($config | to json)
# Check for unresolved interpolation patterns
let unresolved = (detect-unresolved-patterns $json_str)
let unresolved_errors = if ($unresolved | length) > 0 {
[{
type: "unresolved_interpolation",
severity: "error",
patterns: $unresolved,
message: $"Unresolved interpolation patterns found: ($unresolved | str join ', ')"
}]
} else {
[]
}
# Check for circular dependencies
let circular = (detect-circular-dependencies $json_str)
let circular_errors = if ($circular | length) > 0 {
[{
type: "circular_dependency",
severity: "error",
dependencies: $circular,
message: $"Circular interpolation dependencies detected"
}]
} else {
[]
}
# Check for unsafe environment variable access
let unsafe = (detect-unsafe-env-patterns $json_str)
let unsafe_warnings = if ($unsafe | length) > 0 {
[{
type: "unsafe_env_access",
severity: "warning",
variables: $unsafe,
message: $"Potentially unsafe environment variable access"
}]
} else {
[]
}
# Validate git context if needed
let git_warnings = if ($json_str | str contains "{{git.") {
let git_check = (do { ^git rev-parse --git-dir err> /dev/null } | complete)
if ($git_check.exit_code != 0) {
[{
type: "git_context",
severity: "warning",
message: "Git interpolation patterns found but not in a git repository"
}]
} else {
[]
}
} else {
[]
}
# Combine all results
let all_errors = ($unresolved_errors | append $circular_errors)
let all_warnings = ($unsafe_warnings | append $git_warnings)
if (not $detailed) and (($all_errors | length) > 0) {
let error_messages = ($all_errors | each { |err| $err.message })
error make {msg: ($error_messages | str join "; ")}
}
{
valid: (($all_errors | length) == 0),
errors: $all_errors,
warnings: $all_warnings,
summary: {
total_errors: ($all_errors | length),
total_warnings: ($all_warnings | length),
interpolation_patterns_detected: (count-interpolation-patterns $json_str)
}
}
}
# Detect unresolved interpolation patterns
def detect-unresolved-patterns [text: string]: nothing -> list {
# Known patterns that should be handled
let known_prefixes = ["paths" "env" "now" "git" "sops" "providers" "path"]
# Extract all {{...}} patterns and check if they match known types
let all_patterns = (do {
$text | str replace --regex "\\{\\{([^}]+)\\}\\}" "$1"
} | complete)
if ($all_patterns.exit_code != 0) {
return []
}
# Check for unknown patterns (simplified detection)
if ($text | str contains "{{unknown.") {
["unknown.*"]
} else {
[]
}
}
# Detect circular interpolation dependencies
def detect-circular-dependencies [text: string]: nothing -> list {
if (($text | str contains "{{paths.base}}") and ($text | str contains "paths.base.*{{paths.base}}")) {
["paths.base -> paths.base"]
} else {
[]
}
}
# Detect unsafe environment variable patterns
def detect-unsafe-env-patterns [text: string]: nothing -> list {
let dangerous_patterns = ["PATH" "LD_LIBRARY_PATH" "PYTHONPATH" "SHELL" "PS1"]
# Use reduce --fold to find all unsafe patterns (Rule 3)
$dangerous_patterns | reduce --fold [] {|pattern, unsafe_list|
if ($text | str contains $"{{env.($pattern)}}") {
($unsafe_list | append $pattern)
} else {
$unsafe_list
}
}
}
# Count interpolation patterns in text for metrics
def count-interpolation-patterns [text: string]: nothing -> number {
# Count {{...}} occurrences
($text | str replace --all --regex "\\{\\{[^}]+\\}\\}" "" | length) - ($text | length)
| math abs
| ($text | length) - .
| . / 4 # Approximate based on {{ }} length
}