#!/usr/bin/env nu # Context assembler for federated integration modes. # Reads a cabling.ncl file, resolves each binding to its plaintext value, # and groups results by domain for delivery to mode binaries. use primitives/io/logging.nu [log-debug log-error log-info] # Resolve one binding record to {value, source}. def resolve-binding [ resolver: record ws_root: string ncl_import: string ]: nothing -> record { match $resolver.kind { "sops" => { let abs_path = if ($resolver.path | str starts-with "/") { $resolver.path } else { $ws_root | path join $resolver.path } let r = (do { ^sops --decrypt $abs_path } | complete) if $r.exit_code != 0 { error make --unspanned { msg: $"sops decrypt failed ($abs_path): ($r.stderr)" } } let data = if ($abs_path | str ends-with ".yaml") or ($abs_path | str ends-with ".yml") { $r.stdout | from yaml } else { $r.stdout | from json } let val = ($data | get -o $resolver.key | default "" | into string) { value: $val, source: "sops" } } "component" => { # field is a dot-separated cell path into the component's evaluated output let comp_path = (infer-components-dir $ws_root) | path join $"($resolver.name).ncl" if not ($comp_path | path exists) { error make --unspanned { msg: $"Component NCL not found: ($comp_path)" } } let r = (do { ^nickel export --import-path $ncl_import $comp_path } | complete) if $r.exit_code != 0 { error make --unspanned { msg: $"nickel export failed ($comp_path): ($r.stderr)" } } let val = ($r.stdout | from json | get -o $resolver.field | default "" | into string) { value: $val, source: "component_output" } } "literal" => { { value: ($resolver.value | into string), source: "literal" } } "env" => { let val = ($env | get -o $resolver.env_var | default "" | into string) if ($val | is-empty) { log-error $"env resolver: ($resolver.env_var) is not set" } { value: $val, source: "env" } } _ => { error make --unspanned { msg: $"Unknown resolver kind: ($resolver.kind)" } } } } # Derive the components directory from the workspace root. # Convention: /infra//components/ when infra/ is present, # otherwise /components/. def infer-components-dir [ws_root: string]: nothing -> string { let infra = ($ws_root | path join "infra") if ($infra | path exists) { let candidates = (ls $infra | where type == "dir" | get name) if ($candidates | is-empty) { $ws_root | path join "components" } else { $candidates | first | path join "components" } } else { $ws_root | path join "components" } } # Assemble context from a cabling file. # # Returns: # { mode_id, workspace, domains: { : { : { value, source } } } } # # domain-id and field come from splitting the binding key on the first ".". # SOPS paths in the cabling are resolved relative to --workspace-root (default: $env.PWD). # Component paths are derived from the cabling file location. export def assemble-context [ cabling_path: string --workspace-root: string = "" --ncl-import: string = "" ]: nothing -> record { let import_path = if ($ncl_import | is-not-empty) { $ncl_import } else { $env.PROVISIONING? | default "/opt/provisioning" } let ws_root = if ($workspace_root | is-not-empty) { $workspace_root } else { $env.PWD } let r = (do { ^nickel export --import-path $import_path $cabling_path } | complete) if $r.exit_code != 0 { error make --unspanned { msg: $"Failed to evaluate cabling ($cabling_path): ($r.stderr)" } } let cabling = ($r.stdout | from json | get config) mut domains = {} for entry in ($cabling.bindings | transpose key resolver) { let domain_id = ($entry.key | split row "." | first) let field_name = ($entry.key | split row "." | skip 1 | str join ".") let resolved = (resolve-binding $entry.resolver $ws_root $import_path) let existing = ($domains | get -o $domain_id | default {}) $domains = ($domains | upsert $domain_id ($existing | upsert $field_name $resolved)) } { mode_id: $cabling.mode_id, workspace: $cabling.workspace, domains: $domains, } } # Produce _credentials.env content from a cabling file for component bundle injection. # # For each SOPS binding: writes KEY='decrypted-value' (KEY = resolver.key) # For each literal: writes KEY='value' (KEY = DOMAIN_FIELD uppercased) # env bindings are skipped — those vars are already present in the environment. # component bindings are skipped — not applicable to shell env files. # # Caller is responsible for setting SOPS_AGE_KEY_FILE in the environment # (or passing --kage-path so the function sets it via with-env). export def build-cabling-credentials-env [ cabling_path: string --workspace-root: string = "" --ncl-import: string = "" --kage-path: string = "" ]: nothing -> string { let import_path = if ($ncl_import | is-not-empty) { $ncl_import } else { $env.PROVISIONING? | default "/opt/provisioning" } let ws_root = if ($workspace_root | is-not-empty) { $workspace_root } else { $env.PWD } let r = (do { ^nickel export --import-path $import_path $cabling_path } | complete) if $r.exit_code != 0 { error make --unspanned { msg: $"build-cabling-credentials-env: failed to evaluate cabling ($cabling_path): ($r.stderr)" } } let cabling = ($r.stdout | from json | get config) mut lines: list = [] for entry in ($cabling.bindings | transpose key resolver) { let domain_id = ($entry.key | split row "." | first) let field_name = ($entry.key | split row "." | skip 1 | str join ".") let resolver = $entry.resolver match $resolver.kind { "sops" => { let abs_path = if ($resolver.path | str starts-with "/") { $resolver.path } else { $ws_root | path join $resolver.path } let dec = if ($kage_path | is-not-empty) { do { with-env { SOPS_AGE_KEY_FILE: $kage_path } { ^sops --decrypt --output-type yaml $abs_path } } | complete } else { do { ^sops --decrypt --output-type yaml $abs_path } | complete } if $dec.exit_code != 0 { error make --unspanned { msg: $"build-cabling-credentials-env: sops decrypt failed ($abs_path): ($dec.stderr)" } } let data = ($dec.stdout | from yaml) let val = ($data | get -o $resolver.key | default "" | into string) let quoted = ($val | str replace --all "'" "'\''") let var_name = ($resolver | get -o env_var | default $resolver.key) $lines = ($lines | append $"($var_name)='($quoted)'") } "literal" => { let env_key = $"($domain_id | str upcase)_($field_name | str upcase | str replace --all '.' '_')" let val = ($resolver.value | into string) let quoted = ($val | str replace --all "'" "'\''") $lines = ($lines | append $"($env_key)='($quoted)'") } _ => {} } } $lines | str join "\n" } # Build a SecretDeliveryContext JSON string from an assembled context record. # Filters to the "secret-delivery" domain bindings. export def build-secret-delivery-context [ctx: record]: nothing -> string { let sd = ($ctx.domains | get -o "secret-delivery" | default {}) let secrets = ($sd | transpose key entry | reduce --fold {} { |row, acc| $acc | upsert $row.key { value: $row.entry.value, source: $row.entry.source, } }) { schema_version: "0.1.0", workspace: $ctx.workspace, mode_id: $ctx.mode_id, secrets: $secrets, metadata: { delivered_at: (date now | format date "%Y-%m-%dT%H:%M:%SZ"), }, } | to json --indent 2 }