#!/usr/bin/env nu # Content-addressed resolution of the catalog component-manifest contract (ADR-046). # # Declaration layer: a catalog declares requires.contract = { id, version, subject }. # Resolution layer: version (semver intent) is resolved to an immutable sha256 # digest via the OCI Referrers API — no mutable tags — pinned in catalog.lock.ncl # (DomainLockEntry shape), and all automated loading reads the digest thereafter. # # Extends the ADR-042 Domain-artifact primitives. Reuses the credential and # registry-resolution machinery from platform/oci. use primitives/io/logging.nu [log-debug log-info] use platform/oci/credentials.nu [ resolve-registry-credential assert-actor-authorized assert-target-in-scope ] use platform/oci/domain_client.nu [resolve-registry] const CONTRACT_MEDIA_TYPE = "application/vnd.ontoref.domain.contract.v1" const ANCHOR_MEDIA_TYPE = "application/vnd.ontoref.domain.anchor.v1" const VERSION_ANNOTATION = "org.opencontainers.image.version" const CONTRACT_CACHE_BASE = ".cache/ontoref/contracts" 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 } # Acquire the docker config dir for an OCI op. Honors a global push-registry # override (PROVISIONING_PUSH_DOCKER_CONFIG, set by select-push-registry) — which # decouples the push target from the active workspace; otherwise resolves the # workspace-scoped credential with actor + scope gating. Returns { dir, ephemeral }: # ephemeral dirs are owned by this call and must be removed after use. def acquire-docker-config [ project_root: string op: string scope_target: string registry_id: string ]: nothing -> record { let override = ($env.PROVISIONING_PUSH_DOCKER_CONFIG? | default "") if ($override | is-not-empty) and ($override | path exists) { { dir: $override, ephemeral: false } } else { assert-actor-authorized $project_root $op if ($scope_target | is-not-empty) { assert-target-in-scope $project_root $scope_target } let cred = (resolve-registry-credential $project_root $registry_id --op $op) { dir: $cred.docker_config_dir, ephemeral: true } } } # Extract the "Digest: sha256:..." line oras prints on push/attach. def parse-digest [output: string]: nothing -> string { let lines = ($output | lines | where { |l| $l | str contains "Digest:" }) if ($lines | is-empty) { "" } else { $lines | first | str replace --regex '.*Digest:\s*' '' | str trim } } # Parse a semver core (ignores pre-release/build metadata) into typed components. export def parse-semver [v: string]: nothing -> record { let core = ($v | str trim | split row "+" | first | split row "-" | first) let parts = ($core | split row ".") { major: ($parts | get 0? | default "0" | into int), minor: ($parts | get 1? | default "0" | into int), patch: ($parts | get 2? | default "0" | into int), } } # Compare two semver strings: -1 if ab. export def semver-cmp [a: string, b: string]: nothing -> int { let pa = (parse-semver $a) let pb = (parse-semver $b) if $pa.major != $pb.major { return (if $pa.major > $pb.major { 1 } else { -1 }) } if $pa.minor != $pb.minor { return (if $pa.minor > $pb.minor { 1 } else { -1 }) } if $pa.patch != $pb.patch { return (if $pa.patch > $pb.patch { 1 } else { -1 }) } 0 } # Evaluate a single comparator (">=1.0.0", "<2.0.0", "^1.2.3", "~1.2.3", "=1.0.0"). def satisfies-one [version: string, comparator: string]: nothing -> bool { let c = ($comparator | str trim) if ($c | is-empty) { return true } let m = ($c | parse --regex '^(?>=|<=|>|<|=|\^|~)?\s*(?\d+\.\d+\.\d+.*)$') if ($m | is-empty) { return false } let op = ($m | first | get op) let ver = ($m | first | get ver | str trim) let cmp = (semver-cmp $version $ver) let pv = (parse-semver $ver) match $op { ">=" => ($cmp >= 0), "<=" => ($cmp <= 0), ">" => ($cmp > 0), "<" => ($cmp < 0), "=" => ($cmp == 0), "" => ($cmp == 0), "^" => { let upper = if $pv.major > 0 { $"($pv.major + 1).0.0" } else if $pv.minor > 0 { $"0.($pv.minor + 1).0" } else { $"0.0.($pv.patch + 1)" } ($cmp >= 0) and ((semver-cmp $version $upper) < 0) }, "~" => { let upper = $"($pv.major).($pv.minor + 1).0" ($cmp >= 0) and ((semver-cmp $version $upper) < 0) }, _ => false, } } # Whether `version` satisfies a comma-separated AND of comparators. export def semver-satisfies [version: string, constraint: string]: nothing -> bool { let parts = ($constraint | split row "," | each { |p| $p | str trim } | where { |p| ($p | is-not-empty) }) if ($parts | is-empty) { return true } $parts | all { |comp| satisfies-one $version $comp } } # Zero-padded sort key so plain sort-by orders semver correctly. def semver-key [v: string]: nothing -> string { let p = (parse-semver $v) let mj = ($p.major | into string | fill --alignment right --character '0' --width 10) let mn = ($p.minor | into string | fill --alignment right --character '0' --width 10) let pt = ($p.patch | into string | fill --alignment right --character '0' --width 10) $"($mj).($mn).($pt)" } # Highest candidate satisfying the constraint. candidates: [{version, digest, ...}]. # Returns null when none match. export def select-version [candidates: list, constraint: string]: nothing -> any { let matching = ($candidates | where { |c| semver-satisfies $c.version $constraint }) if ($matching | is-empty) { return null } $matching | insert _k { |c| semver-key $c.version } | sort-by _k | last | reject _k } # Discover content-addressed contract versions via the OCI Referrers API. # `subject_ref` is the digest-anchored subject the versions refer to. # Returns [{version, digest, artifact_type}] for referrers carrying a version annotation. export def discover-contract-referrers [ subject_ref: string --registry-id: string = "" ]: nothing -> list { let oras = (require-oras) let project_root = ($env.ONTOREF_PROJECT_ROOT? | default $env.PWD) let dc = (acquire-docker-config $project_root "pull" "" $registry_id) let result = (with-env { DOCKER_CONFIG: $dc.dir } { do { ^$oras discover --format json $subject_ref } | complete }) if $dc.ephemeral { rm --recursive --force $dc.dir } if $result.exit_code != 0 { error make --unspanned { msg: $"oras discover failed:\n($result.stderr)" } } let parsed = ($result.stdout | from json) let referrers = ($parsed | get -o referrers | default ($parsed | get -o manifests | default [])) $referrers | each { |r| { version: ($r | get -o annotations | get -o $VERSION_ANNOTATION | default ""), digest: ($r | get -o digest | default ""), artifact_type: ($r | get -o artifactType | default ""), } } | where { |c| ($c.version | is-not-empty) and ($c.digest | is-not-empty) } } # Cache dir for a pinned contract digest. Digest ':' is path-sanitized. def contract-cache-dir [id: string, digest: string]: nothing -> string { $env.HOME | path join $CONTRACT_CACHE_BASE $id ($digest | str replace ':' '-') } # Pull a contract artifact strictly by digest (never by tag) into the local cache. # Returns the cache directory. Cache hit short-circuits the registry round-trip. export def pull-contract-by-digest [ id: string digest: string --participant: string = "provisioning" --registry: string = "" --registry-id: string = "" ]: 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 = $"($reg)/domains/($participant)/($id)@($digest)" let cache = (contract-cache-dir $id $digest) if ($cache | path join "contract.ncl" | path exists) { log-debug $"Contract cache hit for ($id)@($digest)" return $cache } let dc = (acquire-docker-config $project_root "pull" $"domains/($participant)/" $registry_id) mkdir $cache log-info $"Pulling contract ($ref) → ($cache)" let result = (with-env { DOCKER_CONFIG: $dc.dir } { do { ^$oras pull $ref --output $cache } | complete }) if $dc.ephemeral { rm --recursive --force $dc.dir } if $result.exit_code != 0 { rm --recursive --force $cache error make --unspanned { msg: $"oras pull (by digest) failed:\n($result.stderr)" } } $cache } # Write catalog.lock.ncl pinning the resolved contract to its digest. # Reuses the DomainLockEntry shape from oci_artifact_format.ncl. export def write-contract-lock [ catalog_root: string id: string version: string digest: string pulled_at: string ]: nothing -> string { let lock_path = ($catalog_root | path join "catalog.lock.ncl") let content = $"# Generated by prvng — ADR-046 catalog contract lock. Do not edit by hand. # The digest, not the version range, is the authority for automated loading. let oci = import \"schemas/lib/integration/oci_artifact_format.ncl\" in { contract = { version = \"($version)\", digest = \"($digest)\", pulled_at = \"($pulled_at)\", media_type = \"($CONTRACT_MEDIA_TYPE)\", } | oci.DomainLockEntry, } " $content | save --force $lock_path $lock_path } # Resolve requires.contract → digest, pull by digest, write catalog.lock.ncl. # `contract` is the evaluated requires.contract record: { id, version, registry?, subject?, participant? }. export def resolve-contract-and-lock [ catalog_root: string contract: record ]: nothing -> record { let participant = ($contract | get -o participant | default "provisioning") let registry = ($contract | get -o registry | default "") let reg = if ($registry | is-empty) { resolve-registry } else { $registry } let subject = ($contract | get -o subject | default "") if ($subject | is-empty) { error make --unspanned { msg: $"requires.contract.subject is required for referrers-based discovery (ADR-046): contract '($contract.id)' has no content-addressed anchor" } } let subject_ref = $"($reg)/domains/($participant)/($contract.id)@($subject)" let candidates = (discover-contract-referrers $subject_ref) let chosen = (select-version $candidates $contract.version) if $chosen == null { let available = ($candidates | get version | str join ", ") error make --unspanned { msg: $"No contract version satisfies '($contract.version)' for '($contract.id)' — available: [($available)]" } } let cache = (pull-contract-by-digest $contract.id $chosen.digest --participant $participant --registry $reg) let pulled_at = (date now | format date "%+") let lock = (write-contract-lock $catalog_root $contract.id $chosen.version $chosen.digest $pulled_at) log-info $"Locked contract ($contract.id) → ($chosen.version) @ ($chosen.digest)" { id: $contract.id, version: $chosen.version, digest: $chosen.digest, cache: $cache, lock: $lock, contract: ($cache | path join "contract.ncl"), } } # ── Producer side: publish contract versions as referrers ─────────────────── # Publish (once) the immutable referrers anchor for a contract id. # Returns { ref, digest }. The digest is the stable `subject` recorded in # requires.contract — every published version attaches to it. The :anchor tag is # bootstrap reachability only; loaders address the subject by digest, never by tag. export def publish-contract-anchor [ participant: string id: string --registry: string = "" --registry-id: string = "" ]: 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 = $"($reg)/domains/($participant)/($id):anchor" let dc = (acquire-docker-config $project_root "push" $"domains/($participant)/" $registry_id) log-info $"Publishing contract anchor → ($ref)" let result = (with-env { DOCKER_CONFIG: $dc.dir } { do { ^$oras push $ref --artifact-type $ANCHOR_MEDIA_TYPE } | complete }) if $dc.ephemeral { rm --recursive --force $dc.dir } if $result.exit_code != 0 { error make --unspanned { msg: $"oras push (anchor) failed:\n($result.stderr)" } } { ref: $ref, digest: (parse-digest $"($result.stdout)\n($result.stderr)") } } # Attach a contract version to the anchor as a referrer carrying its version # annotation. This is what discover-contract-referrers enumerates. Returns the # referrer digest — the immutable reference the resolver pins in catalog.lock.ncl. export def publish-contract-version [ contract_file: string # path to contract.ncl participant: string id: string version: string subject_digest: string # the anchor digest from publish-contract-anchor --registry: string = "" --registry-id: string = "" ]: 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 } if not ($contract_file | path exists) { error make --unspanned { msg: $"contract file not found: ($contract_file)" } } let subject_ref = $"($reg)/domains/($participant)/($id)@($subject_digest)" let dir = ($contract_file | path dirname) let file = ($contract_file | path basename) let annotation = $"($VERSION_ANNOTATION)=($version)" let layer = $"($file):($CONTRACT_MEDIA_TYPE)" let dc = (acquire-docker-config $project_root "push" $"domains/($participant)/" $registry_id) log-info $"Attaching contract ($id) v($version) → ($subject_ref)" let result = (with-env { DOCKER_CONFIG: $dc.dir } { do { cd $dir ^$oras attach --artifact-type $CONTRACT_MEDIA_TYPE --annotation $annotation $subject_ref $layer } | complete }) if $dc.ephemeral { rm --recursive --force $dc.dir } if $result.exit_code != 0 { error make --unspanned { msg: $"oras attach (version) failed:\n($result.stderr)" } } { id: $id, version: $version, digest: (parse-digest $"($result.stdout)\n($result.stderr)"), subject: $subject_digest } } # ── Step 3: validation-layer wiring ───────────────────────────────────────── # Evaluate a component manifest and return its requires.contract declaration, # or null when the manifest declares none. export def read-contract-decl [manifest_path: string]: nothing -> any { if not ($manifest_path | path exists) { error make --unspanned { msg: $"manifest not found: ($manifest_path)" } } let import_path = ($env.NICKEL_IMPORT_PATH? | default ($env.PROVISIONING? | default "")) let r = (do { ^nickel export --import-path $import_path $manifest_path } | complete) if $r.exit_code != 0 { error make --unspanned { msg: $"Failed to evaluate ($manifest_path): ($r.stderr)" } } $r.stdout | from json | get -o requires.contract | default null } # Validate a component manifest against its declared contract (ADR-046). # Resolves requires.contract → digest, pins catalog.lock.ncl, then typechecks the # manifest with the pulled contract directory on the Nickel import path so the # manifest's contract import resolves to the locked digest, not a vendored copy. # A manifest with no requires.contract is reported as skipped (not a failure). export def validate-manifest-against-contract [ manifest_path: string --catalog-root: string = "" ]: nothing -> record { let root = if ($catalog_root | is-empty) { ($manifest_path | path dirname) } else { $catalog_root } let decl = (read-contract-decl $manifest_path) if $decl == null { return { manifest: $manifest_path, contract: null, locked: false, typechecks: true, note: "no requires.contract declared — contract validation skipped", } } let resolved = (resolve-contract-and-lock $root $decl) let base_import = ($env.NICKEL_IMPORT_PATH? | default ($env.PROVISIONING? | default "")) let import_path = $"($resolved.cache):($base_import)" let check = (do { ^nickel typecheck --import-path $import_path $manifest_path } | complete) { manifest: $manifest_path, contract: $resolved.id, version: $resolved.version, digest: $resolved.digest, lock: $resolved.lock, locked: true, typechecks: ($check.exit_code == 0), errors: (if $check.exit_code == 0 { [] } else { [($check.stderr | str trim)] }), } }