provisioning-core/nulib/platform/integrations/context_assembler.nu

221 lines
8.5 KiB
Text

#!/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: <ws_root>/infra/<workspace>/components/ when infra/ is present,
# otherwise <ws_root>/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: { <domain-id>: { <field>: { 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<string> = []
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
}