415 lines
17 KiB
Text
415 lines
17 KiB
Text
#!/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)] }),
|
|
}
|
|
}
|