344 lines
13 KiB
Text
344 lines
13 KiB
Text
|
|
# 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
|
||
|
|
}
|