ontoref/reflection/modules/vault.nu
Jesús Pérez 82a358f18d
Some checks failed
Nickel Type Check / Nickel Type Checking (push) Has been cancelled
Rust CI / Security Audit (push) Has been cancelled
Rust CI / Check + Test + Lint (push) Has been cancelled
feat: #[onto_mcp_tool] catalog, OCI credential vault layer, validate ADR-018 mode hierarchy
ontoref-derive: #[onto_mcp_tool] attribute macro registers MCP tool unit-structs in
  the catalog at link time via inventory::submit!; annotated item is emitted unchanged,
  ToolBase/AsyncTool impls stay on the struct. All 34 tools migrated from manual wiring
  (net +5: ontoref_list_projects, ontoref_search, ontoref_describe,
  ontoref_list_ontology_extensions, ontoref_get_ontology_extension).

  validate modes (ADR-018): reads level_hierarchy from workflow.ncl and checks every
  .ncl mode for level declared, strategy declared, delegate chain coherent, compose
  extends valid. mode resolve <id> shows which hierarchy level handles a mode and why.
  --self-test generates synthetic fixtures in a temp dir for CI smoke-testing.

  validate run-cargo: two-step Cargo.toml resolution — workspace layout first
  (crates/<check.crate>/Cargo.toml), single-crate fallback by package name or repo
  basename. Lets the same ADR constraint shape apply to workspace and single-crate repos.

  ontology/schemas/manifest.ncl: registry_topology_type contract — multi-registry
  coordination, push targets, participant scopes, per-namespace capability.

  reflection/requirements/base.ncl: oras ≥1.2.0, cosign ≥2.0.0, sops ≥3.9.0, age
  ≥1.1.0, restic declared as Hard/Soft requirements with version_min, check_cmd, and
  install_hint (ADR-017 toolchain surface).

  ADR-019: per-file recipient routing for tenant isolation without multi-vault. Schema
  additions: sops.recipient_groups + sops.recipient_rules in ontoref-project.ncl.
  secrets-bootstrap generates .sops.yaml from project.ncl in declarative mode. Three
  new secrets-audit checks: recipient-routing-coherent, recipient-routing-coverage,
  no-multi-vault. Adoption templates: single-team/, multi-tenant/, agent-first/.
  Integration templates: domain-producer/, mode-producer/, mode-consumer/.

  UI: project_picker surfaces registry badge (⟳ participant) and vault badge
  (⛁ vault_id · N, green=declarative / amber=legacy) per project card. Expanded panel
  adds collapsible Registry section with namespace, endpoint, and push/pull capability.
  manage.html gains Runtime Services card — MCP and GraphQL toggleable without restart
  via HTMX POST /ui/manage/services/{service}/toggle.

  describe.nu: capabilities JSON includes registry_topology and vault_state per project.
  sync.nu: drift check extended to detect //! absence on newly registered crates.
  qa.ncl: six entries — credential-vault-best-practice (layered data-flow diagram),
  credential-vault-templates (paths A/B/C), credential-vault-troubleshooting (15 named
  errors), integration-what-and-why (ADR-042 OCI federation), integration-how-to-implement,
  integration-troubleshooting.

  on+re: core.ncl + manifest.ncl updated to reflect OCI, MCP, and mode-hierarchy nodes.
  Deleted stale presentation assets (2026-02 slides + voice notes).
2026-05-12 04:46:15 +01:00

258 lines
9.8 KiB
Text

#!/usr/bin/env nu
# Vault backend abstraction for src-vault snapshot operations (ADR-017).
# All functions accept the decrypted vault_key value — they never read sops.
# Switching backends: change sops.vault_backend in project.ncl, re-init the local repo.
use ./secrets.nu [resolve-vault-access]
const VAULT_BACKENDS = ["restic" "kopia"]
def require-tool [tool: string]: nothing -> string {
let rows = (which $tool)
if ($rows | is-empty) {
error make --unspanned { msg: $"vault backend '($tool)' not found in PATH" }
}
$rows | get path | first | into string
}
def assert-backend [tool: string]: nothing -> nothing {
if $tool not-in $VAULT_BACKENDS {
error make --unspanned { msg: $"Unknown vault backend: ($tool). Valid: ($VAULT_BACKENDS | str join ', ')" }
}
}
# Initialize a new vault repository at `repo_path`.
export def vault-init [
repo_path: string
vault_key: string
--tool: string = "restic"
]: nothing -> nothing {
assert-backend $tool
let bin = (require-tool $tool)
let result = match $tool {
"restic" => (with-env { RESTIC_PASSWORD: $vault_key } {
do { ^$bin -r $repo_path init } | complete
}),
"kopia" => (with-env { KOPIA_PASSWORD: $vault_key } {
do { ^$bin repository create filesystem --path $repo_path } | complete
}),
}
if $result.exit_code != 0 {
error make --unspanned { msg: $"vault init failed:\n($result.stderr)" }
}
}
# Create a snapshot of `source_path`. Returns the snapshot short-id.
export def vault-backup [
repo_path: string
source_path: string
vault_key: string
--tool: string = "restic"
--tags: list<string> = []
]: nothing -> string {
assert-backend $tool
let bin = (require-tool $tool)
let result = match $tool {
"restic" => {
let tag_args = ($tags | each { |t| ["--tag" $t] } | flatten)
with-env { RESTIC_PASSWORD: $vault_key } {
do { ^$bin -r $repo_path backup $source_path ...$tag_args } | complete
}
},
"kopia" => {
with-env { KOPIA_PASSWORD: $vault_key } {
do { ^$bin --config-file ($repo_path | path join "kopia.config") snapshot create $source_path } | complete
}
},
}
if $result.exit_code != 0 {
error make --unspanned { msg: $"vault backup failed:\n($result.stderr)" }
}
$result.stdout | lines
| where { |l| $l | str contains "snapshot " }
| first
| default ""
| parse --regex 'snapshot\s+([0-9a-f]+)'
| get -o capture0
| first
| default ""
}
# Restore snapshot `snapshot_id` (or "latest") to `dest_path`.
export def vault-restore [
repo_path: string
dest_path: string
vault_key: string
snapshot_id: string = "latest"
--tool: string = "restic"
]: nothing -> nothing {
assert-backend $tool
let bin = (require-tool $tool)
let result = match $tool {
"restic" => (with-env { RESTIC_PASSWORD: $vault_key } {
do { ^$bin -r $repo_path restore $snapshot_id --target $dest_path } | complete
}),
"kopia" => (with-env { KOPIA_PASSWORD: $vault_key } {
do { ^$bin --config-file ($repo_path | path join "kopia.config") snapshot restore $snapshot_id $dest_path } | complete
}),
}
if $result.exit_code != 0 {
error make --unspanned { msg: $"vault restore failed:\n($result.stderr)" }
}
}
# List snapshots. Returns list of {id, time, tags} records.
export def vault-snapshots [
repo_path: string
vault_key: string
--tool: string = "restic"
]: nothing -> list {
assert-backend $tool
let bin = (require-tool $tool)
let result = match $tool {
"restic" => (with-env { RESTIC_PASSWORD: $vault_key } {
do { ^$bin -r $repo_path snapshots --json } | complete
}),
"kopia" => (with-env { KOPIA_PASSWORD: $vault_key } {
do { ^$bin --config-file ($repo_path | path join "kopia.config") snapshot list --json } | complete
}),
}
if $result.exit_code != 0 {
error make --unspanned { msg: $"vault snapshots failed:\n($result.stderr)" }
}
let raw = ($result.stdout | str trim)
if ($raw | is-empty) { return [] }
match $tool {
"restic" => {
$raw | from json | each { |s|
{ id: ($s | get -o short_id | default ""), time: ($s | get -o time | default ""), tags: ($s | get -o tags | default []) }
}
},
"kopia" => {
$raw | from json | each { |s|
{ id: ($s | get -o id | default ""), time: ($s | get -o startTime | default ""), tags: [] }
}
},
}
}
# Verify vault repository integrity.
export def vault-check [
repo_path: string
vault_key: string
--tool: string = "restic"
]: nothing -> bool {
assert-backend $tool
let bin = (require-tool $tool)
let result = match $tool {
"restic" => (with-env { RESTIC_PASSWORD: $vault_key } {
do { ^$bin -r $repo_path check } | complete
}),
"kopia" => (with-env { KOPIA_PASSWORD: $vault_key } {
do { ^$bin --config-file ($repo_path | path join "kopia.config") repository verify-object-integrity } | complete
}),
}
$result.exit_code == 0
}
# ── CLI dispatch — `ore vault X` ──────────────────────────────────────────────
# High-level wrappers that resolve vault context (project.ncl → vault_id, backend,
# repo_path, vault_key) and call the primitives above.
def _project-root-cli []: nothing -> string {
let from_env = ($env.ONTOREF_PROJECT_ROOT? | default "")
if ($from_env | is-not-empty) { return $from_env }
pwd
}
def _resolve-vault-context [project_root: string]: nothing -> record {
let project_ncl = ($project_root | path join ".ontoref" "project.ncl")
if not ($project_ncl | path exists) {
error make --unspanned { msg: $"No project.ncl at ($project_ncl)" }
}
let import_path = ($env.NICKEL_IMPORT_PATH? | default ($env.PROVISIONING? | default ""))
let r = (do { ^nickel export --import-path $import_path $project_ncl } | complete)
if $r.exit_code != 0 {
error make --unspanned { msg: $"Failed to evaluate project.ncl: ($r.stderr)" }
}
let project = ($r.stdout | from json)
let sops_cfg = ($project | get -o sops | default {})
let vault_id = ($sops_cfg | get -o vault_id | default ($project | get -o slug | default ""))
if ($vault_id | is-empty) {
error make --unspanned { msg: "Cannot determine vault_id (slug empty and sops.vault_id absent)" }
}
let backend = ($sops_cfg | get -o vault_backend | default "restic")
let backend_str = if (($backend | describe) == "string") { $backend } else { ($backend | to text) }
{
vault_id: $vault_id,
backend: $backend_str,
repo_path: ($env.HOME | path join ".config" "ontoref" "vaults" $vault_id "repo"),
}
}
# List snapshots in the vault repository for the active project.
export def "vault snapshots" []: nothing -> nothing {
let project_root = (_project-root-cli)
let ctx = (_resolve-vault-context $project_root)
let access = (resolve-vault-access $project_root)
if ($access.vault_key | is-empty) {
error make --unspanned {
msg: "vault_key empty — run: ore secrets sync, then retry"
}
}
let snaps = (vault-snapshots $ctx.repo_path $access.vault_key --tool $ctx.backend)
if ($snaps | is-empty) {
print $" No snapshots in ($ctx.repo_path)"
return
}
print $" (ansi white_bold)Vault snapshots(ansi reset) — ($ctx.vault_id) ((($ctx.backend)))"
for s in $snaps {
print $" ($s.id) ($s.time) tags=[($s.tags | str join ', ')]"
}
}
# Run integrity check on the vault repository.
export def "vault check" []: nothing -> nothing {
let project_root = (_project-root-cli)
let ctx = (_resolve-vault-context $project_root)
let access = (resolve-vault-access $project_root)
if ($access.vault_key | is-empty) {
error make --unspanned {
msg: "vault_key empty — run: ore secrets sync, then retry"
}
}
let ok = (vault-check $ctx.repo_path $access.vault_key --tool $ctx.backend)
if $ok {
print $" (ansi green)OK(ansi reset) ($ctx.vault_id) (($ctx.backend)) repository integrity verified"
} else {
print $" (ansi red)FAIL(ansi reset) ($ctx.vault_id) (($ctx.backend)) check failed"
}
}
# Summary: vault context resolved + last snapshot.
export def "vault status" []: nothing -> nothing {
let project_root = (_project-root-cli)
let ctx = (_resolve-vault-context $project_root)
print $" (ansi white_bold)Vault(ansi reset) ($ctx.vault_id)"
print $" (ansi dark_gray)backend(ansi reset) ($ctx.backend)"
print $" (ansi dark_gray)repo(ansi reset) ($ctx.repo_path) (if ($ctx.repo_path | path exists) { '(present)' } else { '(absent)' })"
if not ($ctx.repo_path | path exists) {
print " Run: ore secrets bootstrap (admin) or ore secrets sync"
return
}
let access = (try { resolve-vault-access $project_root } catch { |e|
print $" vault access (ansi red)error(ansi reset): ($e.msg)"
return
})
if ($access.vault_key | is-empty) { return }
let snaps = (try { vault-snapshots $ctx.repo_path $access.vault_key --tool $ctx.backend } catch { |e|
print $" snapshots (ansi red)error(ansi reset): ($e.msg)"
return
})
if ($snaps | is-empty) {
print " snapshots (none)"
} else {
let last = ($snaps | last)
print $" (ansi dark_gray)snapshots(ansi reset) ($snaps | length) total — last: ($last.id) @ ($last.time)"
}
}