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/scripts/deploy.sh CHANGED
@@ -20,11 +20,26 @@
20
20
  # kampodine deploy --version <sha7> # stream an existing local build
21
21
  # kampodine deploy --rollback [<sha7>] # default: previous
22
22
  # kampodine deploy --host root@<ip> ... # target VM override
23
+ # kampodine deploy --profile <name> ... # per-instance profile (config.json)
23
24
  # kampodine deploy --dockerfile apps/api-go/Dockerfile.cutover # Go api image
25
+ # kampodine deploy list|prune|logs|restart|shell # lifecycle ops (each has --help)
24
26
  # --skip-smoke # skip the public smoke (behind a not-yet-switched proxy)
25
27
  # --refresh-config # ansible container-service refresh BEFORE restart
28
+ # --require-disk <pct> # fail closed when VM disk usage >= pct (default: warn above 90)
26
29
  set -euo pipefail
27
30
 
31
+ HERE="$(cd "$(dirname "$0")" && pwd)"
32
+ # Lifecycle subcommands (list/prune/logs/restart/shell) are read-mostly
33
+ # VM/ledger ops — they never build or stream, must work OUTSIDE the app repo,
34
+ # and must not pay the deploy preflight (git/podman). Delegate first.
35
+ case "${1:-}" in
36
+ list|prune|logs|restart|shell)
37
+ exec bash "$HERE/deploy-lifecycle.sh" "$@"
38
+ ;;
39
+ esac
40
+ # shellcheck source=common.sh
41
+ source "$HERE/common.sh"
42
+
28
43
  REPO_ROOT="$(git rev-parse --show-toplevel)"
29
44
  KAMPODINE_HOST="${KAMPODINE_HOST:-}"
30
45
  PROXY_HOST="${PROXY_HOST:-app.example.com}"
@@ -32,15 +47,21 @@ ENV_FILE_REMOTE="/etc/kampodine/env"
32
47
  DEPLOYED_SHA_FILE="/etc/kampodine/deployed-sha"
33
48
  ENV_CLEAR_KEYS='^(NODE_ENV|PORT|LIBSQL_TENANT_DIR|LIBSQL_API_MOUNT|STATIC_SPA_MOUNT)='
34
49
  IMAGE="127.0.0.1:5000/kampodine-api"
50
+ DISK_PATH="/var/lib/containers"
35
51
 
36
52
  MODE="deploy"
37
53
  VERSION=""
38
54
  SKIP_SMOKE=0
39
55
  REFRESH_CONFIG=0
56
+ REQUIRE_DISK=""
57
+ PROFILE_FLAG=""
58
+ HOST_FLAG=""
59
+ KEY_FLAG=""
40
60
  # SSH key resolution — GENERIC, no hardcoded personal paths:
41
61
  # 1. --ssh-key flag (explicit, per-invocation)
42
- # 2. KAMPODINE_SSH_KEY env (project-level: direnv / .envrc / export)
43
- # 3. empty → ssh-agent and/or the operator's ~/.ssh/config Host block
62
+ # 2. profile sshKey (--profile | KAMPODINE_PROFILE | defaultProfile)
63
+ # 3. KAMPODINE_SSH_KEY env (legacy: direnv / .envrc / export)
64
+ # 4. empty → ssh-agent and/or the operator's ~/.ssh/config Host block
44
65
  # (the POSIX way: per-host IdentityFile belongs in ssh config, not here)
45
66
  if [ -n "${KAMPODINE_SSH_KEY:-}" ]; then
46
67
  SSH_KEY="$KAMPODINE_SSH_KEY"
@@ -57,7 +78,7 @@ die() { printf '\033[1;31m[deploy] FAIL:\033[0m %s\n' "$*" >&2; exit 1; }
57
78
  usage() {
58
79
  cat <<'EOF'
59
80
  Usage:
60
- kampodine deploy [--host root@<ip>] [--version <sha7>] [--rollback [<sha7>]]
81
+ kampodine deploy [--host root@<ip>] [--profile <name>] [--version <sha7>] [--rollback [<sha7>]]
61
82
  [--dockerfile <path>] [--ssh-key <path>] [--skip-smoke] [--refresh-config]
62
83
 
63
84
  Examples:
@@ -65,16 +86,19 @@ EOF
65
86
  grep '^# kampodine deploy' "$0" | sed 's/^# //'
66
87
  cat <<'EOF'
67
88
 
68
- Host/key resolution: --host | KAMPODINE_HOST; --ssh-key | KAMPODINE_SSH_KEY |
69
- KAMPODINE_SSH_KEY | ssh-agent / ~/.ssh/config.
89
+ Host/key resolution: --host | --profile <name> | KAMPODINE_PROFILE |
90
+ config defaultProfile | KAMPODINE_HOST; --ssh-key | profile sshKey |
91
+ KAMPODINE_SSH_KEY | ssh-agent / ~/.ssh/config. Profiles:
92
+ kampodine config init --name prod --host root@<ip> --ssh-key <path>.
70
93
  EOF
71
94
  exit 0
72
95
  }
73
96
 
74
97
  while [[ $# -gt 0 ]]; do
75
98
  case "$1" in
76
- --host) KAMPODINE_HOST="$2"; shift 2 ;;
77
- --ssh-key) SSH_KEY="$2"; shift 2 ;;
99
+ --host) HOST_FLAG="$2"; KAMPODINE_HOST="$2"; shift 2 ;;
100
+ --ssh-key) KEY_FLAG="$2"; SSH_KEY="$2"; shift 2 ;;
101
+ --profile) PROFILE_FLAG="$2"; shift 2 ;;
78
102
  --version) VERSION="$2"; MODE="version"; shift 2 ;;
79
103
  --rollback)
80
104
  MODE="rollback"
@@ -82,13 +106,21 @@ while [[ $# -gt 0 ]]; do
82
106
  ;;
83
107
  --skip-smoke) SKIP_SMOKE=1; shift ;;
84
108
  --refresh-config) REFRESH_CONFIG=1; shift ;;
109
+ --require-disk) REQUIRE_DISK="$2"; shift 2 ;;
85
110
  --dockerfile) DOCKERFILE="$2"; shift 2 ;;
86
111
  -h|--help) usage ;;
87
112
  *) die "unknown argument: $1 (--help)" ;;
88
113
  esac
89
114
  done
90
115
  [[ "$VERSION" != *..* && "$VERSION" =~ ^[0-9a-f]{4,40}$|^$ ]] || die "--version must be a git sha fragment"
91
- [ -n "$KAMPODINE_HOST" ] || die "set KAMPODINE_HOST=root@<vm-ip> (or a ~/.ssh/config Host alias via --host)"
116
+ [[ -z "$REQUIRE_DISK" || "$REQUIRE_DISK" =~ ^[0-9]+$ && "$REQUIRE_DISK" -le 100 ]] || die "--require-disk must be a percentage 0-100 (got: $REQUIRE_DISK)"
117
+ # profile resolution (flag > KAMPODINE_PROFILE > defaultProfile); an explicit
118
+ # --host/--ssh-key flag always beats the profile, the profile beats the
119
+ # legacy env; proxyHost from the profile REPLACES the PROXY_HOST env
120
+ profile_resolve "$PROFILE_FLAG" "$HOST_FLAG" "$KEY_FLAG"
121
+ SSH_KEY="${KEY_FLAG:-${KAMPODINE_SSH_KEY:-}}"
122
+ PROXY_HOST="${PROFILE_PROXY_HOST:-${PROXY_HOST:-app.example.com}}"
123
+ [ -n "$KAMPODINE_HOST" ] || die "target required: --host root@<vm-ip>, --profile <name>, KAMPODINE_PROFILE, config defaultProfile, or KAMPODINE_HOST=root@<vm-ip>"
92
124
 
93
125
  SSH_ARGS=(-o ConnectTimeout=10 -o BatchMode=yes)
94
126
  [[ -n "$SSH_KEY" ]] && SSH_ARGS+=(-i "$SSH_KEY")
@@ -102,6 +134,67 @@ if [[ "$(uname -s)" == "Darwin" ]]; then
102
134
  export SSH_AUTH_SOCK
103
135
  fi
104
136
 
137
+ # --- host init detection (first connect): openrc vs systemd + http client ----
138
+ # Groundwork for non-Alpine targets; on Alpine the verdict is openrc and every
139
+ # svc_action output is byte-identical to the historical strings. The verdict
140
+ # is cached in the active profile entry ("init" field) when one resolves.
141
+ init_detect vm >/dev/null 2>&1 || true
142
+
143
+ # --- deployment ledger: every run appends to ~/.kampodine/deployments.jsonl ---
144
+ # --- {ts, host, sha, tag, result, duration_ms, subject} — shas/tags/hosts ----
145
+ # --- only, NEVER secrets. The EXIT trap records failures + prints the footer --
146
+ # --- so EVERY deploy run ends with the total deployments count. --
147
+ DEPLOY_START_SECONDS="$SECONDS"
148
+ DEPLOY_RECORDING=0
149
+ DEPLOY_APPENDED=0
150
+ DEPLOY_RESULT="success"
151
+ ENV_TMP=""
152
+ deploy_on_exit() {
153
+ local rc=$?
154
+ [[ -n "$ENV_TMP" ]] && rm -f "$ENV_TMP" 2>/dev/null
155
+ if [[ "$DEPLOY_RECORDING" -eq 1 && "$DEPLOY_APPENDED" -ne 1 ]]; then
156
+ # never made it to the append: this run FAILED
157
+ ledger_append "$KAMPODINE_HOST" "$VER" "$VER" "failed" \
158
+ $(( (SECONDS - DEPLOY_START_SECONDS) * 1000 )) "" || true
159
+ DEPLOY_RESULT="failed"
160
+ fi
161
+ if [[ "$DEPLOY_RECORDING" -eq 1 ]]; then
162
+ if [[ "$DEPLOY_RESULT" == "failed" ]]; then
163
+ say "Total deployments: $(ledger_count "$KAMPODINE_HOST") · FAILED — attempted tag: $VER (not activated)"
164
+ else
165
+ say "Total deployments: $(ledger_count "$KAMPODINE_HOST") · current tag: $VER"
166
+ fi
167
+ fi
168
+ exit "$rc"
169
+ }
170
+ trap deploy_on_exit EXIT
171
+
172
+ # --- disk guard (2026-10-08 incident class: old sha-tagged deploy images ------
173
+ # --- silently filled the VM disk until sqlite writes failed with I/O errors). -
174
+ # --- Read-only VM df; LOUD warning above 90%; --require-disk <pct> fails closed
175
+ # --- BEFORE the build. phase = gate (pre-deploy, may fail) | report (warn-only)
176
+ vm_disk_check() {
177
+ local phase="${1:-report}" df_out pct verdict
178
+ df_out="$(vm "df -P $DISK_PATH 2>/dev/null" || true)"
179
+ pct="$(disk_pct_from_df "$df_out")"
180
+ verdict="$(disk_verdict "$pct" "$REQUIRE_DISK")"
181
+ case "$verdict" in
182
+ fail)
183
+ die "VM disk at ${pct}% (>= --require-disk ${REQUIRE_DISK}%) — free space first: kampodine deploy prune --dry-run"
184
+ ;;
185
+ warn)
186
+ say "WARNING: VM disk $DISK_PATH at ${pct}% — old sha-tagged deploy images pile up (~1GB each)"
187
+ say "WARNING: reclaim space: kampodine deploy prune --dry-run (this deploy continues)"
188
+ ;;
189
+ unknown)
190
+ if [[ "$phase" == "gate" && -n "$REQUIRE_DISK" ]]; then
191
+ die "cannot read VM disk usage (df $DISK_PATH) — --require-disk is fail-closed"
192
+ fi
193
+ [[ "$phase" == "gate" ]] && say "disk usage unknown (df unreadable) — continuing"
194
+ ;;
195
+ esac
196
+ }
197
+
105
198
  # --- preflight -----------------------------------------------------------------
106
199
  cd "$REPO_ROOT"
107
200
  if [[ "$MODE" == "deploy" ]]; then
@@ -109,10 +202,15 @@ if [[ "$MODE" == "deploy" ]]; then
109
202
  fi
110
203
  command -v podman >/dev/null 2>&1 || die "podman not found on the Mac (podman machine must run: podman machine start)"
111
204
  podman info >/dev/null 2>&1 || die "podman machine not reachable (podman machine start)"
205
+ # disk gate: BEFORE the build — failing fast beats streaming 1GB into a full disk
206
+ vm_disk_check gate
112
207
  # version mode: stream an EXISTING local build (no rebuild, no clean-tree gate —
113
208
  # the build already happened when that sha was HEAD)
114
209
  [[ "$MODE" == "version" ]] && VER="${VERSION:-$(git rev-parse --short HEAD)}"
115
210
  [[ "$MODE" != "version" ]] && VER="$(git rev-parse --short HEAD)"
211
+ # from here on, every outcome is recorded in the ledger (success via the
212
+ # append at the end; anything else via the EXIT trap)
213
+ DEPLOY_RECORDING=1
116
214
 
117
215
  # --- build (deploy mode always builds HEAD — cached layers keep it minutes) ----
118
216
  if [[ "$MODE" == "deploy" ]]; then
@@ -141,7 +239,7 @@ vm "podman tag $IMAGE:$VER $IMAGE:latest" || die "stream retag failed"
141
239
  # --- env file from varlock (schema-only from HEAD; secrets from pass) ----------
142
240
  say "generating $ENV_FILE_REMOTE from varlock (schema-only from HEAD; secrets resolve from pass — no plaintext secret files)…"
143
241
  ENV_TMP="$(mktemp /tmp/kampodine-env.XXXXXX)"
144
- trap 'rm -f "$ENV_TMP"' EXIT
242
+ # (the EXIT trap — deploy_on_exit — removes $ENV_TMP on every exit path)
145
243
  # Schema-only resolution: extract the COMMITTED schema into a scratch dir
146
244
  # inside the repo (plugin resolution needs node_modules; local
147
245
  # .env/.env.local never enter the pipeline — varlock sees only the extracted
@@ -169,17 +267,20 @@ if [[ $REFRESH_CONFIG -eq 1 ]]; then
169
267
  || die "ansible playbook failed"
170
268
  fi
171
269
 
172
- say "rc-service kampodine-api restart (supervise-daemon: stop-old/start-new podman run on the retagged :latest)…"
173
- vm "rc-service kampodine-api restart" || die "kampodine-api restart failed (rc-service kampodine-api status)"
270
+ RESTART_CMD="$(svc_action kampodine-api restart)" \
271
+ || die "neither rc-service nor systemctl found on $KAMPODINE_HOST — cannot restart the service (host init auto-detection failed)"
272
+ STATUS_CMD="$(svc_action kampodine-api status 2>/dev/null || true)"
273
+ say "$RESTART_CMD (supervise-daemon on Alpine: stop-old/start-new podman run on the retagged :latest)…"
274
+ vm "$RESTART_CMD" || die "kampodine-api restart failed (${STATUS_CMD:-check the service})"
174
275
 
175
- say "health probe (host-side wget /api/auth/ok + served-sha — works for node AND scratch images)…"
276
+ say "health probe (host-side fetch of /api/auth/ok + served-sha — busybox wget default, curl fallback on GNU-only hosts)…"
176
277
  HEALTH_OK=0
177
278
  for _ in $(seq 1 30); do
178
- # wget runs on the VM (busybox, -p 8080:8080 publish); the served-sha
179
- # grep runs LOCALLY — remote grep quoting is fragile (busybox BRE
180
- # (busybox BRE alternation + nested quotes → permanent false-negative).
181
- # grep -E keeps the alternation portable across BSD/GNU/busybox.
182
- if vm "wget -qO- -T 3 http://127.0.0.1:8080/api/auth/ok 2>/dev/null" \
279
+ # the fetch runs on the VM (busybox wget by default; curl when the host has
280
+ # none — remote_http_cmd in common.sh); the served-sha grep runs LOCALLY —
281
+ # remote grep quoting is fragile (busybox BRE alternation + nested quotes →
282
+ # permanent false-negative). grep -E keeps the alternation portable.
283
+ if vm "$(remote_http_cmd "http://127.0.0.1:8080/api/auth/ok")" \
183
284
  | grep -qE "\"(git|build)\":\"$VER"; then
184
285
  HEALTH_OK=1
185
286
  break
@@ -188,7 +289,7 @@ for _ in $(seq 1 30); do
188
289
  done
189
290
  if [[ $HEALTH_OK -ne 1 ]]; then
190
291
  vm "podman logs --tail 30 kampodine-api 2>&1" || true
191
- vm "rc-service kampodine-api status 2>&1" || true
292
+ vm "$(svc_action kampodine-api status 2>/dev/null || echo 'true') 2>&1" || true
192
293
  PREV_HINT="$(vm "test -f $DEPLOYED_SHA_FILE && cat $DEPLOYED_SHA_FILE" 2>/dev/null || true)"
193
294
  die "container never became healthy — rollback: kampodine deploy --rollback${PREV_HINT:+ $PREV_HINT}"
194
295
  fi
@@ -219,4 +320,21 @@ vm "podman image prune -f >/dev/null 2>&1 || true"
219
320
  # busybox head lacks -n -N — tail -n +4 after a newest-first sort keeps 3 tags.
220
321
  vm "podman images --format '{{.Tag}} {{.CreatedAt}}' $IMAGE 2>/dev/null | grep -E '^[0-9a-f]{7,40} ' | sort -k2,2r | tail -n +4 | awk '{print \$1}' | while read -r old; do podman rmi \"$IMAGE:\$old\" >/dev/null 2>&1 || true; done" || true
221
322
  [[ "$MODE" == "rollback" ]] || vm "echo $VER > $DEPLOYED_SHA_FILE" || true
323
+
324
+ # --- post-deploy disk report (warn-only — the deploy already succeeded) --------
325
+ vm_disk_check report
326
+
327
+ # --- deployment ledger append: this run made it all the way through ------------
328
+ DEPLOY_RESULT="success"
329
+ [[ "$MODE" == "rollback" ]] && DEPLOY_RESULT="rollback"
330
+ if [[ "$MODE" == "rollback" ]]; then
331
+ # the subject of a rollback is the ORIGINAL deploy's subject — the ledger
332
+ # knows it; git HEAD would be the wrong commit's message
333
+ SUBJECT="$(ledger_subject_for_tag "$KAMPODINE_HOST" "$VER")"
334
+ else
335
+ SUBJECT="$(git -C "$REPO_ROOT" log -1 --pretty=%s 2>/dev/null || true)"
336
+ fi
337
+ ledger_append "$KAMPODINE_HOST" "$VER" "$VER" "$DEPLOY_RESULT" \
338
+ $(( (SECONDS - DEPLOY_START_SECONDS) * 1000 )) "$SUBJECT" || true
339
+ DEPLOY_APPENDED=1
222
340
  say "done (version: $VER). instant rollback: kampodine deploy --rollback"
package/scripts/dns.sh CHANGED
@@ -23,9 +23,21 @@
23
23
  # (all: optional --zone <id-or-name>; --instance-principal for instance auth)
24
24
  set -euo pipefail
25
25
 
26
+ # NOTE: no $(dirname) here — some hermetic test PATHs strip coreutils;
27
+ # parameter expansion keeps HERE resolution dependency-free.
28
+ HERE="${BASH_SOURCE[0]%/*}"
29
+ # shellcheck source=common.sh
30
+ source "$HERE/common.sh"
31
+
32
+ # NOTE on --profile: dns.sh follows the OCI auth convention — PROFILE below is
33
+ # the OCI CONFIG profile (OCI_PROFILE). The kampodine INSTANCE profile flag
34
+ # (--profile <name>) is parsed into PROFILE_FLAG and validated via
35
+ # profile_resolve (uniform resolution across every subcommand); dns consumes
36
+ # no instance host/sshKey fields.
26
37
  PROFILE="${OCI_PROFILE:-default}"
27
38
  COMPARTMENT_NAME="${OCI_COMPARTMENT:-kampodine}"
28
39
  AUTH_ARGS=()
40
+ PROFILE_FLAG=""
29
41
 
30
42
  say() { printf '\033[1;34m[dns]\033[0m %s\n' "$*"; }
31
43
  die() { printf '\033[1;31m[dns] FAIL:\033[0m %s\n' "$*" >&2; exit 1; }
@@ -100,12 +112,17 @@ while [[ $# -gt 0 ]]; do
100
112
  --type) RTYPE="$2"; shift 2 ;;
101
113
  --value) VALUE="$2"; shift 2 ;;
102
114
  --ttl) TTL="$2"; shift 2 ;;
115
+ --profile) PROFILE_FLAG="$2"; shift 2 ;;
103
116
  --instance-principal) AUTH_ARGS=(--auth instance_principal); shift ;;
104
117
  -h|--help) usage 0 ;;
105
118
  *) die "unknown argument: $1 (--help)" ;;
106
119
  esac
107
120
  done
108
121
 
122
+ # validate the kampodine instance profile when selected (uniform resolution);
123
+ # dns consumes none of its host/sshKey fields
124
+ profile_resolve "$PROFILE_FLAG" "" ""
125
+
109
126
  need_provider() {
110
127
  command -v oci >/dev/null 2>&1 \
111
128
  || 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"
package/scripts/env.sh CHANGED
@@ -13,16 +13,20 @@
13
13
  # ssh-agent (the POSIX default; per-host IdentityFile belongs in ssh config)
14
14
  #
15
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)
16
+ # kampodine env list [--host <user@ip>] [--profile <name>] # KEY + fingerprint table (NEVER values)
17
+ # kampodine env push --file <local-env-file> [...] [--profile <name>] # upload: 0600 temp + atomic mv + restart hint
18
+ # kampodine env pull [--out <file>] [...] [--profile <name>] # 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
20
  #
21
21
  # push sends the file VERBATIM (what you push is what lands). deploy's env
22
22
  # step additionally drops rc-script-owned CLEAR keys — keep those out of the
23
23
  # file you push unless you mean to own them here.
24
24
  set -euo pipefail
25
25
 
26
+ HERE="${BASH_SOURCE[0]%/*}"
27
+ # shellcheck source=common.sh
28
+ source "$HERE/common.sh"
29
+
26
30
  ENV_FILE_REMOTE="${KAMPODINE_ENV_REMOTE:-/etc/kampodine/env}"
27
31
  ENV_TMP_BASE="$(dirname "$ENV_FILE_REMOTE")"
28
32
 
@@ -47,8 +51,9 @@ usage() {
47
51
 
48
52
  Fingerprints NEVER leak values: every line is KEY + value length + first 2 chars.
49
53
  Raw values move only in push's upload stream and pull's stdout/--out payload.
50
- Host/key resolution matches deploy.sh: --host | KAMPODINE_HOST; --ssh-key |
51
- KAMPODINE_SSH_KEY | ssh-agent / ~/.ssh/config.
54
+ Host/key resolution matches deploy.sh: --host | --profile <name> |
55
+ KAMPODINE_PROFILE | config defaultProfile | KAMPODINE_HOST; --ssh-key |
56
+ profile sshKey | KAMPODINE_SSH_KEY | ssh-agent / ~/.ssh/config.
52
57
 
53
58
  Examples:
54
59
  kampodine env list --host root@203.0.113.10
@@ -70,10 +75,14 @@ esac
70
75
 
71
76
  FILE=""
72
77
  OUT=""
78
+ PROFILE_FLAG=""
79
+ HOST_FLAG=""
80
+ KEY_FLAG=""
73
81
  while [[ $# -gt 0 ]]; do
74
82
  case "$1" in
75
- --host) KAMPODINE_HOST="$2"; shift 2 ;;
76
- --ssh-key) SSH_KEY="$2"; shift 2 ;;
83
+ --host) HOST_FLAG="$2"; KAMPODINE_HOST="$2"; shift 2 ;;
84
+ --ssh-key) KEY_FLAG="$2"; SSH_KEY="$2"; shift 2 ;;
85
+ --profile) PROFILE_FLAG="$2"; shift 2 ;;
77
86
  --file) FILE="$2"; shift 2 ;;
78
87
  --out) OUT="$2"; shift 2 ;;
79
88
  -h|--help) usage ;;
@@ -81,6 +90,11 @@ while [[ $# -gt 0 ]]; do
81
90
  esac
82
91
  done
83
92
 
93
+ # profile resolution (flag > KAMPODINE_PROFILE > defaultProfile); an explicit
94
+ # --host/--ssh-key flag always beats the profile
95
+ profile_resolve "$PROFILE_FLAG" "$HOST_FLAG" "$KEY_FLAG"
96
+ SSH_KEY="${KEY_FLAG:-${KAMPODINE_SSH_KEY:-}}"
97
+
84
98
  SSH_ARGS=(-o ConnectTimeout=10 -o BatchMode=yes)
85
99
  [[ -n "$SSH_KEY" ]] && SSH_ARGS+=(-i "$SSH_KEY")
86
100
  # $1 is a composed remote command — client-side expansion is the design.
@@ -94,7 +108,7 @@ if [[ "$(uname -s)" == "Darwin" ]]; then
94
108
  fi
95
109
 
96
110
  require_host() {
97
- [[ -n "$KAMPODINE_HOST" ]] || die "target required: --host root@<ip> or KAMPODINE_HOST=root@<ip> (resolution matches deploy.sh)"
111
+ [[ -n "$KAMPODINE_HOST" ]] || die "target required: --host root@<ip>, --profile <name>, KAMPODINE_PROFILE, config defaultProfile, or KAMPODINE_HOST=root@<ip> (resolution matches deploy.sh)"
98
112
  }
99
113
 
100
114
  # env_fp_lines — env content on stdin -> fingerprint table on stdout.
@@ -36,6 +36,7 @@ COMPARTMENT_NAME="${OCI_COMPARTMENT:-kampodine}"
36
36
  PROFILE="${OCI_PROFILE:-default}"
37
37
  FROM_PASS=0
38
38
  KEEP_OBJECT=0
39
+ PROFILE_FLAG=""
39
40
 
40
41
  say() { printf '[import] %s\n' "$*"; }
41
42
  die() { printf '[import][FAIL] %s\n' "$*" >&2; exit 1; }
@@ -57,6 +58,7 @@ while [[ $# -gt 0 ]]; do
57
58
  --bucket) BUCKET="$2"; shift 2 ;;
58
59
  --name-prefix) NAME_PREFIX="$2"; shift 2 ;;
59
60
  --compartment) COMPARTMENT_NAME="$2"; shift 2 ;;
61
+ --profile) PROFILE_FLAG="$2"; shift 2 ;;
60
62
  --from-pass) FROM_PASS=1; shift ;;
61
63
  --keep-object) KEEP_OBJECT=1; shift ;;
62
64
  -h|--help) usage ;;
@@ -64,6 +66,13 @@ while [[ $# -gt 0 ]]; do
64
66
  esac
65
67
  done
66
68
 
69
+ # validate the kampodine instance profile when selected (uniform resolution);
70
+ # image-import consumes none of its host/sshKey fields (OCI-only surface)
71
+ HERE="${BASH_SOURCE[0]%/*}"
72
+ # shellcheck source=common.sh
73
+ source "$HERE/common.sh"
74
+ profile_resolve "$PROFILE_FLAG" "" ""
75
+
67
76
  command -v oci >/dev/null 2>&1 || die "oci CLI not found — brew install oci-cli, then: oci setup config (infra/oci/README.md § CLI setup)"
68
77
  [[ -n "${OCI_COMPARTMENT:-}" ]] || die "set OCI_COMPARTMENT=<compartment name or ocid> — no default: compartments are account-specific"
69
78
  command -v pass >/dev/null 2>&1 || true
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env bash
2
+ # metrics.sh — ONE-SHOT VM snapshot over SSH (no continuous monitoring —
3
+ # Beszel owns that; this is the CLI triage view). One SSH round-trip per
4
+ # snapshot: load + CPUs, memory, disk WITH an images/data/other breakdown,
5
+ # per-container CPU/MEM (podman stats), uptime, top-5 processes by RSS.
6
+ #
7
+ # The remote command is busybox-safe (cat/echo/nproc/df/du/podman/ps/sort/
8
+ # head — see METRICS_REMOTE_CMD in common.sh); all parsing/rendering happens
9
+ # client-side (metrics_* helpers in common.sh — the SAME snapshot folds into
10
+ # `status --verbose`). Disk threshold reuses the deploy gate's verdict logic
11
+ # (disk_verdict): --warn-disk <pct> (default 90) exits 1 when usage >= pct.
12
+ #
13
+ # Usage:
14
+ # kampodine metrics [--profile <name>] [--host <user@ip>] [--ssh-key <path>]
15
+ # [--warn-disk <pct>] [--watch <sec>] [--count <n>]
16
+ set -euo pipefail
17
+
18
+ HERE="$(cd "$(dirname "$0")" && pwd)"
19
+ # shellcheck source=common.sh
20
+ source "$HERE/common.sh"
21
+
22
+ KAMPODINE_HOST="${KAMPODINE_HOST:-}"
23
+ if [ -n "${KAMPODINE_SSH_KEY:-}" ]; then
24
+ SSH_KEY="$KAMPODINE_SSH_KEY"
25
+ else
26
+ SSH_KEY=""
27
+ fi
28
+ PROFILE_FLAG=""
29
+ HOST_FLAG=""
30
+ KEY_FLAG=""
31
+ WARN_DISK="90"
32
+ WATCH=""
33
+ COUNT=""
34
+
35
+ say() { printf '\033[1;36m[metrics]\033[0m %s\n' "$*"; }
36
+ die() { printf '\033[1;31m[metrics] FAIL:\033[0m %s\n' "$*" >&2; exit 1; }
37
+ usage() {
38
+ local code="${1:-0}"
39
+ printf 'Usage:\n'
40
+ grep '^# kampodine metrics' "$0" | sed 's/^# //'
41
+ cat <<'EOF'
42
+
43
+ One SSH round-trip per snapshot; busybox-safe remote commands; rendering is
44
+ client-side. NO continuous monitoring (Beszel owns that) — one-shot CLI view;
45
+ --watch N re-snapshots every N seconds (Ctrl-C ends), --count M bounds the
46
+ iterations. --warn-disk <pct> (default 90) exits 1 when root disk usage is at
47
+ or above the threshold — the SAME verdict logic as deploy's fail-closed gate.
48
+ Host resolution: --host | --profile <name> | KAMPODINE_PROFILE |
49
+ config defaultProfile | KAMPODINE_HOST.
50
+
51
+ Examples:
52
+ kampodine metrics # profile/host from config or env
53
+ kampodine metrics --host root@203.0.113.10 # explicit target
54
+ kampodine metrics --profile prod # per-instance profile
55
+ kampodine metrics --warn-disk 85 # exit 1 at >= 85% (default 90)
56
+ kampodine metrics --watch 10 --count 6 # snapshot every 10s, 6 times
57
+ kampodine status --verbose # the same snapshot inside status
58
+ EOF
59
+ exit "$code"
60
+ }
61
+
62
+ while [[ $# -gt 0 ]]; do
63
+ case "$1" in
64
+ --profile) PROFILE_FLAG="$2"; shift 2 ;;
65
+ --host) HOST_FLAG="$2"; KAMPODINE_HOST="$2"; shift 2 ;;
66
+ --ssh-key) KEY_FLAG="$2"; SSH_KEY="$2"; shift 2 ;;
67
+ --warn-disk) WARN_DISK="$2"; shift 2 ;;
68
+ --watch) WATCH="$2"; shift 2 ;;
69
+ --count) COUNT="$2"; shift 2 ;;
70
+ -h|--help) usage 0 ;;
71
+ *) die "unknown argument: $1 (--help)" ;;
72
+ esac
73
+ done
74
+
75
+ # profile resolution (flag > KAMPODINE_PROFILE > defaultProfile); an explicit
76
+ # --host/--ssh-key always beats the profile
77
+ profile_resolve "$PROFILE_FLAG" "$HOST_FLAG" "$KEY_FLAG"
78
+ SSH_KEY="${KEY_FLAG:-${KAMPODINE_SSH_KEY:-}}"
79
+
80
+ [[ "$WARN_DISK" =~ ^[0-9]+$ && "$WARN_DISK" -ge 1 && "$WARN_DISK" -le 100 ]] \
81
+ || die "--warn-disk must be a percentage 1-100 (got: $WARN_DISK)"
82
+ [[ -z "$WATCH" || "$WATCH" =~ ^[1-9][0-9]*$ ]] || die "--watch must be a positive-integer number of seconds (got: $WATCH)"
83
+ [[ -z "$COUNT" || "$COUNT" =~ ^[1-9][0-9]*$ ]] || die "--count must be a positive integer (got: $COUNT)"
84
+ [[ -n "$KAMPODINE_HOST" ]] || die "target required: --host root@<ip>, --profile <name>, KAMPODINE_PROFILE, config defaultProfile, or KAMPODINE_HOST=root@<ip>"
85
+
86
+ SSH_ARGS=(-o ConnectTimeout=10 -o BatchMode=yes)
87
+ [[ -n "$SSH_KEY" ]] && SSH_ARGS+=(-i "$SSH_KEY")
88
+ # $1 is a composed remote command — client-side expansion is the design.
89
+ # shellcheck disable=SC2029
90
+ vm() { ssh "${SSH_ARGS[@]}" "$KAMPODINE_HOST" "$1"; }
91
+
92
+ # --- macOS ssh-agent quirk (first run from a fresh machine) -------------------
93
+ if [[ "$(uname -s)" == "Darwin" ]]; then
94
+ SSH_AUTH_SOCK="$(launchctl getenv SSH_AUTH_SOCK 2>/dev/null || true)"
95
+ export SSH_AUTH_SOCK
96
+ fi
97
+
98
+ snapshot() {
99
+ local raw pct verdict
100
+ raw="$(vm "$METRICS_REMOTE_CMD")" || die "cannot reach VM $KAMPODINE_HOST (ssh failed) — nothing to report"
101
+ say "== metrics ($KAMPODINE_HOST) =="
102
+ metrics_render "$raw"
103
+ pct="$(disk_pct_from_df "$M_DISK")"
104
+ verdict="$(disk_verdict "$pct" "$WARN_DISK")"
105
+ case "$verdict" in
106
+ fail)
107
+ printf 'WARNING: root disk at %s%% (>= --warn-disk %s%%) — free space first: kampodine deploy prune --dry-run\n' \
108
+ "${pct:-?}" "$WARN_DISK"
109
+ return 1
110
+ ;;
111
+ warn)
112
+ printf 'WARNING: root disk above 90%% (%s%%) — old sha-tagged deploy images pile up; reclaim: kampodine deploy prune --dry-run\n' "$pct"
113
+ ;;
114
+ esac
115
+ return 0
116
+ }
117
+
118
+ n=0
119
+ while :; do
120
+ n=$(( n + 1 ))
121
+ if ! snapshot; then
122
+ exit 1
123
+ fi
124
+ [[ -n "$COUNT" && "$n" -ge "$COUNT" ]] && exit 0
125
+ [[ -z "$WATCH" ]] && exit 0
126
+ sleep "$WATCH"
127
+ done
@@ -9,13 +9,16 @@
9
9
  # are COLLECTED (files are independent; one bad tenant must not block the others)
10
10
  # and the script exits 1 if any failed — the deploy's smoke gate catches a red migration.
11
11
  # Runs ON the VM from the repo checkout (also fine locally against .tmp data):
12
- # kampodine migrate [--allow-running]
12
+ # kampodine migrate [--allow-running] [--profile <name>]
13
13
  set -euo pipefail
14
14
 
15
15
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
16
16
  REPO_ROOT="${REPO_ROOT:-$(cd "${SCRIPT_DIR}/../../.." && pwd)}"
17
17
  TENANT_DIR="${TENANT_DIR:-/data/tenants}"
18
18
  SERVICE="${SERVICE:-kampodine-api}"
19
+ HERE="$SCRIPT_DIR"
20
+ # shellcheck source=common.sh
21
+ source "$HERE/common.sh"
19
22
 
20
23
  log() { printf '[migrate-all] %s\n' "$*"; }
21
24
  fail() { printf '[migrate-all][FAIL] %s\n' "$*" >&2; exit 1; }
@@ -37,13 +40,25 @@ EOF
37
40
  }
38
41
 
39
42
  ALLOW_RUNNING=0
43
+ PROFILE_FLAG=""
44
+ PENDING_PROFILE=0
40
45
  for arg in "$@"; do
46
+ if [[ "$PENDING_PROFILE" -eq 1 ]]; then
47
+ PROFILE_FLAG="$arg"
48
+ PENDING_PROFILE=0
49
+ continue
50
+ fi
41
51
  case "$arg" in
42
52
  --allow-running) ALLOW_RUNNING=1 ;;
43
- -h|--help) usage ;;
53
+ --profile) PENDING_PROFILE=1 ;;
54
+ --help|-h) usage ;;
44
55
  *) fail "unknown argument: $arg (--help)" ;;
45
56
  esac
46
57
  done
58
+ [[ "$PENDING_PROFILE" -eq 0 ]] || fail "--profile requires a value"
59
+ # profile selection is validated for uniformity — migrate consumes no
60
+ # host/sshKey fields (it runs ON the VM from the repo checkout)
61
+ profile_resolve "$PROFILE_FLAG" "" ""
47
62
 
48
63
  [[ -f "${REPO_ROOT}/apps/api/scripts/libsql-migrate/migrate-db.ts" ]] \
49
64
  || fail "repo root not found at ${REPO_ROOT} (set REPO_ROOT=... if the checkout lives elsewhere)"
@@ -51,11 +66,30 @@ command -v pnpm >/dev/null 2>&1 || fail "pnpm not found in PATH"
51
66
  [[ -d "$TENANT_DIR" ]] || fail "tenant dir ${TENANT_DIR} does not exist"
52
67
 
53
68
  # Writing schema while the API serves traffic risks SQLITE_BUSY on a single-writer
54
- # engine — the service must be stopped first (rc-service kampodine-api stop). Override exists
69
+ # engine — the service must be stopped first (rc-service ${SERVICE} stop). Override exists
55
70
  # for deliberate local use.
56
- if systemctl is-active --quiet "$SERVICE" 2>/dev/null; then
71
+ #
72
+ # Init-aware detection (fixes the DEAD guard, 2026-10-08): this script runs ON
73
+ # the VM, whose init is OpenRC (Alpine ships no systemd) — a bare `systemctl
74
+ # is-active` always failed there, so the stop-first guard NEVER fired and
75
+ # migrations could run under the serving API. Mirror common.sh's detection
76
+ # order: rc-service (openrc) first, then systemctl (systemd hosts). On a host
77
+ # with neither (local dev machines), state is unknowable — proceed with a
78
+ # loud warning rather than blocking local runs.
79
+ service_running() {
80
+ if command -v rc-service >/dev/null 2>&1; then
81
+ rc-service "$SERVICE" status >/dev/null 2>&1
82
+ elif command -v systemctl >/dev/null 2>&1; then
83
+ systemctl is-active --quiet "$SERVICE"
84
+ else
85
+ log "service state unknown (neither rc-service nor systemctl found) — assuming ${SERVICE} is not running"
86
+ return 1
87
+ fi
88
+ }
89
+
90
+ if service_running; then
57
91
  if [[ $ALLOW_RUNNING -ne 1 ]]; then
58
- fail "${SERVICE} is running — stop it first (rc-service kampodine-api stop) or pass --allow-running"
92
+ fail "${SERVICE} is running — stop it first (rc-service ${SERVICE} stop) or pass --allow-running"
59
93
  fi
60
94
  log "WARNING: migrating while ${SERVICE} is running (--allow-running)"
61
95
  fi