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.
@@ -0,0 +1,644 @@
1
+ #!/usr/bin/env bash
2
+ # common.sh — shared library for the deployment lifecycle: the local
3
+ # append-only deployment ledger (~/.kampodine/deployments.jsonl), the disk
4
+ # guard math, the prune keep-set selection, and the deploy-list rendering.
5
+ #
6
+ # Sourced by deploy.sh, deploy-lifecycle.sh, and status.sh. Defines functions
7
+ # ONLY — no side effects at source time, no option changes, no output.
8
+ #
9
+ # Everything here runs on the DEPLOY MACHINE (bash, macOS or Linux); the
10
+ # busybox constraint applies to the remote command strings the callers
11
+ # compose, not to this file.
12
+ #
13
+ # Ledger contract (the deployment HISTORY — the owner's source of truth):
14
+ # {"ts":"…","host":"…","sha":"…","tag":"…","result":"…","duration_ms":N,"subject":"…"}
15
+ # One fixed key order, values JSON-escaped; shas/tags/hosts/subjects only —
16
+ # NEVER secrets, env payloads, or key paths. Readers rely on the fixed order
17
+ # and on escaped values (no unescaped '"' inside strings), so fixed-string
18
+ # grep and field splitting are exact.
19
+
20
+ LEDGER_DIR_NAME=".kampodine"
21
+ LEDGER_FILE_NAME="deployments.jsonl"
22
+ # Named tags the prune keep-set never touches. The sha-tag filter already
23
+ # excludes them from candidates; listed here so the rule is explicit.
24
+ PRUNE_KEEP_TAGS="latest ts-rollback"
25
+
26
+ # --- ledger -------------------------------------------------------------------
27
+
28
+ # ledger_dir -> prints ~/.kampodine, creating it 0700 if missing.
29
+ ledger_dir() {
30
+ local dir="${HOME:?kampodine ledger requires HOME}/${LEDGER_DIR_NAME}"
31
+ mkdir -p "$dir" 2>/dev/null || true
32
+ chmod 700 "$dir" 2>/dev/null || true
33
+ printf '%s' "$dir"
34
+ }
35
+
36
+ # ledger_file -> prints the ledger's full path (dir is created 0700).
37
+ ledger_file() {
38
+ printf '%s/%s' "$(ledger_dir)" "$LEDGER_FILE_NAME"
39
+ }
40
+
41
+ # json_escape_str "$s" -> stdout: $s escaped for a JSON string literal
42
+ # (backslash, double quote, tab/newline/CR flattened to spaces — subjects
43
+ # are single-line by construction).
44
+ json_escape_str() {
45
+ local s="$1"
46
+ s="${s//\\/\\\\}"
47
+ s="${s//\"/\\\"}"
48
+ s="${s//$'\t'/ }"
49
+ s="${s//$'\n'/ }"
50
+ s="${s//$'\r'/ }"
51
+ printf '%s' "$s"
52
+ }
53
+
54
+ # ledger_append <host> <sha> <tag> <result> <duration_ms> [subject]
55
+ # Appends ONE JSONL line. Validates every field and refuses to write on any
56
+ # violation (a malformed line would poison every reader — the ledger is
57
+ # append-only, there is no repair). Returns 1 without writing on invalid
58
+ # input; the caller decides whether that is fatal.
59
+ ledger_append() {
60
+ local host="$1" sha="$2" tag="$3" result="$4" duration_ms="$5" subject="${6:-}"
61
+ [[ -n "$host" && "$host" =~ ^[^[:space:]]+$ ]] || return 1
62
+ [[ "$sha" =~ ^[0-9a-f]{4,40}$ ]] || return 1
63
+ [[ "$tag" =~ ^[0-9a-f]{4,40}$ ]] || return 1
64
+ case "$result" in
65
+ success|failed|rollback) ;;
66
+ *) return 1 ;;
67
+ esac
68
+ [[ "$duration_ms" =~ ^[0-9]+$ ]] || return 1
69
+ local ts file
70
+ ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
71
+ file="$(ledger_file)"
72
+ # 0600 from creation: umask 077 around the append (the file is local state,
73
+ # but hosts+subjects are not everyone's business).
74
+ umask 077
75
+ printf '{"ts":"%s","host":"%s","sha":"%s","tag":"%s","result":"%s","duration_ms":%s,"subject":"%s"}\n' \
76
+ "$ts" "$(json_escape_str "$host")" "$sha" "$tag" "$result" "$duration_ms" \
77
+ "$(json_escape_str "$subject")" >> "$file" || return 1
78
+ chmod 600 "$file" 2>/dev/null || true
79
+ }
80
+
81
+ # ledger_entries [host] -> prints matching JSONL lines (all hosts when no
82
+ # host given; empty output when the ledger does not exist yet).
83
+ # Host matching is a FIXED-STRING match on the `"host":"…","sha":"` seam:
84
+ # the fixed key order + JSON escaping guarantee this byte sequence can only
85
+ # be the field itself, never a forged occurrence inside a value.
86
+ ledger_entries() {
87
+ local file
88
+ file="$(ledger_file)"
89
+ [[ -f "$file" ]] || return 0
90
+ if [[ $# -ge 1 && -n "$1" ]]; then
91
+ grep -F "\"host\":\"$1\",\"sha\":\"" "$file" 2>/dev/null || true
92
+ else
93
+ cat "$file" 2>/dev/null || true
94
+ fi
95
+ }
96
+
97
+ # ledger_count [host] -> prints the number of ledger entries.
98
+ ledger_count() {
99
+ local n
100
+ n="$(ledger_entries "${1:-}" | grep -c . || true)"
101
+ printf '%s' "${n:-0}"
102
+ }
103
+
104
+ # extract_ledger_field <jsonl-line> <field-name> -> prints the field's value
105
+ # (quotes stripped for string fields; numbers pass through). Relies on the
106
+ # fixed key order: split on the `,"` seam — every key after the first is
107
+ # preceded by comma-quote, and the escaping guarantees a value can never
108
+ # contain an unescaped `,"` (in-value quotes are backslash-escaped), so the
109
+ # split is exact even across the unquoted duration_ms number. POSIX awk only —
110
+ # this runs on macOS too.
111
+ extract_ledger_field() {
112
+ awk -F',"' -v want="$2" '{
113
+ gsub(/^\{/, ""); gsub(/\}$/, "")
114
+ for (i = 1; i <= NF; i++) {
115
+ key = $i; sub(/:.*/, "", key); gsub(/^"/, "", key); gsub(/"$/, "", key)
116
+ if (key == want) {
117
+ val = $i; sub(/^[^:]*:/, "", val); gsub(/^"/, "", val); gsub(/"$/, "", val)
118
+ print val
119
+ exit
120
+ }
121
+ }
122
+ }' <<<"$1"
123
+ }
124
+
125
+ # ledger_subject_for_tag <host> <tag> -> prints the NEWEST recorded subject
126
+ # for that host+tag (used to re-attach history to a rollback), empty if none.
127
+ ledger_subject_for_tag() {
128
+ local line
129
+ line="$(ledger_entries "$1" | grep -F "\"tag\":\"$2\",\"result\"" | tail -n 1 || true)"
130
+ [[ -n "$line" ]] || return 0
131
+ extract_ledger_field "$line" subject
132
+ }
133
+
134
+ # ledger_total_line <count> [note] -> prints the owner-visible footer line.
135
+ ledger_total_line() {
136
+ printf 'Total deployments: %s%s\n' "$1" "${2:+ · $2}"
137
+ }
138
+
139
+ # --- disk guard ----------------------------------------------------------------
140
+
141
+ # disk_pct_from_df "<df -P output>" -> prints the usage percentage (integer)
142
+ # from the last data line's Capacity/Use% column, or nothing when the input
143
+ # is not df output. POSIX: `df -P` puts the percentage in field 5 on both
144
+ # BSD and GNU.
145
+ disk_pct_from_df() {
146
+ printf '%s\n' "$1" | awk 'NF >= 5 { pct = $5 } END { if (pct ~ /%$/) { sub(/%$/, "", pct); print pct } }'
147
+ }
148
+
149
+ # disk_verdict <pct|empty> <require-pct|empty> -> prints one of:
150
+ # fail usage >= require-pct (the --require-disk fail-closed gate)
151
+ # warn usage > 90 (the always-on loud warning)
152
+ # ok otherwise
153
+ # unknown pct missing/unparseable (callers decide if that is fatal)
154
+ disk_verdict() {
155
+ local pct="$1" require="$2"
156
+ if [[ -z "$pct" || ! "$pct" =~ ^[0-9]+$ ]]; then
157
+ printf 'unknown'
158
+ return 0
159
+ fi
160
+ if [[ -n "$require" && "$require" =~ ^[0-9]+$ && "$pct" -ge "$require" ]]; then
161
+ printf 'fail'
162
+ return 0
163
+ fi
164
+ if [[ "$pct" -gt 90 ]]; then
165
+ printf 'warn'
166
+ return 0
167
+ fi
168
+ printf 'ok'
169
+ }
170
+
171
+ # --- prune keep-set --------------------------------------------------------------
172
+
173
+ # running_tags_from_ps "<podman ps --format {{.Image}} output>" -> prints ONE
174
+ # space-padded line of bare tags (" tag1 tag2 "), ready for *" tag "* case
175
+ # matches. Digest refs (repo@sha256:…) carry no tag and are skipped; unrelated
176
+ # repos simply contribute their tag.
177
+ running_tags_from_ps() {
178
+ local r out=""
179
+ while IFS= read -r r; do
180
+ [[ -n "$r" ]] || continue
181
+ case "$r" in *@*) continue ;; esac
182
+ case "$r" in *:*) out+=" ${r##*:} " ;; esac
183
+ done <<<"$1"
184
+ printf '%s' "$out"
185
+ }
186
+
187
+ # sha_tagged_lines "<images listing>" -> filters "tag|created|size" lines to
188
+ # sha-like tags. This single rule is what keeps latest / ts-rollback / <none>
189
+ # out of every removal candidate.
190
+ sha_tagged_lines() {
191
+ local line tag
192
+ while IFS= read -r line; do
193
+ [[ -n "$line" ]] || continue
194
+ tag="${line%%|*}"
195
+ [[ "$tag" =~ ^[0-9a-f]{7,40}$ ]] || continue
196
+ printf '%s\n' "$line"
197
+ done <<<"$1"
198
+ }
199
+
200
+ # prune_select "<images listing>" "<ps image lines>" <keep_n>
201
+ # images listing: "tag|CreatedAt|Size" lines for the deploy image repository
202
+ # stdout: removal lines "tag|size" (empty = nothing to remove)
203
+ # ALWAYS kept: the running image(s), ts-rollback + latest (named keeps), and
204
+ # the newest keep_n sha-tags by creation timestamp. `podman rmi` on a shared
205
+ # ID only strips the tag — but the running image is excluded regardless, so
206
+ # nothing a container is using is ever proposed.
207
+ prune_select() {
208
+ local images="$1" running="$2" keep_n="$3" keep_newest line tag size
209
+ [[ "$keep_n" =~ ^[0-9]+$ ]] || return 1
210
+
211
+ local running_tags
212
+ running_tags="$(running_tags_from_ps "$running")"
213
+
214
+ local candidates
215
+ candidates="$(sha_tagged_lines "$images")"
216
+ [[ -n "$candidates" ]] || return 0
217
+
218
+ # BSD head rejects -n 0 — an empty keep_newest means "keep none"
219
+ keep_newest=""
220
+ if [[ "$keep_n" -gt 0 ]]; then
221
+ keep_newest="$(printf '%s\n' "$candidates" | sort -t'|' -k2,2r | head -n "$keep_n" | cut -d'|' -f1)"
222
+ fi
223
+
224
+ while IFS= read -r line; do
225
+ [[ -n "$line" ]] || continue
226
+ tag="${line%%|*}"
227
+ size="$(printf '%s' "$line" | cut -d'|' -f3)"
228
+ case "$PRUNE_KEEP_TAGS" in *" $tag "*) continue ;; esac
229
+ case "$running_tags" in *" $tag "*) continue ;; esac
230
+ if printf '%s\n' "$keep_newest" | grep -Fxq "$tag"; then continue; fi
231
+ printf '%s|%s\n' "$tag" "$size"
232
+ done <<<"$candidates"
233
+ }
234
+
235
+ # sum_sizes_human "<size lines like '936 MB'>" -> prints one human estimate
236
+ # ("~1.8 GB", "~512 MB", "~0 MB"). Approximate by design — it labels itself
237
+ # with ~ and exists to size the prune reward, not for accounting.
238
+ sum_sizes_human() {
239
+ printf '%s\n' "$1" | awk '
240
+ {
241
+ v = $1 + 0; u = toupper($2)
242
+ if (u == "GB") t += v * 1024
243
+ else if (u == "MB") t += v
244
+ else if (u == "KB") t += v / 1024
245
+ }
246
+ END {
247
+ if (t >= 1024) printf "~%.1f GB", t / 1024
248
+ else if (t >= 1) printf "~%d MB", t
249
+ else printf "~0 MB"
250
+ }'
251
+ }
252
+
253
+ # --- deploy list rendering -------------------------------------------------------
254
+
255
+ # render_deploy_list "<images listing>" "<ps image lines>" "<ledger lines>"
256
+ # -> prints table ROWS (no header/footer): VM sha-tagged rows newest-first,
257
+ # then ledger-only rows (tags pruned on the VM, or deployed from elsewhere /
258
+ # failed before load). The running tag is marked; ledger entries enrich each
259
+ # row with the last result + recorded subject.
260
+ render_deploy_list() {
261
+ local images="$1" running="$2" ledger="$3"
262
+ local running_tags vm_tags line tag created state result subject lline
263
+ running_tags="$(running_tags_from_ps "$running")"
264
+ vm_tags=" "
265
+
266
+ while IFS= read -r line; do
267
+ [[ -n "$line" ]] || continue
268
+ tag="${line%%|*}"
269
+ created="$(printf '%s' "$line" | cut -d'|' -f2)"
270
+ state="on VM"
271
+ case "$running_tags" in *" $tag "*) state="RUNNING" ;; esac
272
+ result="-"
273
+ subject="-"
274
+ lline="$(printf '%s\n' "$ledger" | grep -F "\"tag\":\"$tag\",\"result\"" | tail -n 1 || true)"
275
+ if [[ -n "$lline" ]]; then
276
+ result="$(extract_ledger_field "$lline" result)"
277
+ subject="$(extract_ledger_field "$lline" subject)"
278
+ [[ -n "$subject" ]] || subject="-"
279
+ fi
280
+ printf '%-9s %-17s %-9s %-9s %s\n' "$tag" "${created:0:16}" "$state" "$result" "$subject"
281
+ vm_tags+=" $tag "
282
+ done <<<"$(printf '%s\n' "$images" | sed '/^$/d' | sort -t'|' -k2,2r)"
283
+
284
+ while IFS= read -r line; do
285
+ [[ -n "$line" ]] || continue
286
+ tag="$(extract_ledger_field "$line" tag)"
287
+ [[ "$tag" =~ ^[0-9a-f]{7,40}$ ]] || continue
288
+ case "$vm_tags" in *" $tag "*) continue ;; esac
289
+ result="$(extract_ledger_field "$line" result)"
290
+ subject="$(extract_ledger_field "$line" subject)"
291
+ printf '%-9s %-17s %-9s %-9s %s\n' "$tag" "$(extract_ledger_field "$line" ts)" \
292
+ "not on VM" "$result" "${subject:--}"
293
+ done <<<"$ledger"
294
+ }
295
+
296
+ # --- profiles (~/.kampodine/config.json) ----------------------------------------
297
+ #
298
+ # Per-instance config, FLAT by owner decision: each profile carries
299
+ # host/sshKey/proxyHost/group — NO inheritance between profiles (the schema
300
+ # only LEAVES ROOM for a future "defaults" block; nothing reads one here).
301
+ # `group` is cosmetic: config list --group filters, nothing else consumes it.
302
+ # The file never holds secrets — sshKey is a PATH.
303
+ #
304
+ # Resolution everywhere (profile_resolve, called once per script AFTER flag
305
+ # parsing): explicit flag > env (KAMPODINE_PROFILE) > defaultProfile from
306
+ # config > the script's legacy behavior. A resolved profile's host/sshKey
307
+ # override KAMPODINE_HOST/KAMPODINE_SSH_KEY only when no explicit flag was
308
+ # passed — per-invocation always beats per-instance.
309
+
310
+ CONFIG_FILE_NAME="config.json"
311
+
312
+ # config_file -> prints ~/.kampodine/config.json (dir created 0700 if needed).
313
+ config_file() {
314
+ printf '%s/%s' "$(ledger_dir)" "$CONFIG_FILE_NAME"
315
+ }
316
+
317
+ # is_profile_name <name> -> 0 when the name is config-key safe.
318
+ is_profile_name() {
319
+ [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$ ]]
320
+ }
321
+
322
+ # config_require_jq -> 0 when jq exists (config reads/rewrites are jq-only;
323
+ # machines without a config never need it).
324
+ config_require_jq() {
325
+ command -v jq >/dev/null 2>&1 || {
326
+ printf '[kampodine] FAIL: jq is required to read %s — install jq (brew install jq)\n' "$(config_file)" >&2
327
+ return 1
328
+ }
329
+ }
330
+
331
+ # config_default_profile -> prints defaultProfile ("" when no config yet).
332
+ config_default_profile() {
333
+ local f
334
+ f="$(config_file)"
335
+ [[ -f "$f" ]] || return 0
336
+ config_require_jq || return 1
337
+ jq -r '.defaultProfile // empty' "$f" 2>/dev/null || return 1
338
+ }
339
+
340
+ # config_profile_names -> newline-separated profile names ("" without config).
341
+ config_profile_names() {
342
+ local f
343
+ f="$(config_file)"
344
+ [[ -f "$f" ]] || return 0
345
+ config_require_jq || return 1
346
+ jq -r '.profiles // {} | keys[]' "$f" 2>/dev/null || return 1
347
+ }
348
+
349
+ # profile_field <name> <field> -> prints the field value ("" when the field
350
+ # is unset); fails when the profile is unknown, naming the known set.
351
+ profile_field() {
352
+ local name="$1" field="$2" f
353
+ f="$(config_file)"
354
+ [[ -f "$f" ]] || {
355
+ printf '[kampodine] FAIL: no profile config at %s — known profiles: (none — create one: kampodine config init --name <name> --host root@<ip>)\n' "$f" >&2
356
+ return 1
357
+ }
358
+ config_require_jq || return 1
359
+ if [[ "$(jq -r --arg n "$name" '.profiles // {} | has($n)' "$f" 2>/dev/null)" != "true" ]]; then
360
+ printf '[kampodine] FAIL: unknown profile "%s" — known: %s (see: kampodine config list)\n' \
361
+ "$name" "$(config_profile_names | paste -sd' ' -)" >&2
362
+ return 1
363
+ fi
364
+ jq -r --arg n "$name" --arg k "$field" '.profiles[$n][$k] // empty' "$f" 2>/dev/null
365
+ }
366
+
367
+ # profile_resolve [explicit-profile] [host-flag] [ssh-key-flag]
368
+ # ONE call gives every script profile awareness. Effects:
369
+ # sets PROFILE_NAME / PROFILE_GROUP / PROFILE_HOST / PROFILE_SSH_KEY /
370
+ # PROFILE_PROXY_HOST (all "" when nothing resolves)
371
+ # overrides KAMPODINE_HOST / KAMPODINE_SSH_KEY from the profile ONLY when
372
+ # the corresponding explicit flag is absent — an explicit per-invocation
373
+ # flag always wins, the profile beats the legacy env it replaces.
374
+ # Selection: $1 > KAMPODINE_PROFILE > config defaultProfile.
375
+ # shellcheck disable=SC2034 # PROFILE_* / KAMPODINE_* are consumed by callers
376
+ profile_resolve() {
377
+ PROFILE_NAME="" PROFILE_GROUP="" PROFILE_HOST="" PROFILE_SSH_KEY="" PROFILE_PROXY_HOST=""
378
+ local explicit="${1:-}" host_flag="${2:-}" key_flag="${3:-}" name=""
379
+ if [[ -n "$explicit" ]]; then
380
+ name="$explicit"
381
+ elif [[ -n "${KAMPODINE_PROFILE:-}" ]]; then
382
+ name="$KAMPODINE_PROFILE"
383
+ else
384
+ name="$(config_default_profile)" || return 1
385
+ fi
386
+ [[ -z "$name" ]] && return 0
387
+ # validates existence (dies with the known set when unknown)
388
+ PROFILE_HOST="$(profile_field "$name" host)"
389
+ PROFILE_NAME="$name"
390
+ PROFILE_GROUP="$(profile_field "$name" group)"
391
+ PROFILE_SSH_KEY="$(profile_field "$name" sshKey)"
392
+ PROFILE_PROXY_HOST="$(profile_field "$name" proxyHost)"
393
+ if [[ -z "$host_flag" && -n "$PROFILE_HOST" ]]; then
394
+ KAMPODINE_HOST="$PROFILE_HOST"
395
+ fi
396
+ if [[ -z "$key_flag" && -n "$PROFILE_SSH_KEY" ]]; then
397
+ KAMPODINE_SSH_KEY="$PROFILE_SSH_KEY"
398
+ fi
399
+ return 0
400
+ }
401
+
402
+ # --- host init detection (groundwork for non-Alpine targets) ---------------------
403
+ #
404
+ # Detection + action resolution ONLY — no per-OS bootstrap lives here.
405
+ # On first connect init_detect probes the host once: rc-service (OpenRC/
406
+ # Alpine) vs systemctl (systemd) vs neither, and piggybacks an http-client
407
+ # probe (busybox wget stays the default; curl is the GNU-only-host fallback).
408
+ # The init verdict is cached in the active profile entry ("init" field) so
409
+ # later runs skip the probe; without a profile it is a per-process cache.
410
+
411
+ INIT_SYSTEM=""
412
+ REMOTE_HTTP_CLIENT=""
413
+
414
+ # init_detect <run-fn> -> prints the init verdict ("" when none detected).
415
+ # <run-fn> is the script's remote-run wrapper (e.g. vm), called with ONE
416
+ # composed probe command. Order matters: a cached profile verdict
417
+ # short-circuits the probe entirely (one round-trip ever, per host).
418
+ init_detect() {
419
+ local run_fn="$1" out init httpc
420
+ if [[ -z "$INIT_SYSTEM" && -n "$PROFILE_NAME" ]]; then
421
+ INIT_SYSTEM="$(profile_field "$PROFILE_NAME" init)"
422
+ fi
423
+ if [[ -n "$INIT_SYSTEM" ]]; then
424
+ printf '%s' "$INIT_SYSTEM"
425
+ return 0
426
+ fi
427
+ out="$("$run_fn" 'if command -v rc-service >/dev/null 2>&1; then echo openrc; elif command -v systemctl >/dev/null 2>&1; then echo systemd; else echo none; fi; if command -v wget >/dev/null 2>&1; then echo httpc=wget; elif command -v curl >/dev/null 2>&1; then echo httpc=curl; else echo httpc=none; fi' 2>/dev/null)" || out=""
428
+ init="$(printf '%s\n' "$out" | awk 'NR == 1 { print $1 }')"
429
+ httpc="$(printf '%s\n' "$out" | awk -F= '$1 == "httpc" { print $2 }')"
430
+ case "$init" in
431
+ openrc|systemd) INIT_SYSTEM="$init" ;;
432
+ *) INIT_SYSTEM="" ;;
433
+ esac
434
+ case "$httpc" in
435
+ wget|curl) REMOTE_HTTP_CLIENT="$httpc" ;;
436
+ *) REMOTE_HTTP_CLIENT="" ;;
437
+ esac
438
+ # best-effort cache: only with an active profile (the profile IS the host
439
+ # binding); never fatal — jq-less machines still get working detection.
440
+ if [[ -n "$PROFILE_NAME" && -n "$INIT_SYSTEM" ]]; then
441
+ config_set_profile_field "$PROFILE_NAME" init "$INIT_SYSTEM" 2>/dev/null || true
442
+ fi
443
+ printf '%s' "$INIT_SYSTEM"
444
+ }
445
+
446
+ # svc_action <service> <start|stop|restart> -> prints the remote command for
447
+ # the detected init system. OpenRC output is byte-identical to the historical
448
+ # strings; fails (rc != 0, no output) when no init was detected — callers
449
+ # decide whether that is fatal (restart: yes, diagnostics: no).
450
+ svc_action() {
451
+ local svc="$1" action="$2"
452
+ case "$INIT_SYSTEM" in
453
+ openrc) printf 'rc-service %s %s' "$svc" "$action" ;;
454
+ systemd) printf 'systemctl %s %s' "$action" "$svc" ;;
455
+ *) return 1 ;;
456
+ esac
457
+ }
458
+
459
+ # config_set_profile_field <name> <field> <value> — best-effort profile cache
460
+ # write (atomic 0600; requires jq; fails quietly on missing config).
461
+ config_set_profile_field() {
462
+ local name="$1" field="$2" value="$3" f tmp new
463
+ f="$(config_file)"
464
+ [[ -f "$f" ]] || return 1
465
+ config_require_jq || return 1
466
+ new="$(jq --arg n "$name" --arg k "$field" --arg v "$value" \
467
+ '.profiles[$n][$k] = $v' "$f" 2>/dev/null)" || return 1
468
+ tmp="$f.tmp.$$"
469
+ umask 077
470
+ printf '%s\n' "$new" > "$tmp" && chmod 600 "$tmp" && mv -f "$tmp" "$f"
471
+ }
472
+
473
+ # remote_http_cmd <url> -> the remote fetch command for the host's http
474
+ # client. busybox wget is the DEFAULT (byte-identical historical health
475
+ # probe); curl is the fallback for GNU-only hosts (no wget).
476
+ remote_http_cmd() {
477
+ case "$REMOTE_HTTP_CLIENT" in
478
+ curl) printf 'curl -s -m 3 %s 2>/dev/null' "$1" ;;
479
+ *) printf 'wget -qO- -T 3 %s 2>/dev/null' "$1" ;;
480
+ esac
481
+ }
482
+
483
+ # --- metrics snapshot -------------------------------------------------------------
484
+ #
485
+ # ONE SSH round-trip captures every section (busybox-safe remote commands
486
+ # only: cat/echo/nproc/df/du/podman/ps/sort/head); parsing + rendering are
487
+ # client-side pure functions below. metrics.sh renders this for `kampodine
488
+ # metrics`; status.sh folds the SAME snapshot in behind --verbose.
489
+
490
+ # shellcheck disable=SC2034 # consumed by metrics.sh / status.sh
491
+ METRICS_REMOTE_CMD="echo '%%KAMPODINE:LOAD%%'; cat /proc/loadavg 2>/dev/null; echo '%%KAMPODINE:CPU%%'; nproc 2>/dev/null; echo '%%KAMPODINE:MEM%%'; cat /proc/meminfo 2>/dev/null; echo '%%KAMPODINE:UPTIME%%'; cat /proc/uptime 2>/dev/null; echo '%%KAMPODINE:DISK%%'; df -P / 2>/dev/null; echo '%%KAMPODINE:DU%%'; du -sm /var/lib/containers /data 2>/dev/null; echo '%%KAMPODINE:PODMAN%%'; podman stats --no-stream --format '{{.Name}}|{{.CPUPerc}}|{{.MemUsage}}' 2>/dev/null; echo '%%KAMPODINE:TOP%%'; ps aux 2>/dev/null | grep -v '^USER' | sort -k6 -rn | head -n 5; echo '%%KAMPODINE:END%%'"
492
+
493
+ # metrics_uptime_human "<proc/uptime line>" -> "12d 2h 36m" ("" when absent).
494
+ metrics_uptime_human() {
495
+ local secs
496
+ secs="$(printf '%s' "$1" | awk '{print int($1)}')"
497
+ [[ "$secs" =~ ^[0-9]+$ ]] || return 0
498
+ awk -v s="$secs" 'BEGIN {
499
+ d = int(s / 86400); s -= d * 86400
500
+ h = int(s / 3600); s -= h * 3600
501
+ m = int(s / 60)
502
+ if (d > 0) printf "%dd %dh %dm", d, h, m
503
+ else if (h > 0) printf "%dh %dm", h, m
504
+ else printf "%dm", m
505
+ }'
506
+ }
507
+
508
+ # metrics_load_line "<loadavg line>" <nproc> -> "0.52 0.58 0.59 (4 CPUs)".
509
+ metrics_load_line() {
510
+ local avg cpus
511
+ avg="$(printf '%s' "$1" | awk '{print $1, $2, $3}')"
512
+ cpus="${2//[!0-9]/}"
513
+ if [[ -z "${avg//[[:space:]]/}" ]]; then
514
+ printf 'load unreadable'
515
+ return 0
516
+ fi
517
+ [[ -n "$cpus" ]] || cpus="?"
518
+ printf '%s (%s CPUs)' "$avg" "$cpus"
519
+ }
520
+
521
+ # metrics_mem_usage "<meminfo text>" -> "used N MiB · avail N MiB · total N MiB".
522
+ # Available prefers MemAvailable; falls back to MemFree+Buffers+Cached.
523
+ metrics_mem_usage() {
524
+ printf '%s\n' "$1" | awk '
525
+ $1 == "MemTotal:" { t = $2 }
526
+ $1 == "MemAvailable:" { a = $2 }
527
+ $1 == "MemFree:" { f = $2 }
528
+ $1 == "Buffers:" { b = $2 }
529
+ $1 == "Cached:" { c = $2 }
530
+ END {
531
+ if (t == "") t = 0
532
+ if (a == "") a = f + b + c
533
+ total = int(t / 1024); avail = int(a / 1024); used = total - avail
534
+ if (used < 0) used = 0
535
+ printf "used %d MiB · avail %d MiB · total %d MiB", used, avail, total
536
+ }'
537
+ }
538
+
539
+ # metrics_disk_breakdown "<df / output>" "<du -sm output>" -> four lines:
540
+ # the root usage line (disk_pct_from_df) + images/data/other. "other" is the
541
+ # df used MiB minus the two du readings, floored at 0 (du and df disagree by
542
+ # design; this is a CLI triage view, not accounting).
543
+ metrics_disk_breakdown() {
544
+ local pct images data used_mb other
545
+ pct="$(disk_pct_from_df "$1")"
546
+ if [[ -n "$pct" ]]; then
547
+ printf 'disk / : %s%% used\n' "$pct"
548
+ else
549
+ printf 'disk / : unknown (df unreadable)\n'
550
+ fi
551
+ images="$(printf '%s\n' "$2" | awk '$2 == "/var/lib/containers" { print $1 }')"
552
+ data="$(printf '%s\n' "$2" | awk '$2 == "/data" { print $1 }')"
553
+ used_mb="$(printf '%s\n' "$1" | awk 'NF >= 6 { u = $3 } END { if (u != "") print int(u / 1024) }')"
554
+ other=0
555
+ if [[ -n "$used_mb" ]]; then
556
+ other=$(( used_mb - ${images:-0} - ${data:-0} ))
557
+ (( other < 0 )) && other=0
558
+ fi
559
+ printf ' images : %s MB (/var/lib/containers)\n' "${images:-?}"
560
+ printf ' data : %s MB (/data)\n' "${data:-0}"
561
+ if [[ -n "$used_mb" ]]; then
562
+ printf ' other : %s MB (est.)\n' "$other"
563
+ else
564
+ printf ' other : unknown (df unreadable)\n'
565
+ fi
566
+ }
567
+
568
+ # metrics_containers_lines "<podman stats name|cpu|mem lines>" -> table rows;
569
+ # empty input prints an honest "(no containers…)" line.
570
+ metrics_containers_lines() {
571
+ if [[ -z "${1//[[:space:]]/}" ]]; then
572
+ printf ' (no containers or podman unreachable)\n'
573
+ return 0
574
+ fi
575
+ local line name cpu mem
576
+ while IFS= read -r line; do
577
+ [[ -n "$line" ]] || continue
578
+ name="${line%%|*}"; line="${line#*|}"
579
+ cpu="${line%%|*}"; mem="${line#*|}"
580
+ printf ' %-22s %-8s %s\n' "$name" "$cpu" "$mem"
581
+ done <<<"$1"
582
+ }
583
+
584
+ # metrics_top_procs "<ps aux output, sorted by RSS>" -> at most 5 rows,
585
+ # header dropped (client-side cap; the remote command pre-sorts + pre-caps).
586
+ metrics_top_procs() {
587
+ local line n=0
588
+ while IFS= read -r line; do
589
+ [[ -n "$line" ]] || continue
590
+ case "$line" in USER\ *|%\ CPU*) continue ;; esac
591
+ printf '%s\n' "$line" | awk '{
592
+ user = $1; rss = $6
593
+ cmd = ""
594
+ if (NF >= 11) { for (i = 11; i <= NF; i++) cmd = cmd (i > 11 ? " " : "") $i }
595
+ else if (NF >= 9) { for (i = 9; i <= NF; i++) cmd = cmd (i > 9 ? " " : "") $i }
596
+ else cmd = $0
597
+ printf " %8d KB %-8s %s\n", rss, user, cmd
598
+ }'
599
+ n=$(( n + 1 ))
600
+ (( n >= 5 )) && return 0
601
+ done <<<"$1"
602
+ }
603
+
604
+ # metrics_render "<raw snapshot>" -> prints the full snapshot block (no
605
+ # heading — the caller owns it). Also leaves the per-section buffers in the
606
+ # M_* globals (M_DISK is what the --warn-disk threshold re-reads).
607
+ # shellcheck disable=SC2034 # M_* buffers are consumed by callers
608
+ metrics_render() {
609
+ local raw="$1" line
610
+ M_LOAD="" M_CPU="" M_MEM="" M_UPTIME="" M_DISK="" M_DU="" M_PODMAN="" M_TOP=""
611
+ while IFS= read -r line; do
612
+ case "$line" in
613
+ "%%KAMPODINE:LOAD%%") section="load" ;;
614
+ "%%KAMPODINE:CPU%%") section="cpu" ;;
615
+ "%%KAMPODINE:MEM%%") section="mem" ;;
616
+ "%%KAMPODINE:UPTIME%%") section="uptime" ;;
617
+ "%%KAMPODINE:DISK%%") section="disk" ;;
618
+ "%%KAMPODINE:DU%%") section="du" ;;
619
+ "%%KAMPODINE:PODMAN%%") section="podman" ;;
620
+ "%%KAMPODINE:TOP%%") section="top" ;;
621
+ "%%KAMPODINE:"*"%%") section="" ;;
622
+ *)
623
+ case "${section:-}" in
624
+ load) M_LOAD+="$line"$'\n' ;;
625
+ cpu) M_CPU+="$line"$'\n' ;;
626
+ mem) M_MEM+="$line"$'\n' ;;
627
+ uptime) M_UPTIME+="$line"$'\n' ;;
628
+ disk) M_DISK+="$line"$'\n' ;;
629
+ du) M_DU+="$line"$'\n' ;;
630
+ podman) M_PODMAN+="$line"$'\n' ;;
631
+ top) M_TOP+="$line"$'\n' ;;
632
+ esac
633
+ ;;
634
+ esac
635
+ done <<<"$raw"
636
+ printf 'uptime : %s\n' "$(metrics_uptime_human "$M_UPTIME")"
637
+ printf 'load : %s\n' "$(metrics_load_line "$M_LOAD" "$M_CPU")"
638
+ printf 'memory : %s\n' "$(metrics_mem_usage "$M_MEM")"
639
+ metrics_disk_breakdown "$M_DISK" "$M_DU"
640
+ printf 'containers:\n'
641
+ metrics_containers_lines "$M_PODMAN"
642
+ printf 'top rss :\n'
643
+ metrics_top_procs "$M_TOP"
644
+ }