221 lines
8.5 KiB
Text
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
|
|
}
|