#!/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/// # # 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: /domains//: # 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// 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// 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: /modes//: 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// 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)" } } }