259 lines
9.8 KiB
Text
259 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)"
|
||
|
|
}
|
||
|
|
}
|