Collect RSS groups, top processes, and loadavg from inside each podman container (not host /proc), merge into performance.memory, show compact labels with cgroup limits and byte tooltips, and cover this in the test suite.
106 lines
3.6 KiB
Python
Executable file
106 lines
3.6 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Merge container resource snapshot into landing stats.json performance block.
|
|
|
|
Usage:
|
|
merge_resources.py STATS.json RESOURCES.json [-o OUT.json]
|
|
|
|
RESOURCES shape (from mem_snapshot_emit / collect_container_resources.sh):
|
|
{ "ok": true, "loadavg": "0.1,0.2,0.3", "memory": { ... } }
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any, Dict
|
|
|
|
|
|
def compact_memory(mem: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Ensure human labels + short top cmd for UI width."""
|
|
if not isinstance(mem, dict):
|
|
return mem
|
|
# Prefer explicit label (with limit); else human
|
|
if not mem.get("container_rss_label"):
|
|
h = mem.get("container_rss_human") or "—"
|
|
lim = mem.get("cgroup_limit_human")
|
|
if lim:
|
|
mem["container_rss_label"] = f"{h} / {lim}"
|
|
else:
|
|
mem["container_rss_label"] = h
|
|
tops = mem.get("top")
|
|
if isinstance(tops, list):
|
|
for t in tops:
|
|
if not isinstance(t, dict):
|
|
continue
|
|
cmd = str(t.get("cmd") or "")
|
|
if len(cmd) > 72:
|
|
t["cmd_full"] = cmd
|
|
t["cmd"] = cmd[:69] + "…"
|
|
# ensure human present
|
|
if not t.get("rss_human") and t.get("rss_bytes") is not None:
|
|
try:
|
|
b = int(t["rss_bytes"])
|
|
if b < 1024:
|
|
t["rss_human"] = f"{b} B"
|
|
elif b < 1048576:
|
|
t["rss_human"] = f"{b/1024:.1f} KiB"
|
|
elif b < 1073741824:
|
|
t["rss_human"] = f"{b/1048576:.1f} MiB"
|
|
else:
|
|
t["rss_human"] = f"{b/1073741824:.2f} GiB"
|
|
except Exception:
|
|
pass
|
|
return mem
|
|
|
|
|
|
def merge(stats: Dict[str, Any], resources: Dict[str, Any]) -> Dict[str, Any]:
|
|
perf = stats.setdefault("performance", {})
|
|
if not isinstance(perf, dict):
|
|
perf = {}
|
|
stats["performance"] = perf
|
|
|
|
if resources.get("loadavg"):
|
|
perf["loadavg"] = resources["loadavg"]
|
|
perf["loadavg_source"] = "container"
|
|
|
|
mem = resources.get("memory")
|
|
if isinstance(mem, dict) and mem:
|
|
perf["memory"] = compact_memory(dict(mem))
|
|
perf["memory"]["source"] = resources.get("source") or "mem-snapshot"
|
|
return stats
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("stats")
|
|
ap.add_argument("resources")
|
|
ap.add_argument("-o", "--out", default="")
|
|
args = ap.parse_args()
|
|
|
|
stats = json.loads(Path(args.stats).read_text(encoding="utf-8"))
|
|
resources = json.loads(Path(args.resources).read_text(encoding="utf-8"))
|
|
if not isinstance(stats, dict) or not stats.get("ok"):
|
|
print("skip: stats not ok", file=sys.stderr)
|
|
return 1
|
|
if not isinstance(resources, dict) or resources.get("ok") is False:
|
|
print("skip: resources not ok", file=sys.stderr)
|
|
return 1
|
|
|
|
merge(stats, resources)
|
|
out = Path(args.out) if args.out else Path(args.stats)
|
|
tmp = out.with_suffix(out.suffix + f".tmp.{os.getpid()}")
|
|
tmp.write_text(json.dumps(stats, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
tmp.replace(out)
|
|
mem = (stats.get("performance") or {}).get("memory") or {}
|
|
print(
|
|
f"merged memory container={mem.get('container_rss_human')} "
|
|
f"pg={mem.get('postgres_rss_human')} loadavg={stats.get('performance', {}).get('loadavg')}",
|
|
file=sys.stderr,
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|