#!/usr/bin/env bash # Snapshot RSS process groups inside a podman container + host-side CPU/PIDs. # Usage: collect_container_resources.sh CONTAINER [OUT.json] # Prints JSON to stdout (and writes OUT when given). # # Note: rootless podman often has no memory cgroup controller delegated # (memory.current missing → podman stats shows 0B). We therefore use # /proc VmRSS sums inside the container for memory, and podman stats for # CPU% + PID count. /proc/loadavg inside the container is the *host* load. set -euo pipefail CTR="${1:-}" OUT="${2:-}" if [ -z "$CTR" ]; then echo "usage: $0 CONTAINER [OUT.json]" >&2 exit 2 fi ADMIN_LOG="${ADMIN_LOG:-${HOME}/koopa-admin-log}" if [ ! -d "$ADMIN_LOG" ] && [ -d "${HOME}/src/koopa/koopa-admin-log" ]; then ADMIN_LOG="${HOME}/src/koopa/koopa-admin-log" fi MEM_SRC="${MEM_SNAPSHOT_SRC:-$ADMIN_LOG/scripts/taler-shared/mem-snapshot.sh}" MEM_DST="${MEM_SNAPSHOT_DST:-/usr/local/lib/landing-mem-snapshot.sh}" if ! podman inspect -f '{{.State.Running}}' "$CTR" 2>/dev/null | grep -qx true; then echo "{\"ok\":false,\"error\":\"container not running: $CTR\"}" exit 1 fi if [ -f "$MEM_SRC" ]; then podman exec "$CTR" mkdir -p "$(dirname "$MEM_DST")" 2>/dev/null || true # Always refresh helper (fixes stale copies missing json_str / emit) podman cp "$MEM_SRC" "${CTR}:${MEM_DST}" 2>/dev/null || true fi set +e raw=$(podman exec "$CTR" bash -c ' set -e export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" if [ ! -f /usr/local/lib/landing-mem-snapshot.sh ]; then echo "{\"ok\":false,\"error\":\"mem-snapshot helper missing\"}" exit 1 fi # shellcheck disable=SC1091 . /usr/local/lib/landing-mem-snapshot.sh if ! declare -F mem_snapshot_emit >/dev/null 2>&1; then mem_snapshot_json loadavg="" [ -r /proc/loadavg ] && loadavg=$(awk "{print \$1\",\"\$2\",\"\$3}" /proc/loadavg) printf "{\"ok\":true,\"source\":\"mem-snapshot-legacy\",\"loadavg\":%s,\"memory\":{%s}}\n" \ "\"$loadavg\"" "$MEM_JSON" else mem_snapshot_emit fi ' 2>/tmp/landing-mem-err.$$) ec=$? set -e if [ "$ec" -ne 0 ] || [ -z "$raw" ]; then err=$(tr '\n' ' ' /dev/null | head -c 200 || true) rm -f /tmp/landing-mem-err.$$ printf '{"ok":false,"error":"podman exec failed: %s"}\n' "${err//\"/\'}" exit 1 fi rm -f /tmp/landing-mem-err.$$ # Host-side: CPU% + PIDs via podman stats (works without memory controller) stats_json=$(podman stats --no-stream --format json "$CTR" 2>/dev/null || true) # Merge + normalize with Python merged=$( RAW_JSON="$raw" STATS_JSON="$stats_json" CTR_NAME="$CTR" python3 - <<'PY' import json, os, re raw = os.environ.get("RAW_JSON") or "" stats_raw = os.environ.get("STATS_JSON") or "" ctr = os.environ.get("CTR_NAME") or "" try: data = json.loads(raw) except Exception as e: print(json.dumps({"ok": False, "error": f"invalid mem json: {e}"})) raise SystemExit(0) if not isinstance(data, dict) or not data.get("ok"): print(json.dumps(data if isinstance(data, dict) else {"ok": False})) raise SystemExit(0) mem = data.get("memory") if isinstance(data.get("memory"), dict) else {} data["memory"] = mem # Zero-group cleanup: UI should not show "0 B" for key in ("taler", "java", "nginx", "postgres", "redis", "other"): n = mem.get(f"{key}_n") b = mem.get(f"{key}_rss_bytes") try: n = int(n) if n is not None else 0 except Exception: n = 0 try: b = int(b) if b is not None else 0 except Exception: b = 0 if n <= 0 or b <= 0: mem[f"{key}_n"] = 0 mem[f"{key}_rss_bytes"] = 0 mem[f"{key}_rss_human"] = None # UI → "—" mem[f"{key}_rss_label"] = "—" # Prefer process-sum label when cgroup missing if not mem.get("cgroup_bytes") and mem.get("proc_sum_rss_human"): hum = mem.get("container_rss_human") or mem.get("proc_sum_rss_human") mem["container_rss_label"] = f"{hum} (proc)" mem["memory_basis"] = "proc_rss_sum" elif mem.get("cgroup_limit_human") and mem.get("container_rss_human"): mem["container_rss_label"] = f"{mem['container_rss_human']} / {mem['cgroup_limit_human']}" mem["memory_basis"] = "cgroup" else: mem["memory_basis"] = mem.get("memory_basis") or "proc_rss_sum" if mem.get("container_rss_human") and not mem.get("container_rss_label"): mem["container_rss_label"] = mem["container_rss_human"] # loadavg inside rootless containers is usually the host's data["loadavg_note"] = "host (shared; rootless container /proc/loadavg)" data["loadavg_source"] = "host_via_container_proc" # podman stats enrichment cpu = None pids = None if stats_raw.strip(): try: st = json.loads(stats_raw) if isinstance(st, list) and st: st = st[0] if isinstance(st, dict): cp = st.get("CPUPerc") or st.get("cpu_percent") or st.get("CPU") if cp is not None: s = str(cp).strip().rstrip("%") try: cpu = float(s) except Exception: cpu = None p = st.get("PIDs") or st.get("pids") if p is not None: try: pids = int(str(p).strip()) except Exception: pids = None # mem from stats only if non-zero (usually 0B without memory controller) mu = st.get("MemUsage") or st.get("mem_usage") or "" m = re.match(r"^\s*([0-9.]+)\s*([KMGT]?i?B)\s*/\s*([0-9.]+)\s*([KMGT]?i?B)", str(mu), re.I) if m and float(m.group(1)) > 0: mem["podman_stats_mem"] = str(mu).strip() except Exception: pass if cpu is not None: data["cpu_percent"] = cpu data["cpu_percent_label"] = f"{cpu:.2f}%" if pids is not None: data["pids"] = pids mem["pids"] = pids data["container"] = ctr data["source"] = "mem-snapshot+podman-stats" print(json.dumps(data, ensure_ascii=False)) PY ) if ! printf '%s' "$merged" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d.get("ok")' 2>/dev/null; then # fall back to raw if merge failed merged="$raw" fi if [ -n "$OUT" ]; then printf '%s\n' "$merged" >"$OUT" fi printf '%s\n' "$merged"