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

416 lines
17 KiB
Text
Raw Permalink Normal View History

#!/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 a<b, 0 if equal, 1 if a>b.
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 '^(?<op>>=|<=|>|<|=|\^|~)?\s*(?<ver>\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)] }),
}
}