94 lines
3.5 KiB
Text
94 lines
3.5 KiB
Text
|
|
# schemas/lib/integration/cabling.ncl
|
||
|
|
#
|
||
|
|
# Cabling format: per-workspace, per-mode binding file that resolves
|
||
|
|
# domain context fields to their concrete sources.
|
||
|
|
#
|
||
|
|
# Location: infra/<ws>/integrations/<mode-id>.ncl
|
||
|
|
#
|
||
|
|
# Each entry in `bindings` maps a dotted domain field path
|
||
|
|
# (e.g. "secret-delivery.registry_password") to a Resolver record.
|
||
|
|
#
|
||
|
|
# Resolver kinds (discriminated by the `kind` field):
|
||
|
|
# "sops" — decrypt field from a SOPS-encrypted file
|
||
|
|
# "component" — read field from a component's output record
|
||
|
|
# "literal" — static hardcoded value
|
||
|
|
# "env" — read from an environment variable at assembly time
|
||
|
|
#
|
||
|
|
# Usage:
|
||
|
|
# "secret-delivery.registry_password" = { kind = "sops", path = "secrets/zot.sops.yaml", key = "ZOT_HTPASSWD" },
|
||
|
|
# "secret-delivery.registry_url" = { kind = "component", name = "zot", field = "registry_url" },
|
||
|
|
# "event-emission.subject_prefix" = { kind = "literal", value = "ws.libre-wuji.build.lian-build" },
|
||
|
|
# "compute.api_key" = { kind = "env", env_var = "HETZNER_API_KEY" },
|
||
|
|
|
||
|
|
let _valid_kinds = [| 'sops, 'component, 'literal, 'env |] in
|
||
|
|
|
||
|
|
let _Resolver =
|
||
|
|
std.contract.custom (fun label value =>
|
||
|
|
if !std.is_record value then
|
||
|
|
std.contract.blame_with_message "Resolver must be a record with a 'kind' field" label
|
||
|
|
else if !std.record.has_field "kind" value then
|
||
|
|
std.contract.blame_with_message "Resolver missing required field 'kind'" label
|
||
|
|
else
|
||
|
|
match {
|
||
|
|
"sops" =>
|
||
|
|
if std.record.has_field "path" value && std.record.has_field "key" value then
|
||
|
|
'Ok value
|
||
|
|
else
|
||
|
|
std.contract.blame_with_message
|
||
|
|
"Resolver kind='sops' requires fields: path (String), key (String)"
|
||
|
|
label,
|
||
|
|
"component" =>
|
||
|
|
if std.record.has_field "name" value && std.record.has_field "field" value then
|
||
|
|
'Ok value
|
||
|
|
else
|
||
|
|
std.contract.blame_with_message
|
||
|
|
"Resolver kind='component' requires fields: name (String), field (String)"
|
||
|
|
label,
|
||
|
|
"literal" =>
|
||
|
|
if std.record.has_field "value" value then
|
||
|
|
'Ok value
|
||
|
|
else
|
||
|
|
std.contract.blame_with_message
|
||
|
|
"Resolver kind='literal' requires field: value (any)"
|
||
|
|
label,
|
||
|
|
"env" =>
|
||
|
|
if std.record.has_field "env_var" value then
|
||
|
|
'Ok value
|
||
|
|
else
|
||
|
|
std.contract.blame_with_message
|
||
|
|
"Resolver kind='env' requires field: env_var (String)"
|
||
|
|
label,
|
||
|
|
_ =>
|
||
|
|
std.contract.blame_with_message
|
||
|
|
"Unknown Resolver kind '%{value.kind}'. Valid: sops, component, literal, env"
|
||
|
|
label,
|
||
|
|
} value.kind
|
||
|
|
) in
|
||
|
|
|
||
|
|
# Base shape for structural validation before cross-field checks.
|
||
|
|
let _CablingBase = {
|
||
|
|
mode_id | String
|
||
|
|
| doc "Integration mode id — e.g. 'lian-build-provisioning'",
|
||
|
|
workspace | String
|
||
|
|
| doc "Workspace identifier — e.g. 'libre-wuji'",
|
||
|
|
bindings | { _ | _Resolver }
|
||
|
|
| doc "Map of '<domain-id>.<field>' to a Resolver",
|
||
|
|
} in
|
||
|
|
|
||
|
|
# Full Cabling contract: structural + non-empty bindings.
|
||
|
|
let _Cabling =
|
||
|
|
std.contract.custom (fun label value =>
|
||
|
|
let validated = value | _CablingBase in
|
||
|
|
if std.record.length validated.bindings == 0 then
|
||
|
|
std.contract.blame_with_message
|
||
|
|
"Cabling '%{validated.mode_id}': bindings must be non-empty"
|
||
|
|
label
|
||
|
|
else
|
||
|
|
'Ok validated
|
||
|
|
) in
|
||
|
|
|
||
|
|
{
|
||
|
|
Resolver = _Resolver,
|
||
|
|
Cabling = _Cabling,
|
||
|
|
}
|