kampodine 0.4.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 +118 -0
- package/README.md +72 -9
- package/cli.js +21 -1
- package/kampodine.md +13 -3
- package/package.json +1 -1
- package/scripts/backup.sh +290 -0
- package/scripts/bluegreen.sh +21 -1
- package/scripts/common.sh +644 -0
- package/scripts/config.sh +247 -0
- package/scripts/deploy-lifecycle.sh +266 -0
- package/scripts/deploy.sh +136 -18
- package/scripts/dns.sh +17 -0
- package/scripts/env.sh +23 -9
- package/scripts/image-import.sh +9 -0
- package/scripts/metrics.sh +127 -0
- package/scripts/migrate.sh +39 -5
- package/scripts/status.sh +132 -12
- package/scripts/vm-prepare.sh +20 -6
|
@@ -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
|
+
}
|