#!/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 = [] ]: 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)" } }