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.
@@ -0,0 +1,247 @@
1
+ #!/usr/bin/env bash
2
+ # config.sh — per-instance profiles in ~/.kampodine/config.json.
3
+ #
4
+ # FLAT by owner decision: every profile carries host/sshKey/proxyHost/group
5
+ # — NO inheritance between profiles (the schema only LEAVES ROOM for a
6
+ # future "defaults" block; nothing reads one). `group` is cosmetic: it feeds
7
+ # the `config list --group` filter and nothing else. The file NEVER holds
8
+ # secrets — sshKey is a PATH; hosts are addresses; that is the whole schema.
9
+ # (The init-system auto-detection may add an "init" field per profile —
10
+ # written by common.sh's init_detect, never by hand.)
11
+ #
12
+ # File contract: dir 0700, file 0600, atomic writes (same-dir tmp + mv).
13
+ # jq is required for every subcommand here (safe JSON read + rewrite).
14
+ #
15
+ # Usage:
16
+ # kampodine config init --name <name> --host <user@ip> --ssh-key <path> [--proxy-host <host>] [--group <g>] [--set-default] [--force]
17
+ # kampodine config list [--group <g>]
18
+ # kampodine config show [--profile <name>]
19
+ # kampodine config set-default <name>
20
+ # kampodine config remove <name> [--force]
21
+ #
22
+ # The FIRST profile automatically becomes the default; later profiles need
23
+ # --set-default (or `config set-default`) to take it. An explicit --profile
24
+ # flag or KAMPODINE_PROFILE env beats the default everywhere.
25
+ set -euo pipefail
26
+
27
+ HERE="$(cd "$(dirname "$0")" && pwd)"
28
+ # shellcheck source=common.sh
29
+ source "$HERE/common.sh"
30
+
31
+ say() { printf '\033[1;35m[config]\033[0m %s\n' "$*"; }
32
+ die() { printf '\033[1;31m[config] FAIL:\033[0m %s\n' "$*" >&2; exit 1; }
33
+ usage() {
34
+ local code="${1:-0}"
35
+ printf 'Usage:\n'
36
+ grep '^# kampodine config' "$0" | sed 's/^# //'
37
+ cat <<'EOF'
38
+
39
+ Per-instance profiles (~/.kampodine/config.json, dir 0700 / file 0600).
40
+ FLAT — no inheritance; `group` is a cosmetic list filter. sshKey is stored
41
+ as a PATH only; secrets never live in the config. Resolution everywhere:
42
+ explicit flag > KAMPODINE_PROFILE env > defaultProfile > legacy env.
43
+ jq is required (safe JSON rewrites). The first profile auto-becomes the
44
+ default; removing the LAST profile requires --force.
45
+
46
+ Examples:
47
+ kampodine config init --name prod --host root@84.13.128.216 --ssh-key ~/.ssh/id_ed25519-esellar --proxy-host 84-13-128-216.sslip.io --set-default
48
+ kampodine config init --name staging --host root@203.0.113.8 --ssh-key ~/.ssh/id_ed25519 --group preview
49
+ kampodine config list # all profiles (default marked *)
50
+ kampodine config list --group main # cosmetic group filter
51
+ kampodine config show --profile prod
52
+ kampodine config set-default staging
53
+ kampodine config remove staging
54
+ kampodine deploy --profile prod # profiles feed every subcommand
55
+ EOF
56
+ exit "$code"
57
+ }
58
+
59
+ # config_atomic_write <json> — 0700 dir / 0600 file, same-dir tmp + mv.
60
+ config_atomic_write() {
61
+ local json="$1" f tmp
62
+ f="$(config_file)"
63
+ tmp="$f.tmp.$$"
64
+ umask 077
65
+ printf '%s\n' "$json" > "$tmp" || die "cannot write $tmp"
66
+ chmod 600 "$tmp" && mv -f "$tmp" "$f" || die "atomic install failed (tmp left at: $tmp)"
67
+ }
68
+
69
+ # expand_tilde <path> -> $HOME-spelled-out form (config stores absolute paths).
70
+ expand_tilde() {
71
+ case "$1" in
72
+ "~") printf '%s' "$HOME" ;;
73
+ "~"/*) printf '%s%s' "$HOME" "${1#\~}" ;;
74
+ *) printf '%s' "$1" ;;
75
+ esac
76
+ }
77
+
78
+ # reject_config_value <value> <what> — no whitespace/quote/backslash: the
79
+ # config is hand-greppable and jq-round-trippable; nothing legit needs them.
80
+ reject_config_value() {
81
+ local value="$1" what="$2"
82
+ [[ -n "$value" ]] || die "$what must not be empty"
83
+ case "$value" in
84
+ *[[:space:]\"\\]*)
85
+ die "$what must not contain whitespace, quotes, or backslashes (got it with — value not echoed)"
86
+ ;;
87
+ esac
88
+ }
89
+
90
+ CMD="${1:-}"
91
+ case "$CMD" in
92
+ init|list|show|set-default|remove) shift ;;
93
+ -h|--help|help) usage 0 ;;
94
+ "") usage 2 ;;
95
+ *)
96
+ printf 'kampodine config: unknown subcommand: %s\n\n' "$CMD" >&2
97
+ usage 2
98
+ ;;
99
+ esac
100
+
101
+ NAME="" HOST_ARG="" KEY_ARG="" PROXY_ARG="" GROUP_ARG="" SET_DEFAULT=0 FORCE=0
102
+ PROFILE_ARG=""
103
+ POSITIONAL=()
104
+ while [[ $# -gt 0 ]]; do
105
+ case "$1" in
106
+ --name) NAME="$2"; shift 2 ;;
107
+ --host) HOST_ARG="$2"; shift 2 ;;
108
+ --ssh-key) KEY_ARG="$2"; shift 2 ;;
109
+ --proxy-host) PROXY_ARG="$2"; shift 2 ;;
110
+ --group) GROUP_ARG="$2"; shift 2 ;;
111
+ --profile) PROFILE_ARG="$2"; shift 2 ;;
112
+ --set-default) SET_DEFAULT=1; shift ;;
113
+ --force) FORCE=1; shift ;;
114
+ -h|--help) usage 0 ;;
115
+ --) shift; POSITIONAL+=("$@"); break ;;
116
+ *) POSITIONAL+=("$1"); shift ;;
117
+ esac
118
+ done
119
+
120
+ require_jq() {
121
+ command -v jq >/dev/null 2>&1 \
122
+ || die "jq not found — brew install jq (config.sh rewrites JSON safely)"
123
+ }
124
+
125
+ case "$CMD" in
126
+ init)
127
+ [[ -n "$NAME" ]] || die "--name is required (--help)"
128
+ is_profile_name "$NAME" || die "invalid --name '$NAME' — use [A-Za-z0-9][A-Za-z0-9_-]* (max 64)"
129
+ f="$(config_file)"
130
+ if [[ -f "$f" ]] && [[ "$(jq -r --arg n "$NAME" '.profiles // {} | has($n)' "$f" 2>/dev/null || echo false)" == "true" ]] && [[ $FORCE -ne 1 ]]; then
131
+ die "profile '$NAME' already exists — pass --force to overwrite (re-init also refreshes fields; the cached init field is kept unless you change host/ssh-key)"
132
+ fi
133
+ reject_config_value "$HOST_ARG" "--host"
134
+ reject_config_value "$KEY_ARG" "--ssh-key"
135
+ [[ -z "$PROXY_ARG" ]] || reject_config_value "$PROXY_ARG" "--proxy-host"
136
+ [[ -z "$GROUP_ARG" ]] || reject_config_value "$GROUP_ARG" "--group"
137
+ KEY_ABS="$(expand_tilde "$KEY_ARG")"
138
+ require_jq
139
+ if [[ ! -f "$KEY_ABS" ]]; then
140
+ say "note: ssh key file does not exist (yet): $KEY_ABS — stored as a path, deploy will fail until it does"
141
+ fi
142
+ if [[ -f "$f" ]]; then
143
+ # upsert + default rules: explicit --set-default wins; a config without
144
+ # any default (fresh defaultProfile, or cleared by a --force remove)
145
+ # adopts this profile.
146
+ new="$(jq --arg n "$NAME" --arg h "$HOST_ARG" --arg k "$KEY_ABS" --arg p "$PROXY_ARG" --arg g "$GROUP_ARG" --arg sd "$SET_DEFAULT" '
147
+ .profiles[$n] = {host: $h, sshKey: $k, proxyHost: $p, group: $g}
148
+ | if $sd == "1" or (.defaultProfile // "") == "" then .defaultProfile = $n else . end
149
+ ' "$f")"
150
+ config_atomic_write "$new"
151
+ else
152
+ new="$(jq -n --arg n "$NAME" --arg h "$HOST_ARG" --arg k "$KEY_ABS" --arg p "$PROXY_ARG" --arg g "$GROUP_ARG" '
153
+ {defaultProfile: $n, profiles: {($n): {host: $h, sshKey: $k, proxyHost: $p, group: $g}}}
154
+ ')"
155
+ config_atomic_write "$new"
156
+ fi
157
+ default="$(config_default_profile)"
158
+ say "saved profile '$NAME' -> $f (0600)$([ "$default" == "$NAME" ] && printf ' — default' || true)"
159
+ [[ $SET_DEFAULT -eq 1 ]] || say "make default later: kampodine config set-default $NAME"
160
+ ;;
161
+
162
+ list)
163
+ require_jq
164
+ f="$(config_file)"
165
+ [[ -f "$f" ]] || die "no kampodine config yet — create one: kampodine config init --name <name> --host root@<ip> (--help)"
166
+ default="$(config_default_profile)"
167
+ printf '%-12s %-10s %-8s %-26s %-30s %-26s\n' "NAME" "GROUP" "INIT" "HOST" "SSH-KEY (path)" "PROXY-HOST"
168
+ rows="$(jq -r --arg g "$GROUP_ARG" '
169
+ .profiles // {} | to_entries[]
170
+ | select($g == "" or ((.value.group // "") == $g))
171
+ | [.key,
172
+ (if (.value.group // "") == "" then "-" else .value.group end),
173
+ (if (.value.init // "") == "" then "-" else .value.init end),
174
+ (if (.value.host // "") == "" then "-" else .value.host end),
175
+ (if (.value.sshKey // "") == "" then "-" else .value.sshKey end),
176
+ (if (.value.proxyHost // "") == "" then "-" else .value.proxyHost end)]
177
+ | @tsv' "$f")"
178
+ if [[ -z "$rows" ]]; then
179
+ say "(no profiles${GROUP_ARG:+ in group '$GROUP_ARG'})"
180
+ exit 0
181
+ fi
182
+ while IFS=$'\t' read -r name group init host key proxy; do
183
+ marker=""
184
+ [[ "$name" == "$default" ]] && marker=" * default"
185
+ printf '%-12s %-10s %-8s %-26s %-30s %-26s%s\n' \
186
+ "$name" "$group" "$init" "$host" "$key" "$proxy" "$marker"
187
+ done <<<"$rows"
188
+ ;;
189
+
190
+ show)
191
+ require_jq
192
+ f="$(config_file)"
193
+ [[ -f "$f" ]] || die "no kampodine config yet — create one: kampodine config init --name <name> --host root@<ip> (--help)"
194
+ name="${PROFILE_ARG:-$(config_default_profile)}"
195
+ [[ -n "$name" ]] || die "no default profile — pass --profile <name> (known: $(config_profile_names | paste -sd' ' -))"
196
+ host="$(profile_field "$name" host)" || die "unknown profile '$name' — known: $(config_profile_names | paste -sd' ' -)"
197
+ say "profile: $name$([ "$name" == "$(config_default_profile)" ] && printf ' (default)' || true)"
198
+ printf 'host : %s\n' "$host"
199
+ printf 'sshKey : %s # path only — secrets never live in the config\n' "$(profile_field "$name" sshKey)"
200
+ printf 'proxyHost : %s\n' "$(profile_field "$name" proxyHost)"
201
+ printf 'group : %s\n' "$(profile_field "$name" group)"
202
+ cached_init="$(profile_field "$name" init)"
203
+ [[ -z "$cached_init" ]] || printf 'init : %s # cached by host auto-detection\n' "$cached_init"
204
+ ;;
205
+
206
+ set-default)
207
+ target="${POSITIONAL[0]:-}"
208
+ [[ -n "$target" ]] || die "usage: kampodine config set-default <name>"
209
+ require_jq
210
+ f="$(config_file)"
211
+ [[ -f "$f" ]] || die "no kampodine config yet — create one: kampodine config init --name <name> --host root@<ip> (--help)"
212
+ profile_field "$target" host >/dev/null \
213
+ || die "unknown profile '$target' — known: $(config_profile_names | paste -sd' ' -)"
214
+ new="$(jq --arg n "$target" '.defaultProfile = $n' "$f")"
215
+ config_atomic_write "$new"
216
+ say "default profile: $target"
217
+ ;;
218
+
219
+ remove)
220
+ target="${POSITIONAL[0]:-}"
221
+ [[ -n "$target" ]] || die "usage: kampodine config remove <name> [--force]"
222
+ require_jq
223
+ f="$(config_file)"
224
+ [[ -f "$f" ]] || die "no kampodine config at $f — nothing to remove"
225
+ profile_field "$target" host >/dev/null \
226
+ || die "unknown profile '$target' — known: $(config_profile_names | paste -sd' ' -)"
227
+ count="$(jq '.profiles // {} | length' "$f")"
228
+ if [[ "$count" -eq 1 && $FORCE -ne 1 ]]; then
229
+ die "refusing to remove the last profile ('$target') — pass --force if you really mean it"
230
+ fi
231
+ new="$(jq --arg n "$target" '
232
+ .profiles |= del(.[$n])
233
+ | if .defaultProfile == $n
234
+ then .defaultProfile = ((.profiles | keys | sort) | .[0] // "")
235
+ else . end
236
+ ' "$f")"
237
+ config_atomic_write "$new"
238
+ remaining="$(jq -r '.profiles // {} | keys | length' "$f")"
239
+ say "removed profile '$target' ($remaining remaining)"
240
+ new_default="$(config_default_profile)"
241
+ if [[ "$target" == "$new_default" || -z "$new_default" ]]; then
242
+ :
243
+ else
244
+ say "default profile is now: $new_default"
245
+ fi
246
+ ;;
247
+ esac
@@ -0,0 +1,266 @@
1
+ #!/usr/bin/env bash
2
+ # deploy-lifecycle.sh — deployment lifecycle ops for the target VM, reached
3
+ # as `kampodine deploy <subcommand>` (deploy.sh delegates before any build
4
+ # preflight, so these work outside the app repo, offline, and without podman
5
+ # on the deploy machine). Read-mostly by design: nothing here builds or
6
+ # streams images.
7
+ #
8
+ # The local deployment ledger (~/.kampodine/deployments.jsonl, appended by
9
+ # EVERY deploy/rollback) is the deployment HISTORY; the VM's sha-tagged
10
+ # images are the deployable REALITY. `deploy list` merges both, marks the
11
+ # running tag, and prints the total — no more counting `podman images` by
12
+ # hand after an outage.
13
+ #
14
+ # Usage:
15
+ # kampodine deploy list [--host root@<ip>] [--profile <name>] [--ssh-key <path>]
16
+ # kampodine deploy prune [--host root@<ip>] [--profile <name>] [--keep N] [--dry-run]
17
+ # kampodine deploy logs [--host root@<ip>] [--profile <name>] [--lines N]
18
+ # kampodine deploy restart [--host root@<ip>] [--profile <name>]
19
+ # kampodine deploy shell [--host root@<ip>] [--profile <name>] [exec -- <cmd>...]
20
+ set -euo pipefail
21
+
22
+ HERE="$(cd "$(dirname "$0")" && pwd)"
23
+ # shellcheck source=common.sh
24
+ source "$HERE/common.sh"
25
+
26
+ IMAGE="127.0.0.1:5000/kampodine-api"
27
+ CONTAINER="kampodine-api"
28
+ DISK_PATH="/var/lib/containers"
29
+
30
+ KAMPODINE_HOST="${KAMPODINE_HOST:-}"
31
+ # SSH key resolution — same ladder as deploy.sh:
32
+ # 1. --ssh-key flag (explicit, per-invocation)
33
+ # 2. KAMPODINE_SSH_KEY env (project-level: direnv / .envrc / export)
34
+ # 3. 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
+ SUB="${1:-}"
42
+ say() { printf '\033[1;35m[deploy %s]\033[0m %s\n' "$SUB" "$*"; }
43
+ warn() { printf '\033[1;33m[deploy %s] WARNING:\033[0m %s\n' "$SUB" "$*" >&2; }
44
+ die() { printf '\033[1;31m[deploy %s] FAIL:\033[0m %s\n' "$SUB" "$*" >&2; exit 1; }
45
+ usage() {
46
+ local code="${1:-0}"
47
+ printf 'Usage:\n'
48
+ grep '^# kampodine deploy' "$0" | sed 's/^# //'
49
+ cat <<'EOF'
50
+
51
+ Lifecycle subcommands are --host-aware with the SAME resolution as deploy:
52
+ --host | --profile <name> | KAMPODINE_PROFILE | config defaultProfile |
53
+ KAMPODINE_HOST; --ssh-key | profile sshKey | KAMPODINE_SSH_KEY | ssh-agent /
54
+ ~/.ssh/config.
55
+
56
+ list deployment history: the VM's sha-tagged images (running one marked)
57
+ merged with the local ledger (~/.kampodine/deployments.jsonl —
58
+ every deploy/rollback appends {ts, host, sha, tag, result,
59
+ duration_ms, subject}; shas/tags/hosts only, NEVER secrets) with a
60
+ "Total deployments" footer. VM unreachable degrades to a
61
+ ledger-only view.
62
+ prune reclaim VM disk: removes OLD sha-tagged deploy images. ALWAYS kept:
63
+ the currently-running image, ts-rollback, and the newest --keep N
64
+ (default 2). --dry-run prints the exact podman rmi commands and
65
+ removes nothing; images in use by running containers are refused.
66
+ logs tail the running api container's logs (podman logs --tail).
67
+ restart restart the api service via rc-service (deploy's restart path:
68
+ supervise-daemon re-runs the whole podman run against :latest).
69
+ shell interactive sh inside the running api container; flags must come
70
+ before `exec -- <cmd>...` for one-shot commands.
71
+
72
+ Examples:
73
+ kampodine deploy list --host root@203.0.113.10
74
+ kampodine deploy prune --host root@203.0.113.10 --dry-run
75
+ kampodine deploy prune --host root@203.0.113.10 --keep 3
76
+ kampodine deploy logs --host root@203.0.113.10 --lines 200
77
+ kampodine deploy restart --host root@203.0.113.10
78
+ kampodine deploy shell --host root@203.0.113.10
79
+ kampodine deploy shell --host root@203.0.113.10 exec -- df -h /var/lib/containers
80
+ EOF
81
+ exit "$code"
82
+ }
83
+
84
+ case "$SUB" in
85
+ list|prune|logs|restart|shell) shift ;;
86
+ -h|--help|help) usage 0 ;;
87
+ "") usage 2 ;;
88
+ *)
89
+ printf 'kampodine deploy: unknown lifecycle subcommand: %s\n\n' "$SUB" >&2
90
+ usage 2
91
+ ;;
92
+ esac
93
+
94
+ # shell exec: `deploy shell exec -- <cmd>...` — capture the command before the
95
+ # shared flag loop (flags for shell must come BEFORE `exec`).
96
+ SHELL_EXEC=()
97
+ if [[ "$SUB" == "shell" && "${1:-}" == "exec" ]]; then
98
+ shift
99
+ [[ "${1:-}" == "--" ]] || die "shell exec requires -- before the command: kampodine deploy shell exec -- <cmd>..."
100
+ shift
101
+ [[ $# -gt 0 ]] || die "shell exec -- requires a command"
102
+ SHELL_EXEC=("$@")
103
+ set --
104
+ fi
105
+
106
+ KEEP="2"
107
+ DRY_RUN=0
108
+ LINES="100"
109
+ PROFILE_FLAG=""
110
+ HOST_FLAG=""
111
+ KEY_FLAG=""
112
+ while [[ $# -gt 0 ]]; do
113
+ case "$1" in
114
+ --host) HOST_FLAG="$2"; KAMPODINE_HOST="$2"; shift 2 ;;
115
+ --ssh-key) KEY_FLAG="$2"; SSH_KEY="$2"; shift 2 ;;
116
+ --profile) PROFILE_FLAG="$2"; shift 2 ;;
117
+ --keep) KEEP="$2"; shift 2 ;;
118
+ --dry-run) DRY_RUN=1; shift ;;
119
+ --lines) LINES="$2"; shift 2 ;;
120
+ -h|--help) usage 0 ;;
121
+ --) shift; break ;;
122
+ *) die "unknown argument: $1 (--help)" ;;
123
+ esac
124
+ done
125
+
126
+ case "$SUB" in
127
+ prune) [[ "$KEEP" =~ ^[0-9]+$ ]] || die "--keep must be a non-negative integer (got: $KEEP)" ;;
128
+ logs) [[ "$LINES" =~ ^[1-9][0-9]*$ ]] || die "--lines must be a positive integer (got: $LINES)" ;;
129
+ esac
130
+
131
+ # profile resolution (flag > KAMPODINE_PROFILE > defaultProfile); an explicit
132
+ # --host/--ssh-key flag always beats the profile
133
+ profile_resolve "$PROFILE_FLAG" "$HOST_FLAG" "$KEY_FLAG"
134
+ SSH_KEY="${KEY_FLAG:-${KAMPODINE_SSH_KEY:-}}"
135
+
136
+ SSH_ARGS=(-o ConnectTimeout=10 -o BatchMode=yes)
137
+ [[ -n "$SSH_KEY" ]] && SSH_ARGS+=(-i "$SSH_KEY")
138
+ # $1 is a composed remote command — client-side expansion is the design.
139
+ # shellcheck disable=SC2029
140
+ vm() { ssh "${SSH_ARGS[@]}" "$KAMPODINE_HOST" "$1"; }
141
+
142
+ # --- macOS ssh-agent quirk (first run from a fresh machine) -------------------
143
+ if [[ "$(uname -s)" == "Darwin" ]]; then
144
+ SSH_AUTH_SOCK="$(launchctl getenv SSH_AUTH_SOCK 2>/dev/null || true)"
145
+ export SSH_AUTH_SOCK
146
+ fi
147
+
148
+ require_host() {
149
+ [[ -n "$KAMPODINE_HOST" ]] || die "target required: --host root@<ip>, --profile <name>, KAMPODINE_PROFILE, config defaultProfile, or KAMPODINE_HOST=root@<ip> (resolution matches deploy.sh)"
150
+ }
151
+
152
+ # fetch_image_listing -> "tag|CreatedAt|Size" lines for sha-tagged deploy
153
+ # images; returns 1 only when the VM itself is unreachable (podman's own
154
+ # "no images" exits 0 — a reachable VM with zero images is not an error).
155
+ fetch_image_listing() {
156
+ local raw
157
+ raw="$(vm "podman images --format '{{.Tag}}|{{.CreatedAt}}|{{.Size}}' $IMAGE 2>/dev/null")" || return 1
158
+ printf '%s\n' "$raw" | grep -E '^[0-9a-f]{7,40}\|' || true
159
+ }
160
+
161
+ fetch_running_images() {
162
+ vm "podman ps --format '{{.Image}}' 2>/dev/null" || return 1
163
+ }
164
+
165
+ # sh_quote_args <args...> -> the args POSIX-single-quoted for safe composition
166
+ # into one remote sh command string (busybox-safe: '\'' escaping only).
167
+ sh_quote_args() {
168
+ local out="" arg
169
+ for arg in "$@"; do
170
+ arg="${arg//\'/\'\\\'\'}"
171
+ out+="'$arg' "
172
+ done
173
+ printf '%s' "${out% }"
174
+ }
175
+
176
+ case "$SUB" in
177
+ list)
178
+ require_host
179
+ say "deployment history for $KAMPODINE_HOST (ledger: $(ledger_file))"
180
+ images="$(fetch_image_listing)" || images=""
181
+ running="$(fetch_running_images)" || running=""
182
+ if [[ -z "$images" && -z "$running" ]]; then
183
+ if ! vm "podman images >/dev/null 2>&1"; then
184
+ warn "cannot reach VM $KAMPODINE_HOST — showing ledger-only history (no VM truth)"
185
+ fi
186
+ fi
187
+ ledger_lines="$(ledger_entries "$KAMPODINE_HOST")"
188
+ rows="$(render_deploy_list "$images" "$running" "$ledger_lines")"
189
+ printf '%-9s %-17s %-9s %-9s %s\n' "TAG" "CREATED" "STATE" "RESULT" "SUBJECT"
190
+ if [[ -n "$rows" ]]; then
191
+ printf '%s\n' "$rows"
192
+ else
193
+ say "(no deployments recorded yet — the ledger fills as you deploy)"
194
+ fi
195
+ count="$(printf '%s\n' "$rows" | grep -c . || true)"
196
+ ledger_total_line "${count:-0}" "on $KAMPODINE_HOST"
197
+ ;;
198
+
199
+ prune)
200
+ require_host
201
+ images="$(fetch_image_listing)" || die "cannot list images on $KAMPODINE_HOST (ssh failed — refusing to prune blind)"
202
+ running="$(fetch_running_images)" || running=""
203
+ removals="$(prune_select "$images" "$running" "$KEEP")" || die "prune selection failed"
204
+ if [[ -z "$removals" ]]; then
205
+ say "nothing to prune (keep set: running + ts-rollback + newest $KEEP)"
206
+ exit 0
207
+ fi
208
+ reclaim="$(sum_sizes_human "$(printf '%s\n' "$removals" | cut -d'|' -f2)")"
209
+ plan_n="$(printf '%s\n' "$removals" | grep -c . || true)"
210
+ say "plan: ${plan_n} image(s), ${reclaim} reclaimed (keep: running + ts-rollback + newest $KEEP)"
211
+ while IFS= read -r line; do
212
+ [[ -n "$line" ]] || continue
213
+ tag="${line%%|*}"
214
+ say " podman rmi $IMAGE:$tag # ${line#*|}"
215
+ done <<<"$removals"
216
+ if [[ "$DRY_RUN" -eq 1 ]]; then
217
+ say "dry-run: nothing removed — re-run without --dry-run to apply"
218
+ exit 0
219
+ fi
220
+ while IFS= read -r line; do
221
+ [[ -n "$line" ]] || continue
222
+ tag="${line%%|*}"
223
+ say "removing $IMAGE:$tag"
224
+ vm "podman rmi $IMAGE:$tag" || die "podman rmi $IMAGE:$tag failed (in use? remove manually after checking podman ps)"
225
+ done <<<"$removals"
226
+ disk_pct="$(disk_pct_from_df "$(vm "df -P $DISK_PATH 2>/dev/null" || true)")"
227
+ if [[ -n "$disk_pct" ]]; then
228
+ say "VM disk after prune: ${disk_pct}% used on $DISK_PATH"
229
+ if [[ "$disk_pct" -gt 90 ]]; then
230
+ warn "disk still above 90% — investigate before the next deploy"
231
+ fi
232
+ fi
233
+ ;;
234
+
235
+ logs)
236
+ require_host
237
+ say "tailing $CONTAINER on $KAMPODINE_HOST (last $LINES lines)"
238
+ vm "podman logs --tail $LINES $CONTAINER"
239
+ ;;
240
+
241
+ restart)
242
+ require_host
243
+ init_detect vm >/dev/null 2>&1 || true
244
+ RESTART_CMD="$(svc_action "$CONTAINER" restart)" \
245
+ || die "neither rc-service nor systemctl found on $KAMPODINE_HOST — cannot restart (host init auto-detection detected neither; groundwork supports openrc + systemd)"
246
+ say "$RESTART_CMD (deploy's restart path: supervise-daemon on Alpine re-runs podman run on :latest)"
247
+ vm "$RESTART_CMD"
248
+ STATUS_CMD="$(svc_action "$CONTAINER" status 2>/dev/null || true)"
249
+ if [[ -n "$STATUS_CMD" ]]; then
250
+ say "service status:"
251
+ vm "$STATUS_CMD" || say "status command failed — check: kampodine deploy logs"
252
+ fi
253
+ say "done — verify: kampodine deploy logs --lines 50"
254
+ ;;
255
+
256
+ shell)
257
+ require_host
258
+ if [[ ${#SHELL_EXEC[@]} -gt 0 ]]; then
259
+ vm "podman exec $CONTAINER $(sh_quote_args "${SHELL_EXEC[@]}")"
260
+ else
261
+ say "interactive sh in $CONTAINER on $KAMPODINE_HOST (exit to leave)"
262
+ say "one-shot instead: kampodine deploy shell exec -- <cmd>"
263
+ ssh "${SSH_ARGS[@]}" -t "$KAMPODINE_HOST" "podman exec -it $CONTAINER sh"
264
+ fi
265
+ ;;
266
+ esac