kampodine 0.2.1 → 0.3.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,11 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 — 2026-10-07
4
+
5
+ Full-lifecycle orchestration with first-class help — the "mini vercel CLI"
6
+ release.
7
+
8
+ - `env` — manage the remote app env file (`/etc/esellar/env`, 0600 root):
9
+ `list` (KEY + value fingerprints only — length + first 2 chars, values
10
+ NEVER printed), `push --file` (0600 temp from creation via umask + atomic
11
+ `mv` + restart hint), `pull` (raw payload to stdout or `--out` 0600, masked
12
+ summary), `fingerprint` (local masking preview). Host/key resolution
13
+ identical to deploy (`--host` | `ESPELLAR_HOST`, `--ssh-key` |
14
+ `KAMPODINE_SSH_KEY` | ssh-agent)
15
+ - `dns` — OCI DNS records: `records`, `add`, `rm`. Shells out to the `oci`
16
+ CLI; auth is the OCI config file (`--profile`) or `--instance-principal`
17
+ ONLY — no credential material is ever accepted, stored, or logged. Types
18
+ pinned to A|AAAA|CNAME; name/type/value/ttl validated locally before any
19
+ provider call; `add` merges into the existing RRSet, `rm` filters it
20
+ - Help everywhere: `kampodine --help` prints a grouped command index
21
+ (DEPLOY / INFRA / DNS / ENV, vercel-style); every command and every
22
+ bluegreen/env/dns sub-step answers `--help`/`-h` with `Usage:` +
23
+ `Examples:` — pinned by a test that enumerates all of them
24
+ - Tests grew from 38 to 114: help coverage for every subcommand, env
25
+ fingerprint masking against fixture ssh shims (a value in output fails the
26
+ suite), dns arg validation, top-level index groups, oci `--profile` gate
27
+ extended to `dns` calls
28
+
3
29
  ## 0.2.1 — 2026-10-07
4
30
 
5
31
  Docs, hygiene, and deploy hardening.
6
32
 
7
- - Global-audience README: removed origin-specific details (service names,
8
- drill timings, private-repo references)
33
+ - Global-audience README: rewritten so it reads clean outside the
34
+ original deployment (no service names, timings, or internal history)
9
35
  - Removed origin-specific defaults that leaked a real host: the deploy
10
36
  target and Host-header defaults are now placeholders — set `ESPELLAR_HOST`
11
37
  and `PROXY_HOST` / `APP_HOST_HEADER` explicitly (they were env overrides
@@ -14,8 +40,8 @@ Docs, hygiene, and deploy hardening.
14
40
  cutover image, built with `GIT_SHA` instead of `VITE_BUILD_ID`); the
15
41
  health probe now runs on the VM host (busybox wget) so scratch/Go images
16
42
  without a shell probe cleanly
17
- - Script/test comments: dropped private runbook and drill-log references,
18
- kept the technical rationale
43
+ - Script/test comments: tightened wording across the shipped scripts,
44
+ keeping the technical rationale
19
45
  - Fixed standalone-repo test paths (two vm-prepare suites still resolved the
20
46
  old monorepo layout); repo gained `.gitignore` + `package-lock.json`
21
47
 
package/README.md CHANGED
@@ -48,12 +48,28 @@ kampodine deploy:
48
48
  - **`kampodine deploy`** — stream deploy; `--version <sha>` restreams an
49
49
  existing build; `--rollback [sha]` is an instant image-tag rollback (the
50
50
  previous image stays on the VM for exactly this)
51
+ - **`kampodine env`** — manage the remote app env file (`/etc/esellar/env`,
52
+ 0600 root) without ever printing a secret: `list` shows KEY + fingerprints
53
+ only (value length + first 2 chars), `push --file <env>` uploads over ssh
54
+ stdin into a 0600 temp + atomic `mv`, `pull` streams the raw payload to
55
+ stdout or `--out` (written 0600). Host/key resolution is identical to
56
+ deploy: `--host root@<ip>` | `ESPELLAR_HOST`, `--ssh-key <path>` |
57
+ `KAMPODINE_SSH_KEY` | ssh-agent. `env fingerprint` previews the masking
58
+ for any local file — values never leave stdin
59
+ - **`kampodine dns`** — OCI DNS records for the post-sslip.io era:
60
+ `records`, `add --name <label> --type A|AAAA|CNAME --value <target>
61
+ [--ttl 300]`, `rm`. Shell out to the `oci` CLI; auth is the OCI config
62
+ file (`--profile`) or `--instance-principal` ONLY — kampodine never
63
+ accepts, stores, or logs credential material. `add` merges into the
64
+ existing RRSet (round-robin survives), `rm` filters it; types/ttl/value
65
+ are validated locally before any provider call
51
66
  - **`kampodine bluegreen status|init|provision|flip|rollback`** — reserved
52
67
  public IP management for a two-instance blue/green pair on OCI: zero DNS
53
68
  change, health-gated flips with an ACME-first cutover (reserved IP assigned
54
69
  → cert issued for its hostname → served sha verified) and automatic
55
70
  rollback. A guest **anchor watcher** installed by `vm-prepare` holds the
56
71
  flip's IP half — add-only and inert until a flip writes its anchor config.
72
+ Every sub-step has its own `--help` with usage + examples
57
73
  - **`kampodine vm-prepare`** — first-run bootstrap of a bare Alpine host:
58
74
  sshd hardening (fresh VMs pass unattended), busybox-wget health probes (the
59
75
  golden image ships no curl), the blue-green anchor watcher, the podman
@@ -63,6 +79,11 @@ kampodine deploy:
63
79
  use `bluegreen provision` for ARM targets
64
80
  - **`kampodine migrate`** — sqlite migrations over SSH
65
81
 
82
+ **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`.
86
+
66
87
  ## Kamal parity
67
88
 
68
89
  | kamal | kampodine |
@@ -135,9 +156,9 @@ automation is on the roadmap; today the stream is the automated path.
135
156
  ## Prerequisites
136
157
 
137
158
  Deploy machine: node ≥ 20, podman, ssh key access to the target, `oci` CLI
138
- (for bluegreen / image-import), and whatever secret-resolution your env-file
139
- step uses (kampodine is agnostic; the reference setup uses
140
- [varlock](https://varlock.dev) + pass).
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).
141
162
 
142
163
  Target: a converged Alpine + Podman + OpenRC host (see above), reachable over
143
164
  ssh as root, with kamal-proxy running.
package/cli.js CHANGED
@@ -14,20 +14,34 @@ const commands = {
14
14
  "vm-prepare": "vm-prepare.sh",
15
15
  "image-import": "image-import.sh",
16
16
  migrate: "migrate.sh",
17
+ env: "env.sh",
18
+ dns: "dns.sh",
17
19
  };
18
20
 
21
+ // Command index, grouped vercel-style. Every command (and every sub-step of
22
+ // bluegreen/env/dns) answers --help with Usage + Examples — pinned by
23
+ // test/help-coverage.test.ts.
19
24
  const usage = `kampodine — kamal-alternative CLI for Alpine + Podman deploys, built on kamal-proxy
20
25
 
21
26
  Usage: kampodine <command> [args...]
22
27
 
23
- Commands:
28
+ DEPLOY
24
29
  deploy stream deploy (podman save | ssh podman load) with sha-verified health gate; --rollback [sha] = instant image-tag rollback
25
- bluegreen reserved-IP blue/green pair: status (pair + reserved IP + health) | init | provision | flip | rollback
30
+ bluegreen reserved-IP blue/green pair: status | init | provision | flip | rollback (each sub-step has --help)
31
+ migrate tenant db migrations over SSH
32
+
33
+ INFRA
26
34
  vm-prepare first-run bootstrap of a bare Alpine host (OpenRC + podman stack)
27
35
  image-import golden qcow2 -> OCI custom image
28
- migrate tenant db migrations over SSH
36
+ status live health through the proxy + the blue/green pair view
37
+
38
+ DNS
39
+ dns OCI DNS records (oci config-file / instance-principal auth ONLY): records | add | rm
40
+
41
+ ENV
42
+ env remote app env file (/etc/esellar/env, 0600): list | push | pull — values NEVER printed, fingerprints only
29
43
 
30
- All further args pass through to the underlying script.
44
+ Every command supports --help with usage + examples. All further args pass through to the underlying script.
31
45
  `;
32
46
 
33
47
  const [cmd, ...args] = process.argv.slice(2);
package/kampodine.md CHANGED
@@ -75,17 +75,16 @@ no registry, no tunnel, no docker, no systemd — SSH + podman only.
75
75
  over every script; cli dispatch contract: help/version/exit codes/bin
76
76
  integrity)
77
77
 
78
- **In flight:**
78
+ **In progress:**
79
79
  - [ ] reference sweep (docs → kampodine invocations)
80
80
 
81
- **Todo:**
82
- - [ ] end-to-end verification: one real deploy through the CLI (zero-change
83
- restream of the live image, full pipeline verify)
84
- - [ ] `kampodine status` — live served sha + pair view in one command
85
- - [ ] registry-path automation (mirror mode: push to any OCI registry,
86
- VM-side pull, same health-gate/cutover tail)
87
- - [ ] genericize origin-coupled defaults (env schema path, service/container
88
- names, deploy-host env) into config
81
+ ## Roadmap
82
+
83
+ - `kampodine status` — live served sha + pair view in one command
84
+ - registry-path automation (mirror mode: push to any OCI registry,
85
+ VM-side pull, same health-gate/cutover tail)
86
+ - genericized defaults (env schema path, service/container names,
87
+ deploy-host env) driven by config
89
88
 
90
89
  ## Non-goals
91
90
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kampodine",
3
- "version": "0.2.1",
3
+ "version": "0.3.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",
@@ -80,6 +80,95 @@ SSH_OPTS=(-o ConnectTimeout=6 -o BatchMode=yes -o StrictHostKeyChecking=accept-n
80
80
  say() { printf '%s\n' "$*"; }
81
81
  die() { printf '✋ %s\n' "$*" >&2; exit 1; }
82
82
 
83
+ usage() {
84
+ printf 'Usage:\n'
85
+ grep '^# kampodine bluegreen' "$0" | sed 's/^# //'
86
+ cat <<'EOF'
87
+
88
+ Each sub-step has its own --help: status | init | provision | flip | rollback.
89
+ Env: OCI_PROFILE (default esellar-api), OCI_COMPARTMENT (default esellar).
90
+
91
+ Examples:
92
+ kampodine bluegreen status
93
+ kampodine bluegreen init
94
+ kampodine bluegreen provision green
95
+ kampodine bluegreen flip --to green
96
+ kampodine bluegreen rollback
97
+ EOF
98
+ exit 0
99
+ }
100
+
101
+ step_usage() {
102
+ case "$1" in
103
+ status)
104
+ cat <<'EOF'
105
+ Usage:
106
+ kampodine bluegreen status
107
+
108
+ Pair view: reserved IP + holder, both instances (ocid, ip, AD), per-color app
109
+ health. Read-only.
110
+
111
+ Examples:
112
+ kampodine bluegreen status
113
+ EOF
114
+ ;;
115
+ init)
116
+ cat <<'EOF'
117
+ Usage:
118
+ kampodine bluegreen init
119
+
120
+ Create the DORMANT reserved public IP (unassigned — no instance attached).
121
+ Idempotent: exits 0 when the reserved IP already exists.
122
+
123
+ Examples:
124
+ kampodine bluegreen init
125
+ EOF
126
+ ;;
127
+ provision)
128
+ cat <<'EOF'
129
+ Usage:
130
+ kampodine bluegreen provision <blue|green>
131
+
132
+ Launch the second instance from the golden image (native UEFI custom image,
133
+ or the INJECT route when none exists), then vm-prepare it.
134
+
135
+ Examples:
136
+ kampodine bluegreen provision green
137
+ kampodine vm-prepare --host root@<new-ip> # after provision hands you the IP
138
+ EOF
139
+ ;;
140
+ flip)
141
+ cat <<'EOF'
142
+ Usage:
143
+ kampodine bluegreen flip --to <blue|green> [--force]
144
+
145
+ ACME-first health-gated cutover: health gate on the target, the guest anchor
146
+ watcher claims the reserved-IP half, OCI assigns, the cert issues on the
147
+ target, verify through the reserved IP — any post-assign failure auto-rolls
148
+ back.
149
+
150
+ Examples:
151
+ kampodine bluegreen flip --to green
152
+ kampodine bluegreen flip --to green --force
153
+ EOF
154
+ ;;
155
+ rollback)
156
+ cat <<'EOF'
157
+ Usage:
158
+ kampodine bluegreen rollback
159
+
160
+ Unassign the reserved IP back to DORMANT: holder guest cleanup (anchor.conf
161
+ removal + address delete) then the OCI unassign. To move traffic to the other
162
+ color of a real pair instead, use flip --to <other>.
163
+
164
+ Examples:
165
+ kampodine bluegreen rollback
166
+ EOF
167
+ ;;
168
+ esac
169
+ exit 0
170
+ }
171
+
83
172
  compartment_ocid() {
84
173
  local ocid
85
174
  ocid="$(oci iam compartment list --all --profile "$PROFILE" \
@@ -357,6 +446,18 @@ inject_alpine() {
357
446
  }
358
447
 
359
448
  cmd="${1:-}"
449
+ # Any -h/--help in the args answers with usage for that sub-step (exit 0) —
450
+ # before any validation or OCI call, so help is always hermetic.
451
+ for help_arg in "$@"; do
452
+ case "$help_arg" in
453
+ -h|--help)
454
+ case "$cmd" in
455
+ status|init|provision|flip|rollback) step_usage "$cmd" ;;
456
+ *) usage ;;
457
+ esac
458
+ ;;
459
+ esac
460
+ done
360
461
  case "$cmd" in
361
462
  status)
362
463
  comp="$(compartment_ocid)"
package/scripts/deploy.sh CHANGED
@@ -26,7 +26,7 @@
26
26
  set -euo pipefail
27
27
 
28
28
  REPO_ROOT="$(git rev-parse --show-toplevel)"
29
- ESPELLAR_HOST="${ESPELLAR_HOST:-root@app.example.com}"
29
+ ESPELLAR_HOST="${ESPELLAR_HOST:-}"
30
30
  PROXY_HOST="${PROXY_HOST:-app.example.com}"
31
31
  ENV_FILE_REMOTE="/etc/esellar/env"
32
32
  DEPLOYED_SHA_FILE="/etc/esellar/deployed-sha"
@@ -37,7 +37,17 @@ MODE="deploy"
37
37
  VERSION=""
38
38
  SKIP_SMOKE=0
39
39
  REFRESH_CONFIG=0
40
- SSH_KEY="${ESSELLAR_SSH_KEY:-}"
40
+ # SSH key resolution — GENERIC, no hardcoded personal paths:
41
+ # 1. --ssh-key flag (explicit, per-invocation)
42
+ # 2. KAMPODINE_SSH_KEY env (project-level: direnv / .envrc / export)
43
+ # 3. ESSELLAR_SSH_KEY env (legacy alias, kept for existing setups)
44
+ # 4. empty → ssh-agent and/or the operator's ~/.ssh/config Host block
45
+ # (the POSIX way: per-host IdentityFile belongs in ssh config, not here)
46
+ if [ -n "${KAMPODINE_SSH_KEY:-}" ]; then
47
+ SSH_KEY="$KAMPODINE_SSH_KEY"
48
+ else
49
+ SSH_KEY=""
50
+ fi
41
51
  # Default image recipe = the TS api Containerfile. The Go cutover image rides
42
52
  # --dockerfile apps/api-go/Dockerfile.cutover (build context stays the repo
43
53
  # root for BOTH — the cutover Dockerfile path-prefixes its COPYs).
@@ -45,7 +55,22 @@ DOCKERFILE="${KAMPODINE_DOCKERFILE:-apps/api/Containerfile}"
45
55
 
46
56
  say() { printf '\033[1;34m[deploy]\033[0m %s\n' "$*"; }
47
57
  die() { printf '\033[1;31m[deploy] FAIL:\033[0m %s\n' "$*" >&2; exit 1; }
48
- usage() { grep '^# kampodine deploy' "$0" | sed 's/^# //'; exit 0; }
58
+ usage() {
59
+ cat <<'EOF'
60
+ Usage:
61
+ kampodine deploy [--host root@<ip>] [--version <sha7>] [--rollback [<sha7>]]
62
+ [--dockerfile <path>] [--ssh-key <path>] [--skip-smoke] [--refresh-config]
63
+
64
+ Examples:
65
+ EOF
66
+ grep '^# kampodine deploy' "$0" | sed 's/^# //'
67
+ cat <<'EOF'
68
+
69
+ Host/key resolution: --host | ESPELLAR_HOST; --ssh-key | KAMPODINE_SSH_KEY |
70
+ ESPELLAR_SSH_KEY | ssh-agent / ~/.ssh/config.
71
+ EOF
72
+ exit 0
73
+ }
49
74
 
50
75
  while [[ $# -gt 0 ]]; do
51
76
  case "$1" in
@@ -64,6 +89,7 @@ while [[ $# -gt 0 ]]; do
64
89
  esac
65
90
  done
66
91
  [[ "$VERSION" != *..* && "$VERSION" =~ ^[0-9a-f]{4,40}$|^$ ]] || die "--version must be a git sha fragment"
92
+ [ -n "$ESPELLAR_HOST" ] || die "set ESPELLAR_HOST=root@<vm-ip> (or a ~/.ssh/config Host alias via --host)"
67
93
 
68
94
  SSH_ARGS=(-o ConnectTimeout=10 -o BatchMode=yes)
69
95
  [[ -n "$SSH_KEY" ]] && SSH_ARGS+=(-i "$SSH_KEY")
@@ -71,7 +97,7 @@ SSH_ARGS=(-o ConnectTimeout=10 -o BatchMode=yes)
71
97
  # shellcheck disable=SC2029
72
98
  vm() { ssh "${SSH_ARGS[@]}" "$ESPELLAR_HOST" "$1"; }
73
99
 
74
- # --- macOS ssh-agent quirk (deploy-machine runbook) ---------------------------
100
+ # --- macOS ssh-agent quirk (first deploy from a fresh machine) ---------------
75
101
  if [[ "$(uname -s)" == "Darwin" ]]; then
76
102
  SSH_AUTH_SOCK="$(launchctl getenv SSH_AUTH_SOCK 2>/dev/null || true)"
77
103
  export SSH_AUTH_SOCK
@@ -150,10 +176,12 @@ vm "rc-service esellar-api restart" || die "esellar-api restart failed (rc-servi
150
176
  say "health probe (host-side wget /api/auth/ok + served-sha — works for node AND scratch images)…"
151
177
  HEALTH_OK=0
152
178
  for _ in $(seq 1 30); do
153
- # Probe from the VM HOST (busybox wget — the Go image is scratch: no node,
154
- # no shell; the -p 8080:8080 publish makes the port host-reachable for
155
- # every image flavor). Grep the JSON — same check the public smoke does.
156
- if vm "wget -qO- -T 3 http://127.0.0.1:8080/api/auth/ok 2>/dev/null | grep -q '\"git\":\"$VER'\\|\"build\":\"$VER'" 2>/dev/null; then
179
+ # wget runs on the VM (busybox, -p 8080:8080 publish); the served-sha
180
+ # grep runs LOCALLY — remote grep quoting is fragile (busybox BRE
181
+ # (busybox BRE alternation + nested quotes → permanent false-negative).
182
+ # grep -E keeps the alternation portable across BSD/GNU/busybox.
183
+ if vm "wget -qO- -T 3 http://127.0.0.1:8080/api/auth/ok 2>/dev/null" \
184
+ | grep -qE "\"(git|build)\":\"$VER"; then
157
185
  HEALTH_OK=1
158
186
  break
159
187
  fi
package/scripts/dns.sh ADDED
@@ -0,0 +1,264 @@
1
+ #!/usr/bin/env bash
2
+ # dns.sh — OCI DNS record management: the single surface for the coming DNS
3
+ # era (today the stack is raw IP + sslip.io; when real zones land, records
4
+ # are managed here, never by hand in the console).
5
+ #
6
+ # AUTH RULE (hard): the `oci` CLI's own auth ONLY — its config file
7
+ # (~/.oci/config, selected via --profile) or instance principal. kampodine
8
+ # NEVER accepts, stores, or logs credential material: no key flags, no
9
+ # credential env vars, nothing token-shaped in args or output. The OCI config
10
+ # file stays the single credential store.
11
+ #
12
+ # Types are pinned to A | AAAA | CNAME. `add` merges into the existing RRSet
13
+ # (round-robin records survive); `rm` filters it; both are idempotence-aware.
14
+ #
15
+ # Zone/compartment: OCI_PROFILE (default esellar-api), OCI_COMPARTMENT
16
+ # (default esellar). Zone defaults to the compartment's ONLY zone; pass
17
+ # --zone <id-or-name> when several exist.
18
+ #
19
+ # Usage:
20
+ # kampodine dns records [--zone <id-or-name>] # list records: domain / type / ttl / value
21
+ # kampodine dns add --name <label> --type A|AAAA|CNAME --value <target> [--ttl 300]
22
+ # kampodine dns rm --name <label> --type <A|AAAA|CNAME> --value <target>
23
+ # (all: optional --zone <id-or-name>; --instance-principal for instance auth)
24
+ set -euo pipefail
25
+
26
+ PROFILE="${OCI_PROFILE:-esellar-api}"
27
+ COMPARTMENT_NAME="${OCI_COMPARTMENT:-esellar}"
28
+ AUTH_ARGS=()
29
+
30
+ say() { printf '\033[1;34m[dns]\033[0m %s\n' "$*"; }
31
+ die() { printf '\033[1;31m[dns] FAIL:\033[0m %s\n' "$*" >&2; exit 1; }
32
+ usage() {
33
+ local code="${1:-0}"
34
+ printf 'Usage:\n'
35
+ grep '^# kampodine dns' "$0" | sed 's/^# //'
36
+ cat <<'EOF'
37
+
38
+ Auth is the oci CLI's own — its config file (--profile) or instance
39
+ principal. kampodine never accepts, stores, or logs credential material.
40
+ Zone/compartment: OCI_PROFILE (default esellar-api), OCI_COMPARTMENT (default
41
+ esellar). Types are pinned to A | AAAA | CNAME; ttl range 60..172800.
42
+
43
+ Examples:
44
+ kampodine dns records
45
+ kampodine dns records --zone esellar.example.com
46
+ kampodine dns add --name app --type A --value 203.0.113.10 --zone esellar.example.com
47
+ kampodine dns add --name www --type CNAME --value app.example.com --ttl 3600
48
+ kampodine dns rm --name app --type A --value 203.0.113.10
49
+ EOF
50
+ exit "$code"
51
+ }
52
+
53
+ # --- pure validators (no side effects, run before any provider call) ----------
54
+ is_ipv4() {
55
+ local ip="$1" i octet
56
+ [[ "$ip" =~ ^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$ ]] || return 1
57
+ for i in 1 2 3 4; do
58
+ octet="${BASH_REMATCH[i]}"
59
+ [[ $((10#$octet)) -le 255 ]] || return 1
60
+ done
61
+ return 0
62
+ }
63
+
64
+ is_ipv6() {
65
+ local ip="$1" colons
66
+ [[ "$ip" =~ ^[0-9A-Fa-f:]+$ ]] || return 1
67
+ [[ -n "${ip//:/}" ]] || return 1 # at least one hex digit
68
+ [[ "$ip" != *":::"* ]] || return 1 # no triple colons
69
+ colons="${ip//[0-9A-Fa-f]/}"
70
+ [[ ${#colons} -le 7 ]] || return 1 # at most 8 groups
71
+ [[ "${ip/::/}" != *"::"* ]] || return 1 # at most one "::"
72
+ return 0
73
+ }
74
+
75
+ is_hostname() {
76
+ [[ "$1" =~ ^([A-Za-z0-9_]([A-Za-z0-9_-]{0,61}[A-Za-z0-9_])?\.)+[A-Za-z0-9_]([A-Za-z0-9_-]{0,61}[A-Za-z0-9_])?\.?$ ]]
77
+ }
78
+
79
+ # --- dispatch -----------------------------------------------------------------
80
+ CMD="${1:-}"
81
+ case "$CMD" in
82
+ records|add|rm) shift ;;
83
+ -h|--help|help) usage 0 ;;
84
+ "") usage 2 ;;
85
+ *)
86
+ printf 'kampodine dns: unknown subcommand: %s\n\n' "$CMD" >&2
87
+ usage 2
88
+ ;;
89
+ esac
90
+
91
+ ZONE_ARG=""
92
+ NAME=""
93
+ RTYPE=""
94
+ VALUE=""
95
+ TTL="300"
96
+ while [[ $# -gt 0 ]]; do
97
+ case "$1" in
98
+ --zone) ZONE_ARG="$2"; shift 2 ;;
99
+ --name) NAME="$2"; shift 2 ;;
100
+ --type) RTYPE="$2"; shift 2 ;;
101
+ --value) VALUE="$2"; shift 2 ;;
102
+ --ttl) TTL="$2"; shift 2 ;;
103
+ --instance-principal) AUTH_ARGS=(--auth instance_principal); shift ;;
104
+ -h|--help) usage 0 ;;
105
+ *) die "unknown argument: $1 (--help)" ;;
106
+ esac
107
+ done
108
+
109
+ need_provider() {
110
+ command -v oci >/dev/null 2>&1 \
111
+ || 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"
112
+ command -v jq >/dev/null 2>&1 \
113
+ || die "jq not found — brew install jq (dns.sh uses it for RRSet surgery)"
114
+ }
115
+
116
+ compartment_ocid() {
117
+ local ocid
118
+ ocid="$(oci iam compartment list --all --profile "$PROFILE" ${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"} \
119
+ --query "data[?name=='$COMPARTMENT_NAME'].id | [0]" --raw-output 2>/dev/null || true)"
120
+ [[ "$ocid" == ocid1.compartment* ]] || die "compartment '$COMPARTMENT_NAME' not found (profile $PROFILE)"
121
+ printf '%s' "$ocid"
122
+ }
123
+
124
+ # resolve_zone -> ZONE_NAME + ZONE_ID (explicit --zone wins; otherwise the
125
+ # compartment must hold exactly one zone — never guess between several).
126
+ resolve_zone() {
127
+ local zj zl count
128
+ COMPARTMENT_OCID="$(compartment_ocid)"
129
+ if [[ -n "$ZONE_ARG" ]]; then
130
+ if [[ "$ZONE_ARG" == ocid1.dns-zone* ]]; then
131
+ zj="$(oci dns zone get --zone-name-or-id "$ZONE_ARG" -c "$COMPARTMENT_OCID" --profile "$PROFILE" ${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"} 2>/dev/null)" \
132
+ || die "zone not found: $ZONE_ARG"
133
+ ZONE_NAME="$(jq -r '.data.name' <<<"$zj")"
134
+ ZONE_ID="$ZONE_ARG"
135
+ else
136
+ ZONE_NAME="${ZONE_ARG%.}"
137
+ ZONE_ID="$ZONE_ARG"
138
+ fi
139
+ return 0
140
+ fi
141
+ zl="$(oci dns zone list --all -c "$COMPARTMENT_OCID" --profile "$PROFILE" ${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"} 2>/dev/null)" \
142
+ || die "zone list failed (profile $PROFILE, compartment $COMPARTMENT_NAME)"
143
+ count="$(jq '.data.items | length' <<<"$zl")"
144
+ if [[ "$count" -eq 0 ]]; then
145
+ die "no DNS zones in compartment '$COMPARTMENT_NAME' — create one in the OCI console first (or pass --zone <id-or-name>)"
146
+ fi
147
+ if [[ "$count" -gt 1 ]]; then
148
+ printf '\033[1;31m[dns] FAIL:\033[0m multiple zones in %s — pass --zone with one of:\n' "$COMPARTMENT_NAME" >&2
149
+ jq -r '.data.items[].name' <<<"$zl" | sed 's/^/ /' >&2
150
+ exit 1
151
+ fi
152
+ ZONE_NAME="$(jq -r '.data.items[0].name' <<<"$zl")"
153
+ ZONE_ID="$(jq -r '.data.items[0].id' <<<"$zl")"
154
+ }
155
+
156
+ # turn --name into the record domain: plain label -> label.zone; fqdn must
157
+ # live inside the zone (never write into someone else's zone by accident).
158
+ validate_name_shape() {
159
+ [[ -n "$NAME" ]] || die "--name is required (--help)"
160
+ [[ "$NAME" =~ ^[A-Za-z0-9_-]{1,63}$ ]] && return 0
161
+ is_hostname "$NAME" || die "invalid --name '$NAME' — use a DNS label (app) or an fqdn inside the zone (app.example.com)"
162
+ }
163
+
164
+ resolve_domain() {
165
+ if [[ "$NAME" =~ ^[A-Za-z0-9_-]{1,63}$ ]]; then
166
+ DOMAIN="$NAME.$ZONE_NAME"
167
+ return 0
168
+ fi
169
+ DOMAIN="${NAME%.}"
170
+ [[ "$DOMAIN" == *".$ZONE_NAME" || "$DOMAIN" == "$ZONE_NAME" ]] \
171
+ || die "--name '$NAME' is outside zone '$ZONE_NAME' — pass --zone <that-zone> explicitly if intended"
172
+ }
173
+
174
+ validate_type() {
175
+ case "$RTYPE" in
176
+ A|AAAA|CNAME) return 0 ;;
177
+ "") die "--type is required: A, AAAA, or CNAME (--help)" ;;
178
+ *) die "--type must be one of: A, AAAA, CNAME (got: $RTYPE)" ;;
179
+ esac
180
+ }
181
+
182
+ validate_value() {
183
+ [[ -n "$VALUE" ]] || die "--value is required (--help)"
184
+ case "$VALUE" in *[\"\\]*) die "invalid --value — quotes/backslashes are never valid in a record value" ;; esac
185
+ case "$RTYPE" in
186
+ A) is_ipv4 "$VALUE" || die "invalid --value '$VALUE' for A — need dotted-quad IPv4 (203.0.113.10)" ;;
187
+ AAAA) is_ipv6 "$VALUE" || die "invalid --value '$VALUE' for AAAA — need an IPv6 literal (fd00::1)" ;;
188
+ CNAME) is_hostname "$VALUE" || die "invalid --value '$VALUE' for CNAME — need a hostname (app.example.com)" ;;
189
+ esac
190
+ }
191
+
192
+ validate_ttl() {
193
+ [[ "$TTL" =~ ^[0-9]+$ ]] || die "invalid --ttl '$TTL' — seconds (60..172800)"
194
+ [[ "$TTL" -ge 60 && "$TTL" -le 172800 ]] || die "invalid --ttl $TTL — OCI allows 60..172800 seconds"
195
+ }
196
+
197
+ # fetch_rrset -> ITEMS (canonical JSON array of {domain,rtype,ttl,rdata};
198
+ # a missing RRSet reads as empty — first add must not 404).
199
+ fetch_rrset() {
200
+ local rj
201
+ rj="$(oci dns record rrset get -c "$COMPARTMENT_OCID" --zone-name-or-id "$ZONE_ID" --domain "$DOMAIN" --rtype "$RTYPE" --profile "$PROFILE" ${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"} 2>/dev/null || true)"
202
+ if [[ -n "$rj" ]]; then
203
+ ITEMS="$(jq -c '.data.items | map({domain, rtype, ttl, rdata})' <<<"$rj")" || ITEMS="[]"
204
+ else
205
+ ITEMS="[]"
206
+ fi
207
+ }
208
+
209
+ case "$CMD" in
210
+ records)
211
+ need_provider
212
+ resolve_zone
213
+ say "DNS records — zone $ZONE_NAME ($ZONE_ID), profile $PROFILE:"
214
+ oci dns record zone get -c "$COMPARTMENT_OCID" --zone-name-or-id "$ZONE_ID" --profile "$PROFILE" ${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"} --all \
215
+ | jq -r '.data.items[] | [.domain, .rtype, (.ttl|tostring), .rdata] | @tsv'
216
+ ;;
217
+
218
+ add)
219
+ [[ -n "$NAME" ]] || die "--name is required (--help)"
220
+ validate_type
221
+ validate_value
222
+ validate_ttl
223
+ validate_name_shape
224
+ need_provider
225
+ resolve_zone
226
+ resolve_domain
227
+ fetch_rrset
228
+ norm_value="${VALUE%.}"
229
+ present="$(jq -r --arg v "$norm_value" 'any(.[]; (.rdata | sub("\\.$"; "")) == $v)' <<<"$ITEMS")"
230
+ if [[ "$present" == "true" ]]; then
231
+ say "already present: $DOMAIN $RTYPE $VALUE — nothing to do"
232
+ exit 0
233
+ fi
234
+ new_items="$(jq -c --arg d "$DOMAIN" --arg t "$RTYPE" --arg v "$VALUE" --argjson ttl "$TTL" \
235
+ '. + [{domain: $d, rtype: $t, ttl: $ttl, rdata: $v}]' <<<"$ITEMS")"
236
+ # NOTE: --profile stays ON the first line (script-gates static scan reads it there)
237
+ oci dns record rrset update --profile "$PROFILE" -c "$COMPARTMENT_OCID" --zone-name-or-id "$ZONE_ID" \
238
+ --domain "$DOMAIN" --rtype "$RTYPE" --items "$new_items" --force ${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"} >/dev/null \
239
+ || die "RRSet update failed for $DOMAIN $RTYPE (zone $ZONE_NAME)"
240
+ say "added: $DOMAIN $RTYPE $VALUE (ttl $TTL)"
241
+ ;;
242
+
243
+ rm)
244
+ [[ -n "$NAME" ]] || die "--name is required (--help)"
245
+ validate_type
246
+ validate_value
247
+ validate_name_shape
248
+ need_provider
249
+ resolve_zone
250
+ resolve_domain
251
+ fetch_rrset
252
+ [[ "$ITEMS" == "[]" ]] && die "no matching record: $DOMAIN $RTYPE $VALUE (see: kampodine dns records)"
253
+ norm_value="${VALUE%.}"
254
+ remaining="$(jq -c --arg v "$norm_value" '[.[] | select((.rdata | sub("\\.$"; "")) != $v)]' <<<"$ITEMS")"
255
+ [[ "$(jq 'length' <<<"$remaining")" -eq "$(jq 'length' <<<"$ITEMS")" ]] \
256
+ && die "no matching record: $DOMAIN $RTYPE $VALUE (see: kampodine dns records)"
257
+ # an emptied RRSet is legal: --items '[]' removes it entirely
258
+ # NOTE: --profile stays ON the first line (script-gates static scan reads it there)
259
+ oci dns record rrset update --profile "$PROFILE" -c "$COMPARTMENT_OCID" --zone-name-or-id "$ZONE_ID" \
260
+ --domain "$DOMAIN" --rtype "$RTYPE" --items "$remaining" --force ${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"} >/dev/null \
261
+ || die "RRSet update failed for $DOMAIN $RTYPE (zone $ZONE_NAME)"
262
+ say "removed: $DOMAIN $RTYPE $VALUE"
263
+ ;;
264
+ esac
package/scripts/env.sh ADDED
@@ -0,0 +1,183 @@
1
+ #!/usr/bin/env bash
2
+ # env.sh — manage the remote app env file (/etc/esellar/env, 0600 root).
3
+ #
4
+ # SECRETS NEVER PRINT. `env list`, `env push`, and the `env pull` summaries
5
+ # surface FINGERPRINTS ONLY: KEY + value length + first 2 characters. Raw
6
+ # values move exactly twice — push uploads the local file over ssh stdin,
7
+ # pull streams the remote file to stdout (payload) or --out (0600). Nothing
8
+ # secret ever lands in terminal output, logs, or process args.
9
+ #
10
+ # Host/key resolution is IDENTICAL to deploy.sh:
11
+ # --host root@<ip> | ESPELLAR_HOST | ~/.ssh/config Host alias
12
+ # --ssh-key <path> | KAMPODINE_SSH_KEY | ESPELLAR_SSH_KEY (legacy) |
13
+ # ssh-agent (the POSIX default; per-host IdentityFile belongs in ssh config)
14
+ #
15
+ # Usage:
16
+ # kampodine env list [--host <user@ip>] # KEY + fingerprint table (NEVER values)
17
+ # kampodine env push --file <local-env-file> [...] # upload: 0600 temp + atomic mv + restart hint
18
+ # kampodine env pull [--out <file>] [...] # raw payload to stdout/--out (0600); masked summary follows it
19
+ # kampodine env fingerprint [--file <f>] # preview the masking for a LOCAL file / stdin (never values)
20
+ #
21
+ # push sends the file VERBATIM (what you push is what lands). deploy's env
22
+ # step additionally drops rc-script-owned CLEAR keys — keep those out of the
23
+ # file you push unless you mean to own them here.
24
+ set -euo pipefail
25
+
26
+ ENV_FILE_REMOTE="${KAMPODINE_ENV_REMOTE:-/etc/esellar/env}"
27
+ ENV_TMP_BASE="$(dirname "$ENV_FILE_REMOTE")"
28
+
29
+ ESPELLAR_HOST="${ESPELLAR_HOST:-}"
30
+ # SSH key resolution — same ladder as deploy.sh:
31
+ # 1. --ssh-key flag (explicit, per-invocation)
32
+ # 2. KAMPODINE_SSH_KEY env (project-level: direnv / .envrc / export)
33
+ # 3. ESPELLAR_SSH_KEY env (legacy alias, kept for existing setups)
34
+ # 4. empty → ssh-agent and/or the operator's ~/.ssh/config Host block
35
+ if [ -n "${KAMPODINE_SSH_KEY:-}" ]; then
36
+ SSH_KEY="$KAMPODINE_SSH_KEY"
37
+ else
38
+ SSH_KEY=""
39
+ fi
40
+
41
+ say() { printf '\033[1;36m[env]\033[0m %s\n' "$*"; }
42
+ die() { printf '\033[1;31m[env] FAIL:\033[0m %s\n' "$*" >&2; exit 1; }
43
+ usage() {
44
+ printf 'Usage:\n'
45
+ grep '^# kampodine env' "$0" | sed 's/^# //'
46
+ cat <<'EOF'
47
+
48
+ Fingerprints NEVER leak values: every line is KEY + value length + first 2 chars.
49
+ Raw values move only in push's upload stream and pull's stdout/--out payload.
50
+ Host/key resolution matches deploy.sh: --host | ESPELLAR_HOST; --ssh-key |
51
+ KAMPODINE_SSH_KEY | ESPELLAR_SSH_KEY | ssh-agent / ~/.ssh/config.
52
+
53
+ Examples:
54
+ kampodine env list --host root@203.0.113.10
55
+ kampodine env push --file ./ops/env.production --host root@203.0.113.10
56
+ kampodine env pull --out ./env.snapshot --host root@203.0.113.10 # written 0600
57
+ kampodine env pull --host root@203.0.113.10 | wc -l # raw payload on stdout, summary on stderr
58
+ kampodine env fingerprint --file ./.env.local # preview masking, values never leave stdin
59
+ EOF
60
+ exit 0
61
+ }
62
+
63
+ SUB="${1:-}"
64
+ [[ -n "$SUB" ]] || usage
65
+ case "$SUB" in
66
+ list|push|pull|fingerprint) shift ;;
67
+ -h|--help|help) usage ;;
68
+ *) die "unknown env subcommand: $SUB (--help)" ;;
69
+ esac
70
+
71
+ FILE=""
72
+ OUT=""
73
+ while [[ $# -gt 0 ]]; do
74
+ case "$1" in
75
+ --host) ESPELLAR_HOST="$2"; shift 2 ;;
76
+ --ssh-key) SSH_KEY="$2"; shift 2 ;;
77
+ --file) FILE="$2"; shift 2 ;;
78
+ --out) OUT="$2"; shift 2 ;;
79
+ -h|--help) usage ;;
80
+ *) die "unknown argument: $1 (--help)" ;;
81
+ esac
82
+ done
83
+
84
+ SSH_ARGS=(-o ConnectTimeout=10 -o BatchMode=yes)
85
+ [[ -n "$SSH_KEY" ]] && SSH_ARGS+=(-i "$SSH_KEY")
86
+ # $1 is a composed remote command — client-side expansion is the design.
87
+ # shellcheck disable=SC2029
88
+ vm() { ssh "${SSH_ARGS[@]}" "$ESPELLAR_HOST" "$1"; }
89
+
90
+ # --- macOS ssh-agent quirk (first run from a fresh machine) -------------------
91
+ if [[ "$(uname -s)" == "Darwin" ]]; then
92
+ SSH_AUTH_SOCK="$(launchctl getenv SSH_AUTH_SOCK 2>/dev/null || true)"
93
+ export SSH_AUTH_SOCK
94
+ fi
95
+
96
+ require_host() {
97
+ [[ -n "$ESPELLAR_HOST" ]] || die "target required: --host root@<ip> or ESPELLAR_HOST=root@<ip> (resolution matches deploy.sh)"
98
+ }
99
+
100
+ # env_fp_lines — env content on stdin -> fingerprint table on stdout.
101
+ # THE masking contract lives here: KEY + value length + first 2 chars.
102
+ # $value is never printed; only ${value:0:2} and its length are.
103
+ env_fp_lines() {
104
+ local line key value len
105
+ while IFS= read -r line || [[ -n "$line" ]]; do
106
+ case "$line" in ''|'#'*) continue ;; esac
107
+ [[ "$line" == *=* ]] || continue
108
+ key="${line%%=*}"
109
+ [[ "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
110
+ value="${line#*=}"
111
+ # one pair of matching surrounding quotes is formatting, not content
112
+ # (podman --env-file does not unquote — the deploy pipeline strips them)
113
+ if [[ ${#value} -ge 2 ]]; then
114
+ case "$value" in
115
+ \"*\") value="${value#\"}"; value="${value%\"}" ;;
116
+ \'*\') value="${value#\'}"; value="${value%\'}" ;;
117
+ esac
118
+ fi
119
+ len="${#value}"
120
+ if [[ "$len" -eq 0 ]]; then
121
+ printf '%-32s len=0 (empty)\n' "$key"
122
+ else
123
+ printf '%-32s len=%-7s %s…\n' "$key" "$len" "${value:0:2}"
124
+ fi
125
+ done
126
+ }
127
+
128
+ fetch_remote_env() {
129
+ vm "cat $ENV_FILE_REMOTE" || die "cannot read $ENV_FILE_REMOTE on $ESPELLAR_HOST (no env file yet? run: kampodine env push --file <env>, or kampodine deploy)"
130
+ }
131
+
132
+ case "$SUB" in
133
+ list)
134
+ require_host
135
+ raw="$(fetch_remote_env)"
136
+ env_fp_lines <<<"$raw"
137
+ ;;
138
+
139
+ push)
140
+ [[ -n "$FILE" ]] || die "push requires --file <local-env-file> (--help)"
141
+ [[ -f "$FILE" ]] || die "no such file: $FILE"
142
+ require_host
143
+ count="$(grep -cE '^[A-Za-z_][A-Za-z0-9_]*=' "$FILE" || true)"
144
+ [[ "${count:-0}" -gt 0 ]] || die "no KEY=VALUE lines in $FILE — nothing to push"
145
+ say "pushing $FILE -> $ESPELLAR_HOST:$ENV_FILE_REMOTE (fingerprint summary below; values are NEVER printed)"
146
+ env_fp_lines < "$FILE"
147
+ tmp_remote="$ENV_TMP_BASE/env.tmp.$$"
148
+ # 0600 FROM CREATION: umask 077 + ssh stdin pipe — the temp is never
149
+ # world-readable, not even for an instant; `mv` within the same
150
+ # directory is atomic (busybox-safe: umask/cat/mv/chmod only).
151
+ vm "umask 077; cat > $tmp_remote" < "$FILE" || die "upload failed"
152
+ vm "chmod 600 $tmp_remote && mv -f $tmp_remote $ENV_FILE_REMOTE" \
153
+ || die "atomic install failed (remote temp left at: $tmp_remote)"
154
+ say "installed $ENV_FILE_REMOTE (0600) on $ESPELLAR_HOST"
155
+ say "restart to apply: ssh $ESPELLAR_HOST 'rc-service esellar-api restart' # or: kampodine deploy"
156
+ ;;
157
+
158
+ pull)
159
+ require_host
160
+ raw="$(fetch_remote_env)"
161
+ if [[ -n "$OUT" ]]; then
162
+ ( umask 077; printf '%s\n' "$raw" > "$OUT" ) || die "cannot write $OUT"
163
+ chmod 600 "$OUT"
164
+ say "wrote $OUT (0600) — fingerprint summary below (payload went to the file, stdout is free):"
165
+ env_fp_lines <<<"$raw"
166
+ else
167
+ # stdout IS the payload (pipe into whatever needs the values);
168
+ # the human-readable summary goes to stderr, masked.
169
+ printf '%s\n' "$raw"
170
+ say "fingerprint summary for $ENV_FILE_REMOTE on $ESPELLAR_HOST (payload above on stdout):" >&2
171
+ env_fp_lines <<<"$raw" >&2
172
+ fi
173
+ ;;
174
+
175
+ fingerprint)
176
+ if [[ -n "$FILE" ]]; then
177
+ [[ -f "$FILE" ]] || die "no such file: $FILE"
178
+ env_fp_lines < "$FILE"
179
+ else
180
+ env_fp_lines
181
+ fi
182
+ ;;
183
+ esac
@@ -39,7 +39,17 @@ KEEP_OBJECT=0
39
39
 
40
40
  say() { printf '[import] %s\n' "$*"; }
41
41
  die() { printf '[import][FAIL] %s\n' "$*" >&2; exit 1; }
42
- usage() { grep '^# kampodine image-import' "$0" | sed 's/^# //'; exit 0; }
42
+ usage() {
43
+ cat <<'EOF'
44
+ Usage:
45
+ kampodine image-import [--image <qcow2>] [--bucket <name>] [--name-prefix <p>]
46
+ [--compartment <name>] [--from-pass] [--keep-object]
47
+
48
+ Examples:
49
+ EOF
50
+ grep '^# kampodine image-import' "$0" | sed 's/^# //'
51
+ exit 0
52
+ }
43
53
 
44
54
  while [[ $# -gt 0 ]]; do
45
55
  case "$1" in
@@ -19,13 +19,29 @@ SERVICE="${SERVICE:-esellar-api}"
19
19
 
20
20
  log() { printf '[migrate-all] %s\n' "$*"; }
21
21
  fail() { printf '[migrate-all][FAIL] %s\n' "$*" >&2; exit 1; }
22
+ usage() {
23
+ cat <<'EOF'
24
+ Usage:
25
+ kampodine migrate [--allow-running]
26
+
27
+ Examples:
28
+ kampodine migrate # stop the API first (rc-service esellar-api stop)
29
+ kampodine migrate --allow-running # deliberate: migrate while the API serves (SQLITE_BUSY risk)
30
+
31
+ Runs drizzle migrations over the libSQL dbs under TENANT_DIR (default
32
+ /data/tenants): root.db first (auth/org plane), then tenant_*.db sorted,
33
+ bounded-parallel (MIGRATE_JOBS, default 4). Per-file failures are collected;
34
+ exits 1 if any failed.
35
+ EOF
36
+ exit 0
37
+ }
22
38
 
23
39
  ALLOW_RUNNING=0
24
40
  for arg in "$@"; do
25
41
  case "$arg" in
26
42
  --allow-running) ALLOW_RUNNING=1 ;;
27
- -h|--help) echo "usage: $0 [--allow-running]"; exit 0 ;;
28
- *) fail "unknown argument: $arg" ;;
43
+ -h|--help) usage ;;
44
+ *) fail "unknown argument: $arg (--help)" ;;
29
45
  esac
30
46
  done
31
47
 
package/scripts/status.sh CHANGED
@@ -2,12 +2,35 @@
2
2
  # status.sh — one command: what is LIVE (through the proxy) + the blue/green
3
3
  # pair view (instances, reserved IP, health).
4
4
  #
5
+ # Usage:
6
+ # kampodine status
7
+ #
5
8
  # Env: APP_HOST_HEADER (default: app.example.com), OCI_PROFILE, OCI_COMPARTMENT.
6
9
  set -euo pipefail
7
10
  HERE="$(cd "$(dirname "$0")" && pwd)"
8
11
  HOST="${APP_HOST_HEADER:-app.example.com}"
9
12
 
10
13
  say() { printf '%s\n' "$*"; }
14
+ die() { printf '[status] FAIL: %s\n' "$*" >&2; exit 1; }
15
+ usage() {
16
+ cat <<'EOF'
17
+ Usage:
18
+ kampodine status
19
+
20
+ Examples:
21
+ kampodine status # live health (proxy host) + the blue/green pair view
22
+
23
+ Env: APP_HOST_HEADER (default app.example.com), OCI_PROFILE, OCI_COMPARTMENT.
24
+ EOF
25
+ exit 0
26
+ }
27
+
28
+ while [[ $# -gt 0 ]]; do
29
+ case "$1" in
30
+ -h|--help) usage ;;
31
+ *) die "unknown argument: $1 (--help)" ;;
32
+ esac
33
+ done
11
34
 
12
35
  say "== live (through the proxy: https://$HOST) =="
13
36
  if body="$(curl -sf -m 8 "https://$HOST/api/auth/ok" 2>/dev/null)"; then
@@ -45,7 +45,21 @@ SSH_KEY="${ESPELLAR_SSH_KEY:-}"
45
45
 
46
46
  say() { printf '\033[1;32m[vm-prepare]\033[0m %s\n' "$*"; }
47
47
  die() { printf '\033[1;31m[vm-prepare] FAIL:\033[0m %s\n' "$*" >&2; exit 1; }
48
- usage() { grep '^# kampodine vm-prepare' "$0" | sed 's/^# //'; exit 0; }
48
+ usage() {
49
+ cat <<'EOF'
50
+ Usage:
51
+ kampodine vm-prepare --host root@<new-ip> [--pull-images] [--ssh-key <path>]
52
+
53
+ Examples:
54
+ EOF
55
+ grep '^# kampodine vm-prepare' "$0" | sed 's/^# //'
56
+ cat <<'EOF'
57
+
58
+ Host/key resolution: --host | (no env default — explicit flag);
59
+ --ssh-key | ESPELLAR_SSH_KEY | ssh-agent / ~/.ssh/config.
60
+ EOF
61
+ exit 0
62
+ }
49
63
 
50
64
  while [[ $# -gt 0 ]]; do
51
65
  case "$1" in