servchart-agent 0.3.1__tar.gz → 0.4.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: servchart-agent
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: ServChart agent: reports a host's Docker containers, networks and Compose stacks to a ServChart hub.
5
5
  Project-URL: Homepage, https://github.com/opticodev/servchart
6
6
  Project-URL: Issues, https://github.com/opticodev/servchart/issues
@@ -31,6 +31,7 @@ It reads this host's Docker state (GET requests only), drops anything sensitive,
31
31
  ```bash
32
32
  docker run -d --name servchart-agent --restart unless-stopped --pid host \
33
33
  -v /var/run/docker.sock:/var/run/docker.sock:ro \
34
+ -v /:/host:ro,rslave -e SERVCHART_HOST_ROOT=/host \
34
35
  -e SERVCHART_OBSERVE_CONNECTIONS=1 \
35
36
  -e SERVCHART_HUB=https://servchart.example.com \
36
37
  -e SERVCHART_TOKEN=sc_... \
@@ -51,4 +52,6 @@ Get the token from the hub: **+ Add host** in the map.
51
52
 
52
53
  It sends container names, images, state and health, networks and IPs, published ports, mounts, restart policy, and Compose stack details (project, service, folder, compose files). It **never** sends environment variables or container commands, and secret-looking label values are redacted. Set `SERVCHART_HIDE_PATHS=1` to also drop host paths and stack folders.
53
54
 
55
+ Since 0.4 it also reports server health (CPU, memory, load, uptime, disk usage of each mount point), per-container CPU and memory, Docker's disk use, and backup status files from `/var/lib/servchart/status`. After a backup job, record it with `servchart-agent --mark NAME` (add `--failed` when it failed).
56
+
54
57
  All options are in the [main README](https://github.com/opticodev/servchart#agent).
@@ -11,6 +11,7 @@ It reads this host's Docker state (GET requests only), drops anything sensitive,
11
11
  ```bash
12
12
  docker run -d --name servchart-agent --restart unless-stopped --pid host \
13
13
  -v /var/run/docker.sock:/var/run/docker.sock:ro \
14
+ -v /:/host:ro,rslave -e SERVCHART_HOST_ROOT=/host \
14
15
  -e SERVCHART_OBSERVE_CONNECTIONS=1 \
15
16
  -e SERVCHART_HUB=https://servchart.example.com \
16
17
  -e SERVCHART_TOKEN=sc_... \
@@ -31,4 +32,6 @@ Get the token from the hub: **+ Add host** in the map.
31
32
 
32
33
  It sends container names, images, state and health, networks and IPs, published ports, mounts, restart policy, and Compose stack details (project, service, folder, compose files). It **never** sends environment variables or container commands, and secret-looking label values are redacted. Set `SERVCHART_HIDE_PATHS=1` to also drop host paths and stack folders.
33
34
 
35
+ Since 0.4 it also reports server health (CPU, memory, load, uptime, disk usage of each mount point), per-container CPU and memory, Docker's disk use, and backup status files from `/var/lib/servchart/status`. After a backup job, record it with `servchart-agent --mark NAME` (add `--failed` when it failed).
36
+
34
37
  All options are in the [main README](https://github.com/opticodev/servchart#agent).
@@ -1,4 +1,4 @@
1
1
  """ServChart agent: reads Docker state on one host and reports it to a ServChart hub."""
2
2
 
3
- __version__ = "0.3.1"
3
+ __version__ = "0.4.0"
4
4
  SCHEMA_VERSION = 1
@@ -2,6 +2,7 @@
2
2
 
3
3
  python -m servchart_agent --hub https://servchart.example.com --token sc_xxx
4
4
  python -m servchart_agent --stdout # print one snapshot and exit
5
+ python -m servchart_agent --mark kopia # record a successful backup run (see backups.py)
5
6
 
6
7
  Every option can also be set with an environment variable (shown in --help).
7
8
  """
@@ -18,7 +19,7 @@ import time
18
19
  import urllib.error
19
20
  import urllib.request
20
21
 
21
- from . import __version__, commands
22
+ from . import __version__, backups, commands
22
23
  from .collect import collect
23
24
  from .docker_api import DockerClient, DockerError
24
25
 
@@ -55,7 +56,27 @@ def hub_request(hub: str, token: str, ca_file: str | None = None, insecure: bool
55
56
 
56
57
 
57
58
  def push(snapshot: dict, hub: str, token: str, ca_file: str | None = None, insecure: bool = False) -> None:
58
- hub_request(hub, token, ca_file, insecure)("POST", "/api/v1/report", snapshot)
59
+ answer = hub_request(hub, token, ca_file, insecure)("POST", "/api/v1/report", snapshot)
60
+ apply_hub_hints(answer)
61
+
62
+
63
+ def apply_hub_hints(answer) -> None:
64
+ """The hub may list your public addresses so connections to them get reported (max 50, IPs only)."""
65
+ import ipaddress
66
+
67
+ from . import connections
68
+
69
+ ips = (answer or {}).get("observe_ips") if isinstance(answer, dict) else None
70
+ if not isinstance(ips, list):
71
+ return
72
+ clean = set()
73
+ for ip in ips[:50]:
74
+ try:
75
+ clean.add(str(ipaddress.ip_address(str(ip))))
76
+ except ValueError:
77
+ continue
78
+ connections.EXTRA_IPS.clear()
79
+ connections.EXTRA_IPS.update(clean)
59
80
 
60
81
 
61
82
  def parse_args(argv=None):
@@ -72,6 +93,10 @@ def parse_args(argv=None):
72
93
  help="Skip TLS verification (env SERVCHART_INSECURE=1). Not recommended.")
73
94
  p.add_argument("--once", action="store_true", help="Report once and exit")
74
95
  p.add_argument("--stdout", action="store_true", help="Print the snapshot as JSON instead of sending it")
96
+ p.add_argument("--mark", metavar="NAME", help="Record that the backup job NAME just finished, then exit")
97
+ p.add_argument("--failed", action="store_true", help="With --mark: the backup failed")
98
+ p.add_argument("--message", default="", help="With --mark: a short note shown in ServChart")
99
+ p.add_argument("--max-age-hours", type=float, help="With --mark: warn when no new run within this many hours (default 26)")
75
100
  p.add_argument("--version", action="version", version=f"servchart-agent {__version__}")
76
101
  return p.parse_args(argv)
77
102
 
@@ -80,6 +105,14 @@ def main(argv=None) -> int:
80
105
  logging.basicConfig(level=os.environ.get("SERVCHART_LOG_LEVEL", "INFO"),
81
106
  format="%(asctime)s %(levelname)s %(message)s")
82
107
  args = parse_args(argv)
108
+ if args.mark:
109
+ try:
110
+ path = backups.mark(args.mark, ok=not args.failed, message=args.message, max_age_hours=args.max_age_hours)
111
+ except (ValueError, OSError) as exc:
112
+ log.error("could not record backup status: %s", exc)
113
+ return 1
114
+ log.info("recorded %s backup %s in %s", args.mark, "failure" if args.failed else "success", path)
115
+ return 0
83
116
  client = DockerClient()
84
117
 
85
118
  if args.stdout:
@@ -0,0 +1,111 @@
1
+ """Backup status, from small status files your backup jobs write (any backup tool).
2
+
3
+ Put one file per job in /var/lib/servchart/status on the host (or SERVCHART_STATUS_DIR). In a
4
+ container the agent finds it through the host root mount (SERVCHART_HOST_ROOT=/host):
5
+
6
+ * a JSON file such as `kopia.json`:
7
+ {"ok": true, "finished_at": "2026-10-20T03:12:00Z", "max_age_hours": 26,
8
+ "message": "1.2 GB uploaded", "size_bytes": 1234567}
9
+ every field is optional; `finished_at` defaults to the file's modification time;
10
+ * or any other file: its modification time counts as the last successful run.
11
+
12
+ `servchart-agent --mark NAME` (or `--mark NAME --failed --message "..."`) writes one for you,
13
+ so a cron job can end with `&& servchart-agent --mark kopia || servchart-agent --mark kopia --failed`.
14
+ Only these fields are sent; the files are never executed.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import re
22
+ from datetime import datetime, timezone
23
+
24
+ DEFAULT_DIR = "/var/lib/servchart/status"
25
+ NAME_RE = re.compile(r"^[A-Za-z0-9._-]{1,64}$")
26
+ MAX_FILES = 50
27
+ MAX_BYTES = 16 * 1024
28
+
29
+
30
+ def status_dir() -> str:
31
+ """SERVCHART_STATUS_DIR, else the host's /var/lib/servchart/status (through SERVCHART_HOST_ROOT in a container)."""
32
+ if os.environ.get("SERVCHART_STATUS_DIR"):
33
+ return os.environ["SERVCHART_STATUS_DIR"]
34
+ from .system import host_root
35
+
36
+ root = host_root()
37
+ return os.path.join(root, DEFAULT_DIR.lstrip("/")) if root and root != "/" else DEFAULT_DIR
38
+
39
+
40
+ def _iso(ts: float) -> str:
41
+ return datetime.fromtimestamp(ts, timezone.utc).isoformat(timespec="seconds")
42
+
43
+
44
+ def read_one(path: str) -> dict | None:
45
+ name = os.path.basename(path)
46
+ stem = name[:-5] if name.endswith(".json") else name
47
+ if not NAME_RE.match(stem):
48
+ return None
49
+ try:
50
+ st = os.stat(path)
51
+ except OSError:
52
+ return None
53
+ entry = {"name": stem, "ok": True, "finished_at": _iso(st.st_mtime), "max_age_hours": None, "message": "", "size_bytes": None}
54
+ if name.endswith(".json") and st.st_size <= MAX_BYTES:
55
+ try:
56
+ with open(path) as fh:
57
+ doc = json.load(fh)
58
+ except (OSError, ValueError):
59
+ doc = None
60
+ if isinstance(doc, dict):
61
+ if "ok" in doc:
62
+ entry["ok"] = bool(doc["ok"])
63
+ if isinstance(doc.get("finished_at"), str):
64
+ entry["finished_at"] = doc["finished_at"][:40]
65
+ if isinstance(doc.get("max_age_hours"), (int, float)) and 0 < doc["max_age_hours"] <= 24 * 366:
66
+ entry["max_age_hours"] = doc["max_age_hours"]
67
+ if isinstance(doc.get("message"), str):
68
+ entry["message"] = doc["message"][:300]
69
+ if isinstance(doc.get("size_bytes"), int) and doc["size_bytes"] >= 0:
70
+ entry["size_bytes"] = doc["size_bytes"]
71
+ return entry
72
+
73
+
74
+ def collect() -> list[dict] | None:
75
+ """None when the status directory doesn't exist (backups not set up for ServChart here)."""
76
+ d = status_dir()
77
+ try:
78
+ names = sorted(os.listdir(d))
79
+ except OSError:
80
+ return None
81
+ out = []
82
+ for n in names:
83
+ if n.startswith(".") or n.endswith(".tmp"):
84
+ continue
85
+ path = os.path.join(d, n)
86
+ if os.path.isfile(path):
87
+ e = read_one(path)
88
+ if e:
89
+ out.append(e)
90
+ if len(out) >= MAX_FILES:
91
+ break
92
+ return out
93
+
94
+
95
+ def mark(name: str, ok: bool = True, message: str = "", max_age_hours: float | None = None, size_bytes: int | None = None) -> str:
96
+ """Write <status dir>/<name>.json atomically. Returns the path."""
97
+ if not NAME_RE.match(name):
98
+ raise ValueError("Use letters, digits, dot, dash or underscore (max 64) for the backup name.")
99
+ d = status_dir()
100
+ os.makedirs(d, exist_ok=True)
101
+ doc = {"ok": ok, "finished_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), "message": message[:300]}
102
+ if max_age_hours:
103
+ doc["max_age_hours"] = max_age_hours
104
+ if size_bytes is not None:
105
+ doc["size_bytes"] = size_bytes
106
+ path = os.path.join(d, f"{name}.json")
107
+ tmp = path + ".tmp"
108
+ with open(tmp, "w") as fh:
109
+ json.dump(doc, fh)
110
+ os.replace(tmp, path)
111
+ return path
@@ -15,6 +15,7 @@ import re
15
15
  from datetime import datetime, timezone
16
16
 
17
17
  from . import SCHEMA_VERSION, __version__
18
+ from . import backups, stats, storage, system
18
19
  from . import connections as conn
19
20
  from .docker_api import DockerClient
20
21
 
@@ -112,6 +113,26 @@ def parse_depends_on(value: str | None) -> list[str]:
112
113
  return services
113
114
 
114
115
 
116
+ _digests: dict[str, list[str]] = {} # image id -> RepoDigests
117
+
118
+
119
+ def image_digests(client, image_id: str | None) -> list[str]:
120
+ """Registry digests of an image ("repo@sha256:..."), so the hub can tell if its tag moved."""
121
+ if not image_id:
122
+ return []
123
+ if image_id not in _digests:
124
+ try:
125
+ doc = client.get(f"/images/{image_id}/json")
126
+ _digests[image_id] = [d for d in (doc.get("RepoDigests") or []) if isinstance(d, str)][:10]
127
+ except Exception:
128
+ return []
129
+ return _digests[image_id]
130
+
131
+
132
+ def _flag(name: str, default: str = "1") -> bool:
133
+ return os.environ.get(name, default).lower() in ("1", "true", "yes", "on")
134
+
135
+
115
136
  def container_record(inspect: dict, hide_paths: bool = False) -> dict:
116
137
  """Convert a `docker inspect` document into a sanitized ServChart record.
117
138
 
@@ -171,6 +192,13 @@ def collect(client: DockerClient | None = None, hostname: str | None = None) ->
171
192
  except Exception: # container vanished between list and inspect
172
193
  continue
173
194
  containers = [container_record(i, hide_paths) for i in inspected]
195
+ for rec, doc in zip(containers, inspected):
196
+ rec["image_digests"] = image_digests(client, doc.get("Image"))
197
+ if _flag("SERVCHART_STATS"):
198
+ by_id = stats.collect(client, [d.get("Id") for d in inspected if (d.get("State") or {}).get("Running")])
199
+ for rec, doc in zip(containers, inspected):
200
+ if doc.get("Id") in by_id:
201
+ rec["stats"] = by_id[doc["Id"]]
174
202
  networks = [network_record(n) for n in client.networks()]
175
203
 
176
204
  host = {
@@ -185,6 +213,8 @@ def collect(client: DockerClient | None = None, hostname: str | None = None) ->
185
213
  # on-demand log viewing, opt-in per host (SERVCHART_ALLOW_LOGS=1)
186
214
  "logs": "on" if os.environ.get("SERVCHART_ALLOW_LOGS", "0").lower() in ("1", "true", "yes", "on") else "off",
187
215
  }
216
+ if _flag("SERVCHART_HEALTH"):
217
+ host["health"] = system.health(info.get("DockerRootDir"))
188
218
  snapshot = {
189
219
  "schema": SCHEMA_VERSION,
190
220
  "agent_version": __version__,
@@ -194,6 +224,13 @@ def collect(client: DockerClient | None = None, hostname: str | None = None) ->
194
224
  "networks": networks,
195
225
  }
196
226
 
227
+ docker_disk = storage.collect(client)
228
+ if docker_disk is not None:
229
+ snapshot["storage"] = docker_disk
230
+ backup_status = backups.collect()
231
+ if backup_status is not None:
232
+ snapshot["backups"] = backup_status
233
+
197
234
  # The host's own addresses let the hub resolve other hosts' connections to this one.
198
235
  # Readable only when we see the host's /proc (native, or --pid host); else SERVCHART_HOST_IPS.
199
236
  subnets = [s for n in networks for s in n.get("subnets") or []]
@@ -77,6 +77,11 @@ def is_reportable(ip: str, include_public: bool = False) -> bool:
77
77
  return addr.is_private or (addr.version == 4 and addr in CGNAT)
78
78
 
79
79
 
80
+ # Public addresses the hub asked about (your own public IPs, behind your router): reported even
81
+ # though they're public, so connections through your port forwards can be resolved.
82
+ EXTRA_IPS: set[str] = set()
83
+
84
+
80
85
  def outbound(rows, exclude_ips=(), include_public: bool = False) -> list[dict]:
81
86
  """Aggregate outbound established connections: [{"ip", "port", "count"}].
82
87
 
@@ -88,7 +93,7 @@ def outbound(rows, exclude_ips=(), include_public: bool = False) -> list[dict]:
88
93
  for _lip, lport, rip, rport, st in rows:
89
94
  if st != TCP_ESTABLISHED or lport in listening:
90
95
  continue
91
- if rip in exclude or not is_reportable(rip, include_public):
96
+ if rip in exclude or not (is_reportable(rip, include_public) or rip in EXTRA_IPS):
92
97
  continue
93
98
  counts[(rip, rport)] += 1
94
99
  out = [{"ip": ip, "port": port, "count": n} for (ip, port), n in counts.items()]
@@ -0,0 +1,72 @@
1
+ """Per-container CPU and memory, from Docker's stats endpoint (read-only, one-shot).
2
+
3
+ `one-shot=true` answers immediately with a single sample, so CPU use is computed against
4
+ the sample taken at the previous report. Memory excludes the page cache, the way
5
+ `docker stats` shows it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from urllib.parse import quote
11
+
12
+ _prev: dict[str, tuple[int, int]] = {} # container id -> (container cpu ns, system cpu ns)
13
+
14
+
15
+ def _mem_used(mem: dict) -> int | None:
16
+ usage = mem.get("usage")
17
+ if usage is None:
18
+ return None
19
+ st = mem.get("stats") or {}
20
+ cache = st.get("inactive_file", st.get("total_inactive_file", st.get("cache", 0))) or 0
21
+ return max(0, usage - cache)
22
+
23
+
24
+ def _sum(items, key):
25
+ return sum((i or {}).get(key) or 0 for i in items or [])
26
+
27
+
28
+ def parse(cid: str, s: dict, prev: dict | None = None) -> dict:
29
+ """One container's stats document -> {cpu_pct, mem_used, mem_limit, pids, net_rx, net_tx, ...}."""
30
+ prev = _prev if prev is None else prev
31
+ cpu = s.get("cpu_stats") or {}
32
+ total = (cpu.get("cpu_usage") or {}).get("total_usage")
33
+ system = cpu.get("system_cpu_usage")
34
+ cpus = cpu.get("online_cpus") or len((cpu.get("cpu_usage") or {}).get("percpu_usage") or []) or 1
35
+ cpu_pct = None
36
+ if total is not None and system is not None:
37
+ before = prev.get(cid)
38
+ pre = s.get("precpu_stats") or {}
39
+ if before is None and pre.get("system_cpu_usage"): # a streamed sample carries its own baseline
40
+ before = ((pre.get("cpu_usage") or {}).get("total_usage") or 0, pre["system_cpu_usage"])
41
+ if before and system > before[1] and total >= before[0]:
42
+ cpu_pct = round(100.0 * (total - before[0]) / (system - before[1]) * cpus, 1)
43
+ prev[cid] = (total, system)
44
+ mem = s.get("memory_stats") or {}
45
+ limit = mem.get("limit")
46
+ nets = (s.get("networks") or {}).values()
47
+ blk = (s.get("blkio_stats") or {}).get("io_service_bytes_recursive") or []
48
+ return {
49
+ "cpu_pct": cpu_pct,
50
+ "mem_used": _mem_used(mem),
51
+ "mem_limit": limit if limit and limit < 2**60 else None, # "no limit" is a huge number
52
+ "pids": (s.get("pids_stats") or {}).get("current"),
53
+ "net_rx": _sum(nets, "rx_bytes") if nets else None,
54
+ "net_tx": _sum(nets, "tx_bytes") if nets else None,
55
+ "blk_read": sum(b.get("value") or 0 for b in blk if (b.get("op") or "").lower() == "read") if blk else None,
56
+ "blk_write": sum(b.get("value") or 0 for b in blk if (b.get("op") or "").lower() == "write") if blk else None,
57
+ }
58
+
59
+
60
+ def collect(client, running_ids: list[str]) -> dict[str, dict]:
61
+ """container id -> stats, for running containers. Failures are skipped silently."""
62
+ out = {}
63
+ for cid in running_ids:
64
+ try:
65
+ doc = client.get(f"/containers/{quote(cid)}/stats?stream=false&one-shot=true")
66
+ except Exception:
67
+ continue
68
+ if isinstance(doc, dict):
69
+ out[cid] = parse(cid, doc)
70
+ for gone in set(_prev) - set(running_ids):
71
+ _prev.pop(gone, None)
72
+ return out
@@ -0,0 +1,65 @@
1
+ """Docker disk usage (images, container layers, volumes, build cache) from `GET /system/df`.
2
+
3
+ Docker has to measure every volume to answer, which can take a while on big hosts, so the
4
+ result is cached and refreshed every SERVCHART_DF_INTERVAL seconds (default 1 hour;
5
+ 0 turns it off).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import time
12
+
13
+ MAX_VOLUMES = 500
14
+ _cache: dict = {"at": 0.0, "data": None}
15
+
16
+
17
+ def interval() -> int:
18
+ try:
19
+ return int(os.environ.get("SERVCHART_DF_INTERVAL", "3600"))
20
+ except ValueError:
21
+ return 3600
22
+
23
+
24
+ def summarize(df: dict) -> dict:
25
+ images = df.get("Images") or []
26
+ containers = df.get("Containers") or []
27
+ volumes = df.get("Volumes") or []
28
+ cache = df.get("BuildCache") or []
29
+ vols = []
30
+ for v in volumes:
31
+ usage = v.get("UsageData") or {}
32
+ size = usage.get("Size")
33
+ vols.append({"name": v.get("Name"), "size": size if isinstance(size, int) and size >= 0 else None, "refs": usage.get("RefCount")})
34
+ vols.sort(key=lambda v: -(v["size"] or 0))
35
+ return {
36
+ "images_bytes": df.get("LayersSize"),
37
+ "images_count": len(images),
38
+ "images_unused": sum(1 for i in images if not i.get("Containers")),
39
+ "containers_rw_bytes": sum(c.get("SizeRw") or 0 for c in containers),
40
+ "volumes_bytes": sum(v["size"] or 0 for v in vols),
41
+ "volumes_count": len(vols),
42
+ "volumes_unused": sum(1 for v in vols if v["refs"] == 0),
43
+ "build_cache_bytes": sum(b.get("Size") or 0 for b in cache if not b.get("Shared")),
44
+ "volumes": vols[:MAX_VOLUMES],
45
+ "measured_at": int(time.time()),
46
+ }
47
+
48
+
49
+ def collect(client) -> dict | None:
50
+ every = interval()
51
+ if every <= 0:
52
+ return None
53
+ now = time.time()
54
+ if now - _cache["at"] >= every:
55
+ try:
56
+ slow = client
57
+ if hasattr(client, "docker_host"): # measuring volumes can take minutes on big hosts
58
+ from .docker_api import DockerClient
59
+
60
+ slow = DockerClient(client.docker_host, timeout=180)
61
+ _cache["data"] = summarize(slow.get("/system/df"))
62
+ _cache["at"] = now
63
+ except Exception:
64
+ _cache["at"] = now - every + 300 # retry in 5 minutes
65
+ return _cache["data"]
@@ -0,0 +1,180 @@
1
+ """Server health: CPU, memory, load, uptime and disk usage, read from the kernel.
2
+
3
+ /proc/stat, /proc/meminfo, /proc/loadavg and /proc/uptime describe the whole machine even
4
+ inside a container, so they need nothing special. Disks are different: a container only
5
+ sees its own mounts. The agent reports
6
+
7
+ * every real filesystem of the host when it can see the host's root: natively, or in a
8
+ container with the host's / mounted read-only (`-v /:/host:ro` + SERVCHART_HOST_ROOT=/host);
9
+ * otherwise just the filesystem holding Docker's data (the agent container's own root
10
+ lives there), which is usually the one that fills up.
11
+
12
+ Nothing here reads file contents; only sizes and mount points are reported.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import time
19
+
20
+ from .connections import PROC, _in_container
21
+
22
+ # Filesystems worth reporting; pseudo, overlay and squashfs (snaps) are skipped.
23
+ REAL_FS = {
24
+ "ext2", "ext3", "ext4", "xfs", "btrfs", "zfs", "f2fs", "jfs", "reiserfs", "vfat", "exfat",
25
+ "ntfs", "ntfs3", "fuseblk", "nfs", "nfs4", "cifs", "smb3", "ceph", "glusterfs", "fuse.mergerfs",
26
+ "fuse.sshfs", "apfs", "hfs", "hfsplus",
27
+ }
28
+ SKIP_PREFIXES = ("/proc", "/sys", "/dev", "/run", "/snap", "/var/lib/docker", "/var/lib/containerd", "/var/snap")
29
+ MAX_DISKS = 30
30
+
31
+ _prev_cpu: tuple[int, int] | None = None # (busy, total) jiffies from the last report
32
+
33
+
34
+ def _read(path: str) -> str | None:
35
+ try:
36
+ with open(path) as fh:
37
+ return fh.read()
38
+ except OSError:
39
+ return None
40
+
41
+
42
+ def cpu_times(text: str | None = None) -> tuple[int, int] | None:
43
+ """(busy, total) jiffies from the first line of /proc/stat."""
44
+ text = text if text is not None else _read(f"{PROC}/stat")
45
+ if not text:
46
+ return None
47
+ parts = text.splitlines()[0].split()
48
+ if parts[0] != "cpu":
49
+ return None
50
+ values = [int(v) for v in parts[1:]]
51
+ idle = values[3] + (values[4] if len(values) > 4 else 0) # idle + iowait
52
+ total = sum(values[:8]) # guest time is already counted in user/nice
53
+ return total - idle, total
54
+
55
+
56
+ def cpu_percent() -> float | None:
57
+ """Average CPU use since the previous report (or over a short sample on the first one)."""
58
+ global _prev_cpu
59
+ now = cpu_times()
60
+ if now is None:
61
+ return None
62
+ before = _prev_cpu
63
+ if before is None:
64
+ time.sleep(0.5)
65
+ before, now = now, cpu_times() or now
66
+ _prev_cpu = now
67
+ busy, total = now[0] - before[0], now[1] - before[1]
68
+ return round(100.0 * busy / total, 1) if total > 0 else None
69
+
70
+
71
+ def meminfo(text: str | None = None) -> dict:
72
+ text = text if text is not None else _read(f"{PROC}/meminfo") or ""
73
+ kb = {}
74
+ for line in text.splitlines():
75
+ key, _, rest = line.partition(":")
76
+ fields = rest.split()
77
+ if fields and fields[0].isdigit():
78
+ kb[key] = int(fields[0]) * 1024
79
+ total = kb.get("MemTotal")
80
+ available = kb.get("MemAvailable", kb.get("MemFree"))
81
+ return {
82
+ "mem_total": total,
83
+ "mem_available": available,
84
+ "swap_total": kb.get("SwapTotal"),
85
+ "swap_free": kb.get("SwapFree"),
86
+ }
87
+
88
+
89
+ def loadavg(text: str | None = None) -> list[float] | None:
90
+ text = text if text is not None else _read(f"{PROC}/loadavg")
91
+ try:
92
+ return [float(v) for v in text.split()[:3]]
93
+ except (AttributeError, ValueError):
94
+ return None
95
+
96
+
97
+ def uptime(text: str | None = None) -> int | None:
98
+ text = text if text is not None else _read(f"{PROC}/uptime")
99
+ try:
100
+ return int(float(text.split()[0]))
101
+ except (AttributeError, ValueError, IndexError):
102
+ return None
103
+
104
+
105
+ def host_root() -> str | None:
106
+ """Where the host's filesystem is visible: "/" natively, SERVCHART_HOST_ROOT in a container."""
107
+ configured = os.environ.get("SERVCHART_HOST_ROOT")
108
+ if configured:
109
+ return configured.rstrip("/") or "/"
110
+ return None if _in_container() else "/"
111
+
112
+
113
+ def parse_mounts(text: str) -> list[tuple[str, str, str]]:
114
+ """(device, mount point, fs type) of real filesystems, one per device, shortest mount point first."""
115
+ seen, out = set(), []
116
+ rows = []
117
+ for line in text.splitlines():
118
+ parts = line.split()
119
+ if len(parts) < 3:
120
+ continue
121
+ device, mount, fstype = parts[0], parts[1].replace("\\040", " "), parts[2]
122
+ if fstype not in REAL_FS or mount.startswith(SKIP_PREFIXES):
123
+ continue
124
+ rows.append((device, mount, fstype))
125
+ for device, mount, fstype in sorted(rows, key=lambda r: (len(r[1]), r[1])):
126
+ if device in seen: # bind mounts of the same filesystem
127
+ continue
128
+ seen.add(device)
129
+ out.append((device, mount, fstype))
130
+ return out[:MAX_DISKS]
131
+
132
+
133
+ def _usage(path: str) -> tuple[int, int] | None:
134
+ try:
135
+ st = os.statvfs(path)
136
+ except OSError:
137
+ return None
138
+ total = st.f_blocks * st.f_frsize
139
+ if total <= 0:
140
+ return None
141
+ used = (st.f_blocks - st.f_bfree) * st.f_frsize
142
+ return total, used
143
+
144
+
145
+ def _disk(mount: str, fstype: str, usage: tuple[int, int]) -> dict:
146
+ total, used = usage
147
+ # percentage as df shows it: used / (used + available to users)
148
+ return {"mount": mount, "fs": fstype, "total": total, "used": used, "pct": round(100.0 * used / total, 1)}
149
+
150
+
151
+ def disks(docker_root: str | None = None) -> tuple[list[dict], str]:
152
+ """(disks, source). source is "host" (all real filesystems) or "docker" (Docker's data only)."""
153
+ root = host_root()
154
+ if root:
155
+ text = _read(f"{PROC}/1/mounts") or _read(os.path.join(root, "proc/1/mounts")) or _read("/proc/mounts") or ""
156
+ out = []
157
+ for _device, mount, fstype in parse_mounts(text):
158
+ path = mount if root == "/" else root + mount
159
+ usage = _usage(path)
160
+ if usage:
161
+ out.append(_disk(mount, fstype, usage))
162
+ if out:
163
+ return out, "host"
164
+ # In a container without the host's root: our own "/" sits on Docker's data filesystem.
165
+ usage = _usage("/")
166
+ if usage:
167
+ return [_disk(docker_root or "/var/lib/docker", "docker", usage)], "docker"
168
+ return [], "none"
169
+
170
+
171
+ def health(docker_root: str | None = None) -> dict:
172
+ disk_list, source = disks(docker_root)
173
+ return {
174
+ "cpu_pct": cpu_percent(),
175
+ "load": loadavg(),
176
+ "uptime_s": uptime(),
177
+ **meminfo(),
178
+ "disks": disk_list,
179
+ "disk_source": source,
180
+ }
@@ -2,6 +2,7 @@ import json
2
2
  import os
3
3
  import sys
4
4
  import unittest
5
+ from unittest import mock
5
6
 
6
7
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
7
8
 
@@ -98,3 +99,37 @@ class CollectTests(unittest.TestCase):
98
99
 
99
100
  if __name__ == "__main__":
100
101
  unittest.main()
102
+
103
+
104
+ class StatsClient(FakeClient):
105
+ def inspect_container(self, cid):
106
+ doc = json.loads(json.dumps(INSPECT))
107
+ doc["Image"] = "sha256:" + "b" * 64
108
+ doc["State"]["Running"] = True
109
+ return doc
110
+
111
+ def get(self, path):
112
+ if path.startswith("/images/"):
113
+ return {"RepoDigests": ["vaultwarden/server@sha256:" + "a" * 64]}
114
+ if "/stats" in path:
115
+ return {"cpu_stats": {"cpu_usage": {"total_usage": 1}, "system_cpu_usage": 10, "online_cpus": 1},
116
+ "memory_stats": {"usage": 2048, "limit": 4096, "stats": {}}}
117
+ raise RuntimeError(path)
118
+
119
+
120
+ class CollectV04Tests(unittest.TestCase):
121
+ def test_health_stats_and_digests(self):
122
+ with mock.patch.dict(os.environ, {"SERVCHART_STATUS_DIR": "/nonexistent"}):
123
+ snap = collect(StatsClient())
124
+ self.assertIn("mem_total", snap["host"]["health"])
125
+ self.assertIn(snap["host"]["health"]["disk_source"], ("host", "docker", "none"))
126
+ c = snap["containers"][0]
127
+ self.assertEqual(c["image_digests"], ["vaultwarden/server@sha256:" + "a" * 64])
128
+ self.assertEqual(c["stats"]["mem_used"], 2048)
129
+ self.assertNotIn("backups", snap)
130
+
131
+ def test_health_can_be_turned_off(self):
132
+ with mock.patch.dict(os.environ, {"SERVCHART_HEALTH": "0", "SERVCHART_STATS": "0"}):
133
+ snap = collect(StatsClient())
134
+ self.assertNotIn("health", snap["host"])
135
+ self.assertNotIn("stats", snap["containers"][0])
@@ -130,3 +130,17 @@ class ObserveTests(unittest.TestCase):
130
130
 
131
131
  if __name__ == "__main__":
132
132
  unittest.main()
133
+
134
+
135
+ class ExtraIpTests(unittest.TestCase):
136
+ def test_hub_listed_public_ip_is_reported(self):
137
+ from servchart_agent import connections as c
138
+ from servchart_agent.__main__ import apply_hub_hints
139
+
140
+ rows = [("10.0.0.5", 50000, "73.173.202.24", 8843, "01"), ("10.0.0.5", 50001, "8.8.8.8", 443, "01")]
141
+ self.assertEqual(c.outbound(rows), [])
142
+ apply_hub_hints({"observe_ips": ["73.173.202.24", "not-an-ip"]})
143
+ try:
144
+ self.assertEqual(c.outbound(rows), [{"ip": "73.173.202.24", "port": 8843, "count": 1}])
145
+ finally:
146
+ apply_hub_hints({"observe_ips": []})
@@ -0,0 +1,107 @@
1
+ import json
2
+ import os
3
+ import sys
4
+ import tempfile
5
+ import unittest
6
+ from unittest import mock
7
+
8
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
9
+
10
+ from servchart_agent import backups, stats, storage, system # noqa: E402
11
+
12
+ MOUNTS = """/dev/sda2 / ext4 rw,relatime 0 0
13
+ proc /proc proc rw 0 0
14
+ overlay /var/lib/docker/overlay2/abc/merged overlay rw 0 0
15
+ /dev/sda1 /boot/efi vfat rw 0 0
16
+ /dev/sda2 /home/user/bind ext4 rw 0 0
17
+ /dev/loop3 /snap/core/1 squashfs ro 0 0
18
+ nas:/export /mnt/my\\040nas nfs4 rw 0 0
19
+ tmpfs /run tmpfs rw 0 0
20
+ """
21
+
22
+
23
+ class SystemTests(unittest.TestCase):
24
+ def test_cpu_times(self):
25
+ busy, total = system.cpu_times("cpu 100 0 50 800 50 0 0 0 0 0\ncpu0 1 2 3 4\n")
26
+ self.assertEqual((busy, total), (150, 1000))
27
+
28
+ def test_meminfo_load_uptime(self):
29
+ m = system.meminfo("MemTotal: 1000 kB\nMemAvailable: 250 kB\nSwapTotal: 0 kB\n")
30
+ self.assertEqual((m["mem_total"], m["mem_available"]), (1024000, 256000))
31
+ self.assertEqual(system.loadavg("0.50 1.00 2.00 1/100 42"), [0.5, 1.0, 2.0])
32
+ self.assertEqual(system.uptime("3600.55 100.0"), 3600)
33
+
34
+ def test_parse_mounts_keeps_real_filesystems_once(self):
35
+ rows = system.parse_mounts(MOUNTS)
36
+ self.assertEqual([r[1] for r in rows], ["/", "/boot/efi", "/mnt/my nas"])
37
+
38
+ def test_docker_fallback_in_container(self):
39
+ with mock.patch.object(system, "host_root", return_value=None):
40
+ disks, source = system.disks("/var/lib/docker")
41
+ self.assertEqual(source, "docker")
42
+ self.assertEqual(disks[0]["mount"], "/var/lib/docker")
43
+
44
+
45
+ class StatsTests(unittest.TestCase):
46
+ DOC = {
47
+ "cpu_stats": {"cpu_usage": {"total_usage": 2_000_000}, "system_cpu_usage": 20_000_000, "online_cpus": 4},
48
+ "precpu_stats": {},
49
+ "memory_stats": {"usage": 300, "limit": 2**63, "stats": {"inactive_file": 100}},
50
+ "pids_stats": {"current": 7},
51
+ "networks": {"eth0": {"rx_bytes": 10, "tx_bytes": 20}},
52
+ }
53
+
54
+ def test_cpu_from_previous_sample_and_memory_without_cache(self):
55
+ prev = {}
56
+ first = stats.parse("c1", self.DOC, prev)
57
+ self.assertIsNone(first["cpu_pct"]) # nothing to compare with yet
58
+ self.assertEqual(first["mem_used"], 200)
59
+ self.assertIsNone(first["mem_limit"]) # no limit
60
+ nxt = json.loads(json.dumps(self.DOC))
61
+ nxt["cpu_stats"]["cpu_usage"]["total_usage"] = 3_000_000
62
+ nxt["cpu_stats"]["system_cpu_usage"] = 30_000_000
63
+ self.assertEqual(stats.parse("c1", nxt, prev)["cpu_pct"], 40.0) # 1/10 of the machine x 4 cpus
64
+
65
+
66
+ class StorageTests(unittest.TestCase):
67
+ def test_summarize(self):
68
+ df = {
69
+ "LayersSize": 5000,
70
+ "Images": [{"Containers": 1}, {"Containers": 0}],
71
+ "Containers": [{"SizeRw": 10}, {"SizeRw": None}],
72
+ "Volumes": [{"Name": "small", "UsageData": {"Size": 1, "RefCount": 1}}, {"Name": "big", "UsageData": {"Size": 900, "RefCount": 0}}],
73
+ "BuildCache": [{"Size": 7, "Shared": False}],
74
+ }
75
+ s = storage.summarize(df)
76
+ self.assertEqual(s["volumes"][0]["name"], "big")
77
+ self.assertEqual((s["images_unused"], s["volumes_unused"], s["volumes_bytes"], s["containers_rw_bytes"]), (1, 1, 901, 10))
78
+
79
+ def test_disabled(self):
80
+ with mock.patch.dict(os.environ, {"SERVCHART_DF_INTERVAL": "0"}):
81
+ self.assertIsNone(storage.collect(object()))
82
+
83
+
84
+ class BackupTests(unittest.TestCase):
85
+ def test_missing_dir_means_not_set_up(self):
86
+ with mock.patch.dict(os.environ, {"SERVCHART_STATUS_DIR": "/nonexistent/servchart"}):
87
+ self.assertIsNone(backups.collect())
88
+
89
+ def test_mark_and_read(self):
90
+ with tempfile.TemporaryDirectory() as d, mock.patch.dict(os.environ, {"SERVCHART_STATUS_DIR": d}):
91
+ backups.mark("kopia", message="1 GB", max_age_hours=26)
92
+ backups.mark("restic", ok=False, message="repo locked")
93
+ open(os.path.join(d, "borg-nightly"), "w").close() # plain file: mtime counts
94
+ with open(os.path.join(d, "bad name!.json"), "w") as fh:
95
+ fh.write("{}")
96
+ got = {b["name"]: b for b in backups.collect()}
97
+ self.assertEqual(sorted(got), ["borg-nightly", "kopia", "restic"])
98
+ self.assertTrue(got["kopia"]["ok"])
99
+ self.assertEqual(got["kopia"]["max_age_hours"], 26)
100
+ self.assertFalse(got["restic"]["ok"])
101
+ self.assertTrue(got["borg-nightly"]["ok"])
102
+ with self.assertRaises(ValueError):
103
+ backups.mark("../etc/passwd")
104
+
105
+
106
+ if __name__ == "__main__":
107
+ unittest.main()
File without changes