604 lines
25 KiB
Text
604 lines
25 KiB
Text
|
|
#!/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)" }
|
||
|
|
}
|
||
|
|
}
|