servchart-agent 0.3.1__tar.gz → 0.4.1__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.
- {servchart_agent-0.3.1 → servchart_agent-0.4.1}/PKG-INFO +4 -1
- {servchart_agent-0.3.1 → servchart_agent-0.4.1}/README.md +3 -0
- {servchart_agent-0.3.1 → servchart_agent-0.4.1}/servchart_agent/__init__.py +1 -1
- {servchart_agent-0.3.1 → servchart_agent-0.4.1}/servchart_agent/__main__.py +35 -2
- servchart_agent-0.4.1/servchart_agent/backups.py +111 -0
- {servchart_agent-0.3.1 → servchart_agent-0.4.1}/servchart_agent/collect.py +37 -0
- {servchart_agent-0.3.1 → servchart_agent-0.4.1}/servchart_agent/connections.py +6 -1
- servchart_agent-0.4.1/servchart_agent/stats.py +72 -0
- servchart_agent-0.4.1/servchart_agent/storage.py +65 -0
- servchart_agent-0.4.1/servchart_agent/system.py +180 -0
- {servchart_agent-0.3.1 → servchart_agent-0.4.1}/tests/test_collect.py +35 -0
- {servchart_agent-0.3.1 → servchart_agent-0.4.1}/tests/test_connections.py +14 -0
- servchart_agent-0.4.1/tests/test_health.py +107 -0
- {servchart_agent-0.3.1 → servchart_agent-0.4.1}/.gitignore +0 -0
- {servchart_agent-0.3.1 → servchart_agent-0.4.1}/LICENSE +0 -0
- {servchart_agent-0.3.1 → servchart_agent-0.4.1}/pyproject.toml +0 -0
- {servchart_agent-0.3.1 → servchart_agent-0.4.1}/servchart_agent/commands.py +0 -0
- {servchart_agent-0.3.1 → servchart_agent-0.4.1}/servchart_agent/docker_api.py +0 -0
- {servchart_agent-0.3.1 → servchart_agent-0.4.1}/servchart_agent/logs.py +0 -0
- {servchart_agent-0.3.1 → servchart_agent-0.4.1}/tests/test_logs.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: servchart-agent
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.1
|
|
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).
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|