kampodine 0.3.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,144 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0 — 2026-10-08
4
+
5
+ Three surfaces (per-instance profiles, VM metrics, OCI backups) plus
6
+ host init auto-detection groundwork. Tests grew from 171 to 277.
7
+
8
+ - **Profiles (per-instance config)** — `kampodine config
9
+ init|list|show|set-default|remove` manages `~/.kampodine/config.json`
10
+ (dir 0700, file 0600). FLAT by owner decision: each profile carries
11
+ `host` / `sshKey` (PATH only — the config never holds secrets) /
12
+ `proxyHost` / `group`; `group` is a cosmetic `config list --group`
13
+ filter, and the schema only LEAVES ROOM for a future `defaults` block —
14
+ no inheritance logic exists. The first profile auto-becomes the default;
15
+ removing the LAST profile requires `--force`; removing the default
16
+ promotes the alphabetically-first remaining profile. Resolution
17
+ EVERYWHERE (deploy, status, bluegreen, migrate, vm-prepare,
18
+ image-import, env, dns, every deploy-lifecycle sub, metrics):
19
+ `--profile` flag > `KAMPODINE_PROFILE` env > config defaultProfile >
20
+ the legacy env behavior. A resolved profile's host/sshKey/proxyHost feed
21
+ the SAME ladders those scripts already use (explicit per-invocation
22
+ flags always win; profile.proxyHost replaces the `PROXY_HOST` env in
23
+ deploy and the health header in bluegreen/status). The deploy ledger
24
+ already records host, so `deploy list`/ledger filtering work
25
+ per-profile automatically. Reads/rewrites require jq; machines without
26
+ a config are unaffected
27
+ - **`kampodine metrics`** — one-shot VM snapshot over ONE ssh
28
+ round-trip: load + CPU count, memory (used/available from
29
+ /proc/meminfo), disk WITH an images/data/other breakdown (`df` for /
30
+ plus `du -sm` for /var/lib/containers and /data), per-container CPU/MEM
31
+ via `podman stats --no-stream` (busybox-safe remote commands; all
32
+ parsing is client-side), uptime, top-5 processes by RSS.
33
+ `--warn-disk <pct>` (default 90) exits 1 when root disk usage is at or
34
+ above the threshold — the SAME verdict logic as deploy's fail-closed
35
+ gate (reuses the common.sh disk helpers). `--watch N [--count M]`
36
+ re-snapshots every N seconds. `status --verbose` folds the SAME
37
+ snapshot in. NO continuous monitoring — Beszel owns that; this is the
38
+ CLI triage view
39
+ - **`kampodine backup list|download|verify|restore-plan`** — OCI Object
40
+ Storage family (bucket `--bucket`, default esellar-libsql-backups;
41
+ auth via the oci CLI's config file (`--profile`, dns convention) or
42
+ `--instance-principal` — NEVER stored credentials). `list` renders
43
+ objects + sizes + timestamps and, when LIST is denied by policy, prints
44
+ the exact policy shape that is missing (real case: an instance
45
+ principal without inspect/read on list). `download` works even when
46
+ LIST is denied, verifies sha256 (opc-meta-sha256/content-sha256) or
47
+ MD5 (opc-content-md5) metadata when present, writes the output 0600
48
+ and prints fingerprint summaries — never contents. `verify` is
49
+ READ-ONLY: the local .db image travels over ssh stdin to the VM's
50
+ `/usr/local/bin/restore-verify` (exit codes propagate); .tgz archives
51
+ are extracted to a scratch dir and every .db member piped the same
52
+ way — it NEVER writes into /data/tenants. `restore-plan` prints the
53
+ documented stop/swap/start sequence bound to the object and NEVER
54
+ executes a mutating step (proven hermetically: ssh/oci stubs that exit
55
+ 99 stay untouched)
56
+ - **Host init auto-detection — groundwork for non-Alpine targets.** On
57
+ first connect `init_detect` probes `rc-service` (OpenRC) vs
58
+ `systemctl` (systemd) vs neither, and caches the verdict in the active
59
+ profile entry (`"init"` field; per-process without a profile). One
60
+ helper, `svc_action <name> <start|stop|restart>`, emits the
61
+ init-specific command — migrated call sites: deploy's restart path +
62
+ its failure diagnostics, `deploy-lifecycle restart`, and the status
63
+ roll call. Behavior on Alpine is byte-identical (existing tests pin
64
+ the exact `rc-service` strings). A host with NEITHER init refuses
65
+ service actions with guidance instead of guessing. NO per-OS
66
+ vm-prepare / distro bootstrap — detection + action resolution only
67
+ - **Health-probe compat** — the busybox-wget health probe stays the
68
+ default (byte-identical), with a curl fallback for GNU-only hosts;
69
+ detection piggybacks the same first-connect probe
70
+ (`REMOTE_HTTP_CLIENT`)
71
+ - Help everywhere held: every new command and subcommand (config init/
72
+ list/show/set-default/remove, metrics, backup list/download/verify/
73
+ restore-plan) answers `--help`/`-h` with usage + examples; the
74
+ top-level index gained CONFIG / METRICS / BACKUP groups; help
75
+ coverage, cli-dispatch, and script-gates gates extended for every new
76
+ script
77
+
78
+ ## 0.5.0 — 2026-10-08
79
+
80
+ Deployment lifecycle management is now native — born from a real outage:
81
+ the VM disk hit 100% because old sha-tagged deploy images accumulated
82
+ silently (~1GB each), prod sqlite writes failed with disk I/O errors, and
83
+ nothing surfaced it. Plus the owner's ask: total deployments, visible
84
+ always.
85
+
86
+ - **Deployment ledger** — every `deploy` and `deploy --rollback` appends
87
+ `{ts, host, sha, tag, result, duration_ms, subject}` to the local
88
+ append-only `~/.kampodine/deployments.jsonl` (dir 0700, file 0600);
89
+ shas/tags/hosts/subjects only — NEVER secrets. Failed runs are recorded
90
+ too (EXIT trap). Every deploy run ends with a
91
+ `Total deployments: N · current tag: X` footer; `status` prints the count
92
+ - **`deploy list`** — deployment history merging VM truth (podman
93
+ sha-tagged images + created timestamps, the RUNNING one marked) with the
94
+ local ledger (last result + recorded git subject per tag); pruned tags
95
+ stay listed from the ledger; `Total deployments: N` footer. VM
96
+ unreachable degrades to a ledger-only view
97
+ - **Disk guard** — deploy reads the VM's `df` for `/var/lib/containers`
98
+ before the build and after cleanup: above 90% it warns LOUD with the
99
+ prune hint; `--require-disk <pct>` fails closed before anything ships;
100
+ `status --host` shows the same reading
101
+ - **`deploy prune [--keep N] [--dry-run]`** — removes old sha-tagged deploy
102
+ images on the VM, ALWAYS keeping the currently-running image, `ts-rollback`,
103
+ and the newest N sha-tags (default 2); refuses anything a running
104
+ container uses; `--dry-run` prints the exact `podman rmi` commands and
105
+ removes nothing
106
+ - **Ops conveniences** (all `--host`/`--ssh-key` aware, same resolution as
107
+ deploy): `deploy logs [--lines N]` (podman logs --tail), `deploy restart`
108
+ (rc-service — deploy's own restart path, status printed after), and
109
+ `deploy shell` (interactive sh in the api container; `shell exec -- <cmd>`
110
+ for one-shot)
111
+ - **`status` grew a VM section**: disk usage, sha-tagged image count with a
112
+ reclaimable-by-prune estimate, and a service roll call (kampodine-api,
113
+ kamal-proxy, walshipper if present)
114
+ - Help everywhere held: every new subcommand answers `--help`/`-h` with
115
+ usage + examples; the top-level index gained a DEPLOY LIFECYCLE section.
116
+ Tests grew from 114 to 171: ledger append/read (incl. rollback entries and
117
+ exact key-set pins), list table + total footer, prune keep-set fixtures
118
+ (running/ts-rollback/newest-N never removed), disk threshold matrix +
119
+ fail-closed, logs/restart/shell command construction, help coverage
120
+
121
+ ## 0.4.0 — 2026-10-07
122
+
123
+ **Breaking: project-generic naming.** Every origin-coupled default name is
124
+ gone; the package now speaks only for itself.
125
+
126
+ - `KAMPODINE_HOST` replaces `ESPELLAR_HOST` everywhere (deploy, env); the
127
+ key-resolution ladder is `--ssh-key` | `KAMPODINE_SSH_KEY` | ssh-agent /
128
+ ssh-config — the old `ESPELLAR_SSH_KEY` / `ESSELLAR_SSH_KEY` aliases are
129
+ no longer mentioned anywhere
130
+ - Guest paths renamed: `/etc/esellar/*` → `/etc/kampodine/*` (env file,
131
+ deployed-sha, anchor.conf); the anchor watcher is `kampodine-anchor`
132
+ - Service/container/image names: `esellar-api` → `kampodine-api`,
133
+ `esellar-blue`/`esellar-green` → `kampodine-blue`/`kampodine-green`,
134
+ golden image `esellar-alpine*` → `kampodine-alpine*`
135
+ - `OCI_COMPARTMENT` is now REQUIRED (no default — compartments are
136
+ account-specific); `OCI_PROFILE` defaults to `default` (the OCI CLI's
137
+ own default profile) instead of a named profile
138
+ - Existing guests keep booting, but flips against guests provisioned by
139
+ older releases need their watcher/paths updated (or re-run
140
+ `vm-prepare` on a fresh host) since the anchor protocol path changed
141
+
3
142
  ## 0.3.0 — 2026-10-07
4
143
 
5
144
  Full-lifecycle orchestration with first-class help — the "mini vercel CLI"
package/README.md CHANGED
@@ -45,15 +45,59 @@ kampodine deploy:
45
45
  public smoke: health + build-id endpoints
46
46
  ```
47
47
 
48
+ - **`kampodine config`** — per-instance profiles in
49
+ `~/.kampodine/config.json` (dir 0700, file 0600): `init --name prod
50
+ --host root@<ip> --ssh-key <path> [--proxy-host <h>] [--group main]
51
+ [--set-default]`, `list` (+ `--group` cosmetic filter, default marker,
52
+ sshKey shown as PATH only), `show`, `set-default`, `remove` (the last
53
+ profile needs `--force`). FLAT — no inheritance between profiles (the
54
+ schema leaves room for a future `defaults` block; nothing reads one).
55
+ Resolution everywhere: `--profile` flag | `KAMPODINE_PROFILE` |
56
+ defaultProfile | the legacy env vars — a profile's host/sshKey/proxyHost
57
+ feed the same ladders every subcommand already uses
58
+ - **`kampodine metrics`** — one-shot VM snapshot over one SSH round-trip:
59
+ load + CPUs, memory, disk with an images/data/other breakdown,
60
+ per-container CPU/MEM, uptime, top-5 by RSS; `--warn-disk <pct>`
61
+ (default 90) exits 1 at the threshold (the deploy gate's verdict
62
+ logic); `--watch N` to loop. `status --verbose` folds the same
63
+ snapshot in. One-shot CLI view only — continuous monitoring belongs to
64
+ Beszel
65
+ - **`kampodine backup`** — OCI Object Storage backup family
66
+ (`--bucket`, default `esellar-libsql-backups`; oci config-file
67
+ `--profile` or `--instance-principal` auth ONLY — never stored
68
+ credentials): `list` (sizes + timestamps; LIST-denied prints the
69
+ missing-policy guidance), `download` (GET works without LIST;
70
+ sha256/MD5 verification against object metadata when present; output
71
+ 0600; fingerprint summaries, never contents), `verify` (read-only:
72
+ pipes the local .db — or every .db member of a .tgz, extracted to a
73
+ scratch dir — over ssh stdin into the VM's
74
+ `/usr/local/bin/restore-verify`; NEVER writes into /data/tenants), and
75
+ `restore-plan` (prints the documented stop/swap/start sequence; never
76
+ executes anything)
48
77
  - **`kampodine deploy`** — stream deploy; `--version <sha>` restreams an
49
78
  existing build; `--rollback [sha]` is an instant image-tag rollback (the
50
- previous image stays on the VM for exactly this)
51
- - **`kampodine env`** — manage the remote app env file (`/etc/esellar/env`,
79
+ previous image stays on the VM for exactly this). Every run appends to the
80
+ local deployment ledger and ends with a total-deployments footer; a disk
81
+ guard reads the VM's `df` before the build (LOUD warning above 90%,
82
+ `--require-disk <pct>` fails closed)
83
+ - **`kampodine deploy <lifecycle>`** — the deployment lifecycle as native
84
+ subcommands (same `--host`/`--ssh-key` resolution as deploy):
85
+ `list` merges the VM's sha-tagged images (running one marked) with the
86
+ local ledger (`~/.kampodine/deployments.jsonl` — append-only history of
87
+ every deploy/rollback: ts, host, sha, tag, result, duration, git subject;
88
+ never secrets) into a table with a `Total deployments: N` footer;
89
+ `prune [--keep N] [--dry-run]` reclaims VM disk by removing old
90
+ sha-tagged images while ALWAYS keeping the running image, `ts-rollback`,
91
+ and the newest N (the 2026-10-08 disk-full outage is the reason this
92
+ exists); `logs [--lines N]`, `restart` (rc-service, deploy's own restart
93
+ path), and `shell` (interactive sh, or `exec -- <cmd>`) end the
94
+ hand-rolled-ssh era
95
+ - **`kampodine env`** — manage the remote app env file (`/etc/kampodine/env`,
52
96
  0600 root) without ever printing a secret: `list` shows KEY + fingerprints
53
97
  only (value length + first 2 chars), `push --file <env>` uploads over ssh
54
98
  stdin into a 0600 temp + atomic `mv`, `pull` streams the raw payload to
55
99
  stdout or `--out` (written 0600). Host/key resolution is identical to
56
- deploy: `--host root@<ip>` | `ESPELLAR_HOST`, `--ssh-key <path>` |
100
+ deploy: `--host root@<ip>` | `KAMPODINE_HOST`, `--ssh-key <path>` |
57
101
  `KAMPODINE_SSH_KEY` | ssh-agent. `env fingerprint` previews the masking
58
102
  for any local file — values never leave stdin
59
103
  - **`kampodine dns`** — OCI DNS records for the post-sslip.io era:
@@ -70,6 +114,11 @@ kampodine deploy:
70
114
  rollback. A guest **anchor watcher** installed by `vm-prepare` holds the
71
115
  flip's IP half — add-only and inert until a flip writes its anchor config.
72
116
  Every sub-step has its own `--help` with usage + examples
117
+ - **`kampodine status`** — deployment count (from the ledger), live health
118
+ through the proxy, and — with `--host` — VM disk usage (loud warning above
119
+ 90%), the sha-tagged image count with a reclaimable-by-prune estimate, a
120
+ service roll call (app, kamal-proxy, walshipper if present), plus the
121
+ blue/green pair view; `--verbose` adds the full metrics snapshot
73
122
  - **`kampodine vm-prepare`** — first-run bootstrap of a bare Alpine host:
74
123
  sshd hardening (fresh VMs pass unattended), busybox-wget health probes (the
75
124
  golden image ships no curl), the blue-green anchor watcher, the podman
@@ -80,9 +129,18 @@ kampodine deploy:
80
129
  - **`kampodine migrate`** — sqlite migrations over SSH
81
130
 
82
131
  **Help everywhere:** `kampodine --help` (or bare `kampodine`) prints a
83
- grouped command index (DEPLOY / INFRA / DNS / ENV, vercel-style); every
84
- command — and every bluegreen/env/dns sub-step — answers `--help` with
85
- usage + examples. No subcommand silently does nothing on `--help`.
132
+ grouped command index (DEPLOY / DEPLOY LIFECYCLE / INFRA / DNS / ENV /
133
+ CONFIG / METRICS / BACKUP, vercel-style); every command — and every
134
+ deploy-lifecycle/bluegreen/env/dns/config/backup sub-step — answers
135
+ `--help` with usage + examples. No subcommand silently does nothing on
136
+ `--help`.
137
+
138
+ **Host init auto-detection:** on first connect kampodine probes
139
+ `rc-service` (Alpine/OpenRC) vs `systemctl` (systemd) and caches the
140
+ verdict in the active profile entry; service restarts and the status roll
141
+ call emit the right commands for the detected init (`svc_action`).
142
+ Alpine behavior is byte-identical to always. Groundwork for non-Alpine
143
+ targets — no per-distro bootstrap.
86
144
 
87
145
  ## Kamal parity
88
146
 
@@ -92,8 +150,12 @@ usage + examples. No subcommand silently does nothing on `--help`.
92
150
  | `kamal rollback` | `kampodine deploy --rollback [sha]` |
93
151
  | `kamal details` | `kampodine bluegreen status` |
94
152
  | `kamal proxy …` | kamal-proxy container, unchanged |
95
- | `kamal app exec/logs` | `ssh root@<host>` |
96
- | `kamal reboot / app boot` | `rc-service <app> restart` over SSH |
153
+ | `kamal app logs` | `kampodine deploy logs` |
154
+ | `kamal app exec` | `kampodine deploy shell` (or `deploy shell exec -- <cmd>`) |
155
+ | (no equivalent) | `kampodine deploy list` / `deploy prune` / `deploy restart` / `status` |
156
+ | `kamal reboot / app boot` | `rc-service <app> restart` over SSH (init-aware: systemctl on systemd hosts) |
157
+ | kamal's per-env config files | `kampodine config` profiles (`--profile` everywhere) |
158
+ | (no equivalent) | `kampodine metrics` / `backup list|download|verify|restore-plan` |
97
159
 
98
160
  ## Building the expected image
99
161
 
@@ -156,9 +218,10 @@ automation is on the roadmap; today the stream is the automated path.
156
218
  ## Prerequisites
157
219
 
158
220
  Deploy machine: node ≥ 20, podman, ssh key access to the target, `oci` CLI
159
- (for bluegreen / image-import / dns), `jq` (for dns record surgery), and
160
- whatever secret-resolution your env-file step uses (kampodine is agnostic;
161
- the reference setup uses [varlock](https://varlock.dev) + pass).
221
+ (for bluegreen / image-import / dns / backup), `jq` (for dns record surgery
222
+ + profile config reads), and whatever secret-resolution your env-file step
223
+ uses (kampodine is agnostic; the reference setup uses
224
+ [varlock](https://varlock.dev) + pass).
162
225
 
163
226
  Target: a converged Alpine + Podman + OpenRC host (see above), reachable over
164
227
  ssh as root, with kamal-proxy running.
package/cli.js CHANGED
@@ -16,6 +16,9 @@ const commands = {
16
16
  migrate: "migrate.sh",
17
17
  env: "env.sh",
18
18
  dns: "dns.sh",
19
+ config: "config.sh",
20
+ metrics: "metrics.sh",
21
+ backup: "backup.sh",
19
22
  };
20
23
 
21
24
  // Command index, grouped vercel-style. Every command (and every sub-step of
@@ -30,16 +33,33 @@ DEPLOY
30
33
  bluegreen reserved-IP blue/green pair: status | init | provision | flip | rollback (each sub-step has --help)
31
34
  migrate tenant db migrations over SSH
32
35
 
36
+ DEPLOY LIFECYCLE
37
+ deploy list deployment history: VM sha-tagged images (running one marked) merged with the local ledger + total deployments count
38
+ deploy prune reclaim VM disk: remove old sha-tagged images (keeps running + ts-rollback + newest N); --dry-run prints exact commands
39
+ deploy logs tail the running api container's logs (--lines N)
40
+ deploy restart restart the api service (init-aware: rc-service on Alpine, systemctl on systemd hosts)
41
+ deploy shell interactive sh in the api container (exec -- <cmd> for one-shot)
42
+
33
43
  INFRA
34
44
  vm-prepare first-run bootstrap of a bare Alpine host (OpenRC + podman stack)
35
45
  image-import golden qcow2 -> OCI custom image
36
- status live health through the proxy + the blue/green pair view
46
+ status live health + deployment count + VM disk/image/service state + metrics (--verbose) + the blue/green pair view
37
47
 
38
48
  DNS
39
49
  dns OCI DNS records (oci config-file / instance-principal auth ONLY): records | add | rm
40
50
 
41
51
  ENV
42
- env remote app env file (/etc/esellar/env, 0600): list | push | pull — values NEVER printed, fingerprints only
52
+ env remote app env file (/etc/kampodine/env, 0600): list | push | pull — values NEVER printed, fingerprints only
53
+
54
+ CONFIG
55
+ config per-instance profiles (~/.kampodine/config.json, 0700/0600): init | list | show | set-default | remove
56
+ resolution everywhere: --profile flag > KAMPODINE_PROFILE env > defaultProfile > legacy env
57
+
58
+ METRICS
59
+ metrics one-shot VM snapshot over SSH: load, memory, disk (images/data/other), containers, top procs; --watch N; --warn-disk exits 1
60
+
61
+ BACKUP
62
+ backup OCI Object Storage backups (config-file / instance-principal auth ONLY): list | download | verify | restore-plan
43
63
 
44
64
  Every command supports --help with usage + examples. All further args pass through to the underlying script.
45
65
  `;
package/kampodine.md CHANGED
@@ -74,17 +74,27 @@ no registry, no tunnel, no docker, no systemd — SSH + podman only.
74
74
  npm, with package tests (script contract gates: `bash -n` + shellcheck
75
75
  over every script; cli dispatch contract: help/version/exit codes/bin
76
76
  integrity)
77
+ - [x] per-instance profiles (`config`) — flat, multi-instance-ready, one
78
+ resolution ladder everywhere (v0.6.0)
79
+ - [x] one-shot VM metrics snapshot (`metrics`, `status --verbose`) —
80
+ continuous monitoring stays external (Beszel) (v0.6.0)
81
+ - [x] OCI Object Storage backup family (`backup list/download/verify/
82
+ restore-plan`) with policy-denied LIST guidance and read-only
83
+ restore-verify over stdin (v0.6.0)
84
+ - [x] host init auto-detection (openrc vs systemd) — action-resolution
85
+ groundwork for non-Alpine targets, Alpine byte-identical (v0.6.0)
77
86
 
78
87
  **In progress:**
79
88
  - [ ] reference sweep (docs → kampodine invocations)
80
89
 
81
90
  ## Roadmap
82
91
 
83
- - `kampodine status` — live served sha + pair view in one command
84
92
  - registry-path automation (mirror mode: push to any OCI registry,
85
93
  VM-side pull, same health-gate/cutover tail)
86
- - genericized defaults (env schema path, service/container names,
87
- deploy-host env) driven by config
94
+ - non-Alpine targets on top of the init auto-detection groundwork
95
+ (per-distro bootstrap is explicitly OUT of scope for now)
96
+ - optional `defaults` block in config.json feeding unset profile fields
97
+ (the schema already leaves room; NO inheritance exists today)
88
98
 
89
99
  ## Non-goals
90
100
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kampodine",
3
- "version": "0.3.0",
3
+ "version": "0.6.0",
4
4
  "description": "Kamal-style deploys for Alpine + Podman hosts, built on kamal-proxy",
5
5
  "license": "AGPL-3.0",
6
6
  "type": "module",
@@ -0,0 +1,290 @@
1
+ #!/usr/bin/env bash
2
+ # backup.sh — OCI Object Storage backup family (libSQL db backups).
3
+ #
4
+ # AUTH RULE (hard, same as dns.sh): the `oci` CLI's own auth ONLY — its
5
+ # config file (--profile = OCI CONFIG profile) or instance principal (when
6
+ # run ON a VM). kampodine NEVER accepts, stores, or logs credential material:
7
+ # no tokens, no key material, nothing secret-shaped in args or output.
8
+ #
9
+ # The default bucket is esellar-libsql-backups (--bucket overrides). NOTE:
10
+ # --profile here is the OCI config profile (dns convention) — kampodine
11
+ # INSTANCE profiles do not apply to bucket ops; `backup verify` (the only
12
+ # ssh leg) resolves its VM target through the standard ladder:
13
+ # --host flag | KAMPODINE_PROFILE | config defaultProfile | KAMPODINE_HOST.
14
+ #
15
+ # LIST vs GET: object LIST can be denied by policy while GET still works —
16
+ # `list` prints policy guidance when denied; `download` needs only the name.
17
+ #
18
+ # Usage:
19
+ # kampodine backup list [--prefix <prefix>] [--bucket <name>] [--profile <oci>] [--instance-principal]
20
+ # kampodine backup download <object> [--out <file>] [--bucket <name>] [--profile <oci>] [--instance-principal]
21
+ # kampodine backup verify <local.db|.tgz> [--host <user@ip>] [--ssh-key <path>]
22
+ # kampodine backup restore-plan <object> [--bucket <name>]
23
+ #
24
+ # verify is READ-ONLY: the local db image travels over ssh stdin to the VM's
25
+ # /usr/local/bin/restore-verify; .tgz archives are extracted to a scratch
26
+ # dir and each db member piped the same way. It NEVER writes into
27
+ # /data/tenants. restore-plan prints the documented stop/swap/start sequence
28
+ # and NEVER executes a mutating step.
29
+ set -euo pipefail
30
+
31
+ HERE="$(cd "$(dirname "$0")" && pwd)"
32
+ # shellcheck source=common.sh
33
+ source "$HERE/common.sh"
34
+
35
+ BUCKET="${KAMPODINE_BACKUP_BUCKET:-esellar-libsql-backups}"
36
+ OCI_PROFILE_NAME="${OCI_PROFILE:-default}"
37
+ AUTH_ARGS=()
38
+
39
+ say() { printf '\033[1;34m[backup]\033[0m %s\n' "$*"; }
40
+ die() { printf '\033[1;31m[backup] FAIL:\033[0m %s\n' "$*" >&2; exit 1; }
41
+ usage() {
42
+ local code="${1:-0}"
43
+ printf 'Usage:\n'
44
+ grep '^# kampodine backup' "$0" | sed 's/^# //'
45
+ cat <<'EOF'
46
+
47
+ Auth is the oci CLI's own — its config file (--profile) or instance
48
+ principal. kampodine never accepts, stores, or logs credential material.
49
+ Bucket: --bucket (default esellar-libsql-backups). LIST-denied by policy is
50
+ not fatal for download/verify — GET works with just the object name; `list`
51
+ prints the exact policy shape when denied. verify is read-only over ssh
52
+ stdin into /usr/local/bin/restore-verify; scratch dirs only, NEVER writes
53
+ into /data/tenants. restore-plan prints the documented stop/swap/start
54
+ sequence and NEVER executes anything.
55
+
56
+ Examples:
57
+ kampodine backup list --prefix tenants/
58
+ kampodine backup list --bucket my-backups --profile my-oci-profile
59
+ kampodine backup download tenants/acme/20261008T050000Z.db --out /tmp/restore.db
60
+ kampodine backup verify /tmp/restore.db --host root@203.0.113.10
61
+ kampodine backup verify ./bundle.tgz # every .db member, read-only
62
+ kampodine backup restore-plan tenants/acme/20261008T050000Z.db
63
+ EOF
64
+ exit "$code"
65
+ }
66
+
67
+ # --- shared helpers -------------------------------------------------------------
68
+
69
+ need_oci() {
70
+ command -v oci >/dev/null 2>&1 \
71
+ || die "oci CLI not found — brew install oci-cli (https://docs.oracle.com/en-us/iaas/Content/API/SDKDocs/cliinstall.htm), then: oci setup config"
72
+ }
73
+
74
+ # backup_expected_digest "<oci os object head json>" -> "sha256:<hex>" |
75
+ # "md5:<base64>" | "" (verification skipped). Prefers sha256 metadata
76
+ # (opc-meta-sha256 / content-sha256), falls back to opc-content-md5.
77
+ backup_expected_digest() {
78
+ local j="$1" v=""
79
+ command -v jq >/dev/null 2>&1 || { printf ''; return 0; }
80
+ v="$(jq -r '.data["opc-meta-sha256"] // .data["content-sha256"] // empty' <<<"$j" 2>/dev/null || true)"
81
+ if [[ -n "$v" ]]; then
82
+ printf 'sha256:%s' "$v"
83
+ return 0
84
+ fi
85
+ v="$(jq -r '.data["opc-content-md5"] // empty' <<<"$j" 2>/dev/null || true)"
86
+ if [[ -n "$v" ]]; then
87
+ printf 'md5:%s' "$v"
88
+ fi
89
+ }
90
+
91
+ # hash_file <file> <sha256|md5> -> prints the digest (sha256: hex, md5:
92
+ # base64 — the same encoding OCI reports). Portable across macOS/Linux.
93
+ hash_file() {
94
+ local f="$1" alg="$2"
95
+ if [[ "$alg" == "sha256" ]]; then
96
+ if command -v sha256sum >/dev/null 2>&1; then
97
+ sha256sum "$f" | awk '{print $1}'
98
+ elif command -v shasum >/dev/null 2>&1; then
99
+ shasum -a 256 "$f" | awk '{print $1}'
100
+ else
101
+ return 1
102
+ fi
103
+ else
104
+ if command -v openssl >/dev/null 2>&1; then
105
+ openssl dgst -md5 -binary "$f" | base64
106
+ else
107
+ return 1
108
+ fi
109
+ fi
110
+ }
111
+
112
+ # --- dispatch -------------------------------------------------------------------
113
+ SUB="${1:-}"
114
+ case "$SUB" in
115
+ list|download|verify|restore-plan) shift ;;
116
+ -h|--help|help) usage 0 ;;
117
+ "") usage 2 ;;
118
+ *)
119
+ printf 'kampodine backup: unknown subcommand: %s\n\n' "$SUB" >&2
120
+ usage 2
121
+ ;;
122
+ esac
123
+
124
+ BUCKET_FLAG="" PREFIX="" OUT="" OBJ=""
125
+ OCI_PROFILE_FLAG="" INSTANCE_PRINCIPAL=0
126
+ HOST_FLAG="" KEY_FLAG=""
127
+ KAMPODINE_HOST="${KAMPODINE_HOST:-}"
128
+ SSH_KEY="${KAMPODINE_SSH_KEY:-}"
129
+ PROFILE_FLAG=""
130
+ POSITIONAL=()
131
+ while [[ $# -gt 0 ]]; do
132
+ case "$1" in
133
+ --bucket) BUCKET_FLAG="$2"; shift 2 ;;
134
+ --prefix) PREFIX="$2"; shift 2 ;;
135
+ --out) OUT="$2"; shift 2 ;;
136
+ --profile) OCI_PROFILE_FLAG="$2"; shift 2 ;;
137
+ --instance-principal) INSTANCE_PRINCIPAL=1; shift ;;
138
+ --host) HOST_FLAG="$2"; KAMPODINE_HOST="$2"; shift 2 ;;
139
+ --ssh-key) KEY_FLAG="$2"; SSH_KEY="$2"; shift 2 ;;
140
+ --kampodine-profile) PROFILE_FLAG="$2"; shift 2 ;;
141
+ -h|--help) usage 0 ;;
142
+ --) shift; POSITIONAL+=("$@"); break ;;
143
+ *) POSITIONAL+=("$1"); shift ;;
144
+ esac
145
+ done
146
+ [[ -n "$BUCKET_FLAG" ]] && BUCKET="$BUCKET_FLAG"
147
+ [[ -n "$OCI_PROFILE_FLAG" ]] && OCI_PROFILE_NAME="$OCI_PROFILE_FLAG"
148
+ (( INSTANCE_PRINCIPAL == 1 )) && AUTH_ARGS=(--auth instance_principal)
149
+ OBJ="${POSITIONAL[0]:-}"
150
+
151
+ case "$SUB" in
152
+ list)
153
+ need_oci
154
+ PREFIX_ARGS=()
155
+ [[ -n "$PREFIX" ]] && PREFIX_ARGS=(--prefix "$PREFIX")
156
+ say "objects in bucket $BUCKET${PREFIX:+ (prefix: $PREFIX)} (profile: $OCI_PROFILE_NAME${INSTANCE_PRINCIPAL:+, instance-principal}):"
157
+ # NOTE: --profile stays ON the first line (script-gates static scan reads it there)
158
+ if ! out="$(oci os object list --all --bucket-name "$BUCKET" "${PREFIX_ARGS[@]+"${PREFIX_ARGS[@]}"}" --profile "$OCI_PROFILE_NAME" ${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"} 2>&1)"; then
159
+ case "$out" in
160
+ *NotAuthorizedOrNotFound*|*NotAuthorized*|*NotAuthenticated*|*"Authorization failed"*)
161
+ printf '\033[1;31m[backup] FAIL:\033[0m LIST denied — this identity (profile %s / instance principal) lacks INSPECT+READ on bucket %s.\n' "$OCI_PROFILE_NAME" "$BUCKET" >&2
162
+ printf 'The bucket compartment needs a policy like:\n' >&2
163
+ printf ' Allow dynamic-group <your-dg> to read buckets in compartment <compartment>\n' >&2
164
+ printf ' Allow dynamic-group <your-dg> to read objects in compartment <compartment>\n' >&2
165
+ printf 'GET works without LIST once the object name is known:\n' >&2
166
+ printf ' kampodine backup download <object> --bucket %s\n' "$BUCKET" >&2
167
+ exit 1
168
+ ;;
169
+ *)
170
+ die "object list failed: $out"
171
+ ;;
172
+ esac
173
+ fi
174
+ rows="$(jq -r '.data.objects[]? | [.name, (.size | tostring), (.timeCreated // "-")] | @tsv' <<<"$out")"
175
+ if [[ -z "$rows" ]]; then
176
+ say "(no objects${PREFIX:+ with prefix $PREFIX})"
177
+ exit 0
178
+ fi
179
+ printf '%-56s %-12s %s\n' "NAME" "SIZE" "UPDATED"
180
+ while IFS=$'\t' read -r n s t; do
181
+ printf '%-56s %-12s %s\n' "$n" "$s" "$t"
182
+ done <<<"$rows"
183
+ ;;
184
+
185
+ download)
186
+ need_oci
187
+ [[ -n "$OBJ" ]] || die "usage: kampodine backup download <object> [--out <file>] (--help)"
188
+ OUT="${OUT:-$(basename "$OBJ")}"
189
+ head_json="$(oci os object head --bucket-name "$BUCKET" --name "$OBJ" --profile "$OCI_PROFILE_NAME" ${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"} 2>/dev/null || true)"
190
+ expected="$(backup_expected_digest "$head_json")"
191
+ # 0600 FROM CREATION: umask 077 around the write; chmod after.
192
+ ( umask 077; oci os object get --bucket-name "$BUCKET" --name "$OBJ" --file "$OUT" --profile "$OCI_PROFILE_NAME" ${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"} >/dev/null ) \
193
+ || die "object get failed for $OBJ (bucket $BUCKET)"
194
+ chmod 600 "$OUT"
195
+ size="$(wc -c < "$OUT" | tr -d '[:space:]')"
196
+ fp="$(hash_file "$OUT" sha256 || true)"
197
+ say "downloaded $OBJ -> $OUT (0600, $size bytes)"
198
+ [[ -n "$fp" ]] && say "sha256: ${fp:0:16}…"
199
+ if [[ -z "$expected" ]]; then
200
+ say "integrity: skipped (no digest metadata on the object)"
201
+ else
202
+ alg="${expected%%:*}"
203
+ want="${expected#*:}"
204
+ got="$(hash_file "$OUT" "$alg" 2>/dev/null || true)"
205
+ if [[ -z "$got" || "$got" != "$want" ]]; then
206
+ printf '\033[1;31m[backup] FAIL:\033[0m digest MISMATCH (%s)\n' "$alg" >&2
207
+ printf ' expected: %s\n' "$want" >&2
208
+ printf ' got : %s\n' "$got" >&2
209
+ exit 1
210
+ fi
211
+ say "integrity: verified ($alg)"
212
+ fi
213
+ ;;
214
+
215
+ verify)
216
+ [[ -n "$OBJ" ]] || die "usage: kampodine backup verify <local.db|.tgz> (--help)"
217
+ [[ -f "$OBJ" ]] || die "no such file: $OBJ"
218
+ case "$OBJ" in
219
+ *.db|*.sqlite|*.sqlite3) VERIFY_MODE="db" ;;
220
+ *.tgz|*.tar.gz) VERIFY_MODE="tgz" ;;
221
+ *) die "backup verify takes a .db (or .sqlite/.sqlite3) or .tgz file — got: $OBJ" ;;
222
+ esac
223
+ # ssh leg resolves through the kampodine ladder: --host flag | profile
224
+ profile_resolve "$PROFILE_FLAG" "$HOST_FLAG" "$KEY_FLAG"
225
+ SSH_KEY="${KEY_FLAG:-${KAMPODINE_SSH_KEY:-}}"
226
+ [[ -n "$KAMPODINE_HOST" ]] || die "verify needs a VM target: --host root@<ip>, --kampodine-profile <name>, KAMPODINE_PROFILE, config defaultProfile, or KAMPODINE_HOST"
227
+ SSH_ARGS=(-o ConnectTimeout=10 -o BatchMode=yes)
228
+ [[ -n "$SSH_KEY" ]] && SSH_ARGS+=(-i "$SSH_KEY")
229
+ # $1 is a composed remote command — client-side expansion is the design.
230
+ # shellcheck disable=SC2029
231
+ vm() { ssh "${SSH_ARGS[@]}" "$KAMPODINE_HOST" "$1"; }
232
+ if [[ "$(uname -s)" == "Darwin" ]]; then
233
+ SSH_AUTH_SOCK="$(launchctl getenv SSH_AUTH_SOCK 2>/dev/null || true)"
234
+ export SSH_AUTH_SOCK
235
+ fi
236
+
237
+ if [[ "$VERIFY_MODE" == "db" ]]; then
238
+ say "piping $OBJ over ssh stdin to /usr/local/bin/restore-verify on $KAMPODINE_HOST (read-only; scratch only — NEVER writes into /data/tenants)"
239
+ cat "$OBJ" | vm "/usr/local/bin/restore-verify /dev/stdin"
240
+ say "OK — restore-verify accepted the db image"
241
+ else
242
+ scratch="$(mktemp -d "${TMPDIR:-/tmp}/kampodine-verify.XXXXXX")"
243
+ trap 'rm -rf "$scratch"' EXIT
244
+ tar -xzf "$OBJ" -C "$scratch"
245
+ members="$(find "$scratch" -type f \( -name '*.db' -o -name '*.sqlite' -o -name '*.sqlite3' \) | LC_ALL=C sort)"
246
+ [[ -n "$members" ]] || die "no .db/.sqlite members in $OBJ — nothing to verify"
247
+ say "piping every .db member of $OBJ over ssh stdin (read-only; scratch dir $scratch)"
248
+ while IFS= read -r m; do
249
+ member_name="${m#"$scratch"/}"
250
+ say "verify member: $member_name"
251
+ cat "$m" | vm "/usr/local/bin/restore-verify /dev/stdin" \
252
+ || die "restore-verify FAILED for $member_name"
253
+ done <<<"$members"
254
+ say "OK — every db member passed restore-verify"
255
+ fi
256
+ ;;
257
+
258
+ restore-plan)
259
+ [[ -n "$OBJ" ]] || usage 2
260
+ # PRINT-ONLY by design: no oci, no ssh, no mutation — the plan is
261
+ # documentation bound to this object, never a runner.
262
+ cat <<EOF
263
+ == restore plan (print-only — kampodine NEVER executes these steps) ==
264
+ object : $OBJ
265
+ bucket : $BUCKET
266
+
267
+ 1. stage the backup locally (download works even when LIST is denied):
268
+ kampodine backup download '$OBJ' --out /tmp/restore.db
269
+ 2. copy to the VM scratch dir (never directly into /data/tenants):
270
+ scp /tmp/restore.db root@<vm>:/tmp/restore.db
271
+ 3. verify BEFORE touching anything (read-only, over ssh stdin):
272
+ kampodine backup verify /tmp/restore.db --host root@<vm>
273
+ 4. STOP the api so writes drain:
274
+ ssh root@<vm> 'rc-service kampodine-api stop'
275
+ 5. snapshot the current state (your rollback point):
276
+ ssh root@<vm> 'cp -a /data/tenants/<tenant-dir> /data/tenants/<tenant-dir>.pre-restore'
277
+ 6. SWAP the restored file in (permissions matter):
278
+ ssh root@<vm> 'install -m 640 -o root -g root /tmp/restore.db /data/tenants/<tenant-dir>/db.sqlite'
279
+ 7. START and smoke:
280
+ ssh root@<vm> 'rc-service kampodine-api start'
281
+ kampodine status
282
+
283
+ Notes:
284
+ - step 3 is this family's restore-verify contract: the db image travels
285
+ over ssh stdin; /usr/local/bin/restore-verify checks it read-only.
286
+ - kampodine NEVER executes any step above — this plan documents the
287
+ infra runbook sequence for this object; a human runs each step.
288
+ EOF
289
+ ;;
290
+ esac