provisioning-core/nulib/platform/oci/domain_client.nu

604 lines
25 KiB
Text
Raw Normal View History

#!/usr/bin/env nu
# Domain artifact OCI operations via oras subprocess.
# All push/pull/describe/verify operations use oras-cli; no curl fallback.
# Cache root: ~/.cache/ontoref/domains/<id>/<version>/
#
# ADR-017 — every oras invocation runs with an isolated DOCKER_CONFIG resolved
# via platform/oci/credentials.nu::resolve-registry-credential. No env-var
# fallback to ~/.docker/config.json. Authorization is gated through
# assert-actor-authorized before any push/pull touches the registry.
use primitives/io/logging.nu [log-debug log-error log-info]
use platform/oci/credentials.nu [
resolve-registry-credential
assert-actor-authorized
assert-target-in-scope
]
const DOMAIN_MEDIA_TYPE = "application/vnd.ontoref.domain.v1"
const CONTRACT_MEDIA_TYPE = "application/vnd.ontoref.domain.contract.v1"
const EXAMPLE_MEDIA_TYPE = "application/vnd.ontoref.domain.example.v1"
const MODE_MEDIA_TYPE = "application/vnd.ontoref.mode.v1"
const MODE_DECL_MEDIA_TYPE = "application/vnd.ontoref.mode.declaration.v1"
const MODE_LOCK_MEDIA_TYPE = "application/vnd.ontoref.mode.lock.v1"
const DEFAULT_REGISTRY = "reg.librecloud.online"
const CACHE_BASE = ".cache/ontoref/domains"
const MODE_CACHE_BASE = ".cache/ontoref/modes"
const GLOBAL_CONFIG_REL = ".config/ontoref/config.ncl"
def domain-cache-dir [participant: string, id: string, version: string]: nothing -> string {
$env.HOME | path join $CACHE_BASE $participant $id $version
}
def domain-ref [registry: string, participant: string, id: string, version: string]: nothing -> string {
$"($registry)/domains/($participant)/($id):($version)"
}
def require-oras []: nothing -> string {
let rows = (which oras)
if ($rows | is-empty) {
error make --unspanned { msg: "oras not found in PATH — install via: brew install oras" }
}
$rows | get path | first | into string
}
# Resolve the cosign public key path used to verify domain artifact signatures.
# Resolution order (declarative, ADR-017):
# 1. --cosign-key explicit flag (caller-provided)
# 2. ~/.config/ontoref/config.ncl::vault.cosign.pub_path (global declarative)
# 3. $PROVISIONING/catalog/domains/_signing/cosign.pub (legacy catalog signing key)
# Empty string when none resolves — caller decides whether unsigned-permitted.
def find-cosign-pubkey [explicit_key: string]: nothing -> string {
if ($explicit_key | is-not-empty) {
return $explicit_key
}
let global_cfg = ($env.HOME | path join $GLOBAL_CONFIG_REL)
if ($global_cfg | path exists) {
let import_path = ($env.NICKEL_IMPORT_PATH? | default ($env.PROVISIONING? | default ""))
let r = (do { ^nickel export --import-path $import_path $global_cfg } | complete)
if $r.exit_code == 0 {
let from_cfg = ($r.stdout | from json | get -o vault.cosign.pub_path | default "")
if ($from_cfg | is-not-empty) and ($from_cfg | path exists) {
return $from_cfg
}
}
}
let prov_root = ($env.PROVISIONING? | default "")
let from_prov = ($env.PROVISIONING_CATALOG_PATH? | default (if ($prov_root | is-not-empty) { $prov_root | path join "catalog" } else { "" }))
if ($from_prov | is-not-empty) {
let catalog_key = ($from_prov | path join "domains/_signing/cosign.pub")
if ($catalog_key | path exists) {
return $catalog_key
}
}
""
}
def cosign-verify-signature [ref: string, pubkey_path: string]: nothing -> record {
let rows = (which cosign)
if ($rows | is-empty) {
return { ok: false, msg: "cosign not in PATH — signature not checked" }
}
if not ($pubkey_path | path exists) {
return { ok: false, msg: $"cosign pubkey not found: ($pubkey_path)" }
}
# Read tlog policy from global config — symmetric with sign side.
# tlog=false → cosign verify needs --insecure-ignore-tlog (no Rekor entry expected).
let global_cfg = ($env.HOME | path join $GLOBAL_CONFIG_REL)
mut tlog_flag = ""
if ($global_cfg | path exists) {
let import_path = ($env.NICKEL_IMPORT_PATH? | default ($env.PROVISIONING? | default ""))
let r = (do { ^nickel export --import-path $import_path $global_cfg } | complete)
if $r.exit_code == 0 {
let tlog = ($r.stdout | from json | get -o vault.cosign.tlog | default false)
if not $tlog { $tlog_flag = "--insecure-ignore-tlog" }
}
}
let flag = $tlog_flag # immutable copy for closure capture
let result = if ($flag | is-empty) {
do { ^cosign verify --key $pubkey_path $ref } | complete
} else {
do { ^cosign verify $flag --key $pubkey_path $ref } | complete
}
if $result.exit_code == 0 {
{ ok: true, msg: "signature verified" }
} else {
let msg = ($result.stderr | str trim | lines | last | default "verification failed")
{ ok: false, msg: $msg }
}
}
# Push a domain artifact directory to the registry.
# Directory must contain contract.ncl; example.json is optional.
# ref format: <registry>/domains/<participant>/<id>:<version>
# If --ref is omitted, reads id/version from manifest.ncl; participant must be supplied.
# Endpoint resolves via sentinel pattern: --registry "" → resolve-registry.
# Credentials resolve from manifest's registry_provides[--registry-id] (default "primary").
export def domain-push [
path: string
participant: string
--ref: string = ""
--registry: string = "" # sentinel — empty resolves via resolve-registry
--registry-id: string = "" # empty = use registries.default from manifest # which RegistryEntry to use for credentials
]: nothing -> record {
let oras = (require-oras)
let project_root = ($env.ONTOREF_PROJECT_ROOT? | default $env.PWD)
let reg = if ($registry | is-empty) { resolve-registry } else { $registry }
let resolved_ref = if ($ref | is-not-empty) {
$ref
} else {
let manifest_path = ($path | path join "manifest.ncl")
if not ($manifest_path | path exists) {
error make --unspanned { msg: $"No manifest.ncl in ($path) and --ref not provided" }
}
let import_path = ($env.NICKEL_IMPORT_PATH? | default ($env.PROVISIONING? | default ""))
let ncl = (do { ^nickel export --import-path $import_path $manifest_path } | complete)
if $ncl.exit_code != 0 {
error make --unspanned { msg: $"Failed to evaluate manifest.ncl: ($ncl.stderr)" }
}
let artifact = ($ncl.stdout | from json | get artifact)
domain-ref $reg $participant $artifact.id $artifact.version
}
let contract = ($path | path join "contract.ncl")
if not ($contract | path exists) {
error make --unspanned { msg: $"Domain directory ($path) missing required contract.ncl" }
}
# Build layer list; copy to immutable before capture in do{} below.
mut layer_list = [$"contract.ncl:($CONTRACT_MEDIA_TYPE)"]
let example = ($path | path join "example.json")
if ($example | path exists) {
$layer_list = ($layer_list | append $"example.json:($EXAMPLE_MEDIA_TYPE)")
}
let layers = $layer_list # immutable copy for closure capture
# ADR-017: gate by actor scope + namespace, then resolve isolated DOCKER_CONFIG.
assert-actor-authorized $project_root push
assert-target-in-scope $project_root $"domains/($participant)/"
let cred = (resolve-registry-credential $project_root $registry_id --op push)
log-info $"Pushing domain artifact → ($resolved_ref)"
let result = (with-env { DOCKER_CONFIG: $cred.docker_config_dir } {
do { cd $path; ^$oras push $resolved_ref --artifact-type $DOMAIN_MEDIA_TYPE ...$layers } | complete
})
rm --recursive --force $cred.docker_config_dir
if $result.exit_code != 0 {
error make --unspanned { msg: $"oras push failed:\n($result.stderr)" }
}
let all_output = $"($result.stdout)\n($result.stderr)"
let digest_lines = ($all_output | lines | where { |l| $l | str contains "Digest:" })
let digest = if ($digest_lines | is-empty) {
""
} else {
$digest_lines | first | str replace --regex '.*Digest:\s*' '' | str trim
}
{ ref: $resolved_ref, digest: $digest, status: "pushed" }
}
# Pull a domain artifact into the local cache.
# Returns the cache directory path.
export def domain-pull [
participant: string
id: string
version: string
--registry: string = ""
--registry-id: string = "" # empty = use registries.default from manifest
]: nothing -> string {
let oras = (require-oras)
let project_root = ($env.ONTOREF_PROJECT_ROOT? | default $env.PWD)
let reg = if ($registry | is-empty) { resolve-registry } else { $registry }
let ref = (domain-ref $reg $participant $id $version)
let cache = (domain-cache-dir $participant $id $version)
# Authorize FIRST — cache hits without auth check would bypass scope enforcement.
# The cache is convenience for the registry round-trip, not a security boundary.
assert-actor-authorized $project_root pull
assert-target-in-scope $project_root $"domains/($participant)/"
let cached_manifest = ($cache | path join "oci-manifest.json")
if ($cache | path exists) and ($cached_manifest | path exists) {
log-debug $"Cache hit for ($id):($version) at ($cache)"
return $cache
}
let cred = (resolve-registry-credential $project_root $registry_id --op pull)
mkdir $cache
log-info $"Pulling ($ref) → ($cache)"
let result = (with-env { DOCKER_CONFIG: $cred.docker_config_dir } {
do { ^$oras pull $ref --output $cache } | complete
})
if $result.exit_code != 0 {
rm --recursive --force $cred.docker_config_dir
rm --recursive --force $cache
error make --unspanned { msg: $"oras pull failed:\n($result.stderr)" }
}
let manifest = (with-env { DOCKER_CONFIG: $cred.docker_config_dir } {
do { ^$oras manifest fetch $ref } | complete
})
rm --recursive --force $cred.docker_config_dir
if $manifest.exit_code == 0 {
$manifest.stdout | save --force ($cache | path join "oci-manifest.json")
}
$cache
}
# Fetch and return the OCI manifest for a domain artifact.
export def domain-describe [
participant: string
id: string
version: string
--registry: string = ""
--registry-id: string = "" # empty = use registries.default from manifest
]: nothing -> record {
let oras = (require-oras)
let project_root = ($env.ONTOREF_PROJECT_ROOT? | default $env.PWD)
let reg = if ($registry | is-empty) { resolve-registry } else { $registry }
let ref = (domain-ref $reg $participant $id $version)
assert-actor-authorized $project_root pull
assert-target-in-scope $project_root $"domains/($participant)/"
let cred = (resolve-registry-credential $project_root $registry_id --op pull)
let result = (with-env { DOCKER_CONFIG: $cred.docker_config_dir } {
do { ^$oras manifest fetch $ref } | complete
})
rm --recursive --force $cred.docker_config_dir
if $result.exit_code != 0 {
error make --unspanned { msg: $"oras manifest fetch failed:\n($result.stderr)" }
}
let manifest = ($result.stdout | from json)
{
ref: $ref,
artifact_type: ($manifest | get -o artifactType | default ""),
layers: ($manifest | get -o layers | default []),
digest: ($manifest | get -o config.digest | default ""),
raw: $manifest,
}
}
# Verify a domain artifact: pull to cache, check digest consistency,
# typecheck the contract.ncl layer, and verify cosign signature.
# Cosign key resolution order: --cosign-key flag → ONTOREF_COSIGN_PUBKEY env →
# $PROVISIONING/catalog/domains/_signing/cosign.pub. Unsigned artifacts pass
# when no key is resolvable; fail when a key is found but signature is absent.
export def domain-verify [
participant: string
id: string
version: string
--registry: string = ""
--registry-id: string = "" # empty = use registries.default from manifest
--cosign-key: string = ""
]: nothing -> record {
let project_root = ($env.ONTOREF_PROJECT_ROOT? | default $env.PWD)
let reg = if ($registry | is-empty) { resolve-registry } else { $registry }
let cache = (domain-pull $participant $id $version --registry $reg --registry-id $registry_id)
let oras = (require-oras)
let ref = (domain-ref $reg $participant $id $version)
let manifest = (domain-describe $participant $id $version --registry $reg --registry-id $registry_id)
mut artifact_type_ok = false
mut contract_present_ok = false
mut contract_typechecks_ok = false
mut digest_ok = false
mut cosign_ok = true
mut errors = []
if $manifest.artifact_type == $DOMAIN_MEDIA_TYPE {
$artifact_type_ok = true
} else {
$errors = ($errors | append $"Expected artifactType ($DOMAIN_MEDIA_TYPE), got ($manifest.artifact_type)")
}
let contract_path = ($cache | path join "contract.ncl")
if ($contract_path | path exists) {
$contract_present_ok = true
let import_path = ($env.NICKEL_IMPORT_PATH? | default ($env.PROVISIONING? | default ""))
let ncl_check = (do { ^nickel typecheck --import-path $import_path $contract_path } | complete)
if $ncl_check.exit_code == 0 {
$contract_typechecks_ok = true
} else {
$errors = ($errors | append $"contract.ncl typecheck failed: ($ncl_check.stderr)")
}
} else {
$errors = ($errors | append "contract.ncl not found in pulled artifact")
}
# Live manifest fetch for digest comparison — needs its own DOCKER_CONFIG.
assert-actor-authorized $project_root pull
assert-target-in-scope $project_root $"domains/($participant)/"
let verify_cred = (resolve-registry-credential $project_root $registry_id --op pull)
let live = (with-env { DOCKER_CONFIG: $verify_cred.docker_config_dir } {
do { ^$oras manifest fetch $ref } | complete
})
rm --recursive --force $verify_cred.docker_config_dir
if $live.exit_code == 0 {
let cached_manifest_path = ($cache | path join "oci-manifest.json")
if ($cached_manifest_path | path exists) {
let cached_raw = (open $cached_manifest_path | to json --indent 0)
let live_raw = ($live.stdout | from json | to json --indent 0)
if $cached_raw == $live_raw {
$digest_ok = true
} else {
$errors = ($errors | append "Cached manifest diverges from live registry — re-pull needed")
}
} else {
$digest_ok = true
}
}
let resolved_key = (find-cosign-pubkey $cosign_key)
let cosign_result = if ($resolved_key | is-not-empty) {
cosign-verify-signature $ref $resolved_key
} else {
{ ok: true, msg: "no cosign key configured — signature check skipped" }
}
if not $cosign_result.ok {
$cosign_ok = false
$errors = ($errors | append $"cosign: ($cosign_result.msg)")
}
let checks = {
artifact_type: $artifact_type_ok,
contract_present: $contract_present_ok,
contract_typechecks: $contract_typechecks_ok,
digest_ok: $digest_ok,
cosign_signature: $cosign_ok,
}
let pass = $artifact_type_ok and $contract_present_ok and $contract_typechecks_ok and $digest_ok and $cosign_ok
{ ref: $ref, pass: $pass, checks: $checks, errors: $errors, cosign_key: $resolved_key }
}
# List all domain artifacts in the registry (domains/<participant>/<id> namespace).
# Uses zot OCI catalog API; returns empty list if catalog is disabled.
# Registry resolution: --registry flag → PROVISIONING_REGISTRY env → capabilities.ncl → DEFAULT_REGISTRY
export def ecosystem-list-domains [
--registry: string = ""
]: nothing -> list {
let reg = if ($registry | is-empty) { resolve-registry } else { $registry }
let catalog_url = $"https://($reg)/v2/_catalog"
let body = try {
http get $catalog_url
} catch {
log-error $"Registry catalog API unavailable at ($catalog_url)"
return []
}
let repos = ($body | get -o repositories | default [])
$repos
| where { |r| $r | str starts-with "domains/" }
| each { |r|
# repos are domains/<participant>/<id>
let parts = ($r | str replace "domains/" "" | split row "/")
let participant = ($parts | first)
let domain_id = ($parts | skip 1 | str join "/")
let tags = (domain-tags $participant $domain_id --registry $reg)
{ participant: $participant, id: $domain_id, versions: $tags, ref_prefix: $"($reg)/($r)" }
}
}
def domain-tags [participant: string, id: string, --registry: string = ""]: nothing -> list {
let reg = if ($registry | is-empty) { resolve-registry } else { $registry }
let url = $"https://($reg)/v2/domains/($participant)/($id)/tags/list"
let body = try {
http get $url
} catch {
return []
}
$body | get -o tags | default []
}
def mode-ref [registry: string, participant: string, mode_id: string, version: string]: nothing -> string {
$"($registry)/modes/($participant)/($mode_id):($version)"
}
def mode-cache-dir [participant: string, mode_id: string, version: string]: nothing -> string {
$env.HOME | path join $MODE_CACHE_BASE $participant $mode_id $version
}
# Push a mode artifact to the registry.
# Mode directory must contain provisioning.ncl and domains.lock.ncl.
# ref format: <registry>/modes/<participant>/<mode-id>:<version>
export def mode-push [
path: string
participant: string
mode_id: string
version: string
--registry: string = ""
--registry-id: string = "" # empty = use registries.default from manifest
]: nothing -> record {
let oras = (require-oras)
let project_root = ($env.ONTOREF_PROJECT_ROOT? | default $env.PWD)
let reg = if ($registry | is-empty) { resolve-registry } else { $registry }
let ref = (mode-ref $reg $participant $mode_id $version)
let decl_path = ($path | path join "provisioning.ncl")
let lock_path = ($path | path join "domains.lock.ncl")
if not ($decl_path | path exists) {
error make --unspanned { msg: $"Mode directory ($path) missing provisioning.ncl" }
}
if not ($lock_path | path exists) {
error make --unspanned { msg: $"Mode directory ($path) missing domains.lock.ncl" }
}
assert-actor-authorized $project_root push
assert-target-in-scope $project_root $"modes/($participant)/"
let cred = (resolve-registry-credential $project_root $registry_id --op push)
log-info $"Pushing mode artifact → ($ref)"
let decl_layer = $"provisioning.ncl:($MODE_DECL_MEDIA_TYPE)"
let lock_layer = $"domains.lock.ncl:($MODE_LOCK_MEDIA_TYPE)"
let result = (with-env { DOCKER_CONFIG: $cred.docker_config_dir } {
do { cd $path; ^$oras push $ref --artifact-type $MODE_MEDIA_TYPE $decl_layer $lock_layer } | complete
})
rm --recursive --force $cred.docker_config_dir
if $result.exit_code != 0 {
error make --unspanned { msg: $"oras push mode failed:\n($result.stderr)" }
}
let all_output = $"($result.stdout)\n($result.stderr)"
let digest_lines = ($all_output | lines | where { |l| $l | str contains "Digest:" })
let digest = if ($digest_lines | is-empty) {
""
} else {
$digest_lines | first | str replace --regex '.*Digest:\s*' '' | str trim
}
{ ref: $ref, digest: $digest, participant: $participant, mode_id: $mode_id, version: $version, status: "pushed" }
}
# Pull a mode artifact into the local cache.
export def mode-pull [
participant: string
mode_id: string
version: string
--registry: string = ""
--registry-id: string = "" # empty = use registries.default from manifest
]: nothing -> string {
let oras = (require-oras)
let project_root = ($env.ONTOREF_PROJECT_ROOT? | default $env.PWD)
let reg = if ($registry | is-empty) { resolve-registry } else { $registry }
let ref = (mode-ref $reg $participant $mode_id $version)
let cache = (mode-cache-dir $participant $mode_id $version)
# Authorize FIRST — cache hits would bypass scope enforcement otherwise.
assert-actor-authorized $project_root pull
assert-target-in-scope $project_root $"modes/($participant)/"
let cached_manifest = ($cache | path join "oci-manifest.json")
if ($cache | path exists) and ($cached_manifest | path exists) {
log-debug $"Cache hit for mode ($participant)/($mode_id):($version) at ($cache)"
return $cache
}
let cred = (resolve-registry-credential $project_root $registry_id --op pull)
mkdir $cache
log-info $"Pulling mode ($ref) → ($cache)"
let result = (with-env { DOCKER_CONFIG: $cred.docker_config_dir } {
do { ^$oras pull $ref --output $cache } | complete
})
if $result.exit_code != 0 {
rm --recursive --force $cred.docker_config_dir
rm --recursive --force $cache
error make --unspanned { msg: $"oras pull mode failed:\n($result.stderr)" }
}
let manifest = (with-env { DOCKER_CONFIG: $cred.docker_config_dir } {
do { ^$oras manifest fetch $ref } | complete
})
rm --recursive --force $cred.docker_config_dir
if $manifest.exit_code == 0 {
$manifest.stdout | save --force ($cache | path join "oci-manifest.json")
}
$cache
}
# Walk up from CWD looking for infra/*/capabilities.ncl (workspace convention).
def _find-capabilities-ncl []: nothing -> string {
let ws_path = ($env | get -o PROVISIONING_WORKSPACE_PATH | default "")
if ($ws_path | is-not-empty) {
let candidates = (do { glob $"($ws_path)/infra/*/capabilities.ncl" } | default [])
if ($candidates | is-not-empty) {
return ($candidates | first)
}
}
mut dir = $env.PWD
for _ in 0..6 {
# Skip filesystem root — glob "//infra/..." is malformed.
if $dir == "/" or ($dir | str trim) == "" { break }
let cur = $dir # immutable copy for closure capture in glob
let candidates = (try {
do { glob $"($cur)/infra/*/capabilities.ncl" } | default []
} catch { [] })
if ($candidates | is-not-empty) {
return ($candidates | first)
}
let parent = ($dir | path dirname)
if $parent == $dir { break }
$dir = $parent
}
""
}
# Resolve the active registry endpoint.
# Priority: PROVISIONING_REGISTRY env → capabilities.ncl provides.registries.default → DEFAULT_REGISTRY
export def resolve-registry []: nothing -> string {
let from_env = ($env | get -o PROVISIONING_REGISTRY | default "")
if ($from_env | is-not-empty) {
return $from_env
}
let caps_path = (_find-capabilities-ncl)
if ($caps_path | is-not-empty) {
let import_path = ($env.NICKEL_IMPORT_PATH? | default ($env.PROVISIONING? | default ""))
let ncl = (do { ^nickel export --import-path $import_path $caps_path } | complete)
if $ncl.exit_code == 0 {
let data = ($ncl.stdout | from json)
let reg_default = ($data | get -o provides.registries.default | default "")
let registries = ($data | get -o provides.registries.registries | default [])
if ($reg_default | is-not-empty) and ($registries | is-not-empty) {
let entry = ($registries | where { |e| $e.id == $reg_default } | first | default null)
if $entry != null {
return $entry.endpoint
}
}
}
}
$DEFAULT_REGISTRY
}
def mode-tags [participant: string, mode_id: string, --registry: string = ""]: nothing -> list {
let reg = if ($registry | is-empty) { resolve-registry } else { $registry }
let url = $"https://($reg)/v2/modes/($participant)/($mode_id)/tags/list"
let body = try {
http get $url
} catch {
return []
}
$body | get -o tags | default []
}
# List all mode artifacts in the registry (modes/<participant>/<mode-id> namespace).
# Registry resolution: --registry flag → PROVISIONING_REGISTRY env → capabilities.ncl → DEFAULT_REGISTRY
export def ecosystem-list-modes [
--registry: string = ""
]: nothing -> list {
let reg = if ($registry | is-empty) { resolve-registry } else { $registry }
let catalog_url = $"https://($reg)/v2/_catalog"
let body = try {
http get $catalog_url
} catch {
log-error $"Registry catalog API unavailable at ($catalog_url)"
return []
}
let repos = ($body | get -o repositories | default [])
$repos
| where { |r| $r | str starts-with "modes/" }
| each { |r|
let parts = ($r | str replace "modes/" "" | split row "/")
let participant = ($parts | first)
let mode_id = ($parts | skip 1 | str join "/")
let tags = (mode-tags $participant $mode_id --registry $reg)
{ participant: $participant, id: $mode_id, versions: $tags, ref_prefix: $"($reg)/($r)" }
}
}