servchart-agent 0.2.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.
@@ -0,0 +1,25 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ .venv/
5
+ venv/
6
+
7
+ # Django runtime data (database, generated secret key) and build output
8
+ hub/data/
9
+ hub/staticfiles/
10
+ hub/web_dist/
11
+
12
+ # Node
13
+ web/node_modules/
14
+ web/dist/
15
+
16
+ # Your real inventory never belongs in the repo
17
+ *.inventory.json
18
+ local/
19
+ .env
20
+ .env.*
21
+
22
+ # OS / editors
23
+ .DS_Store
24
+ .idea/
25
+ .vscode/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 OptiCoDev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,53 @@
1
+ Metadata-Version: 2.5
2
+ Name: servchart-agent
3
+ Version: 0.2.0
4
+ Summary: ServChart agent: reports a host's Docker containers, networks and Compose stacks to a ServChart hub.
5
+ Project-URL: Homepage, https://github.com/opticodev/servchart
6
+ Project-URL: Issues, https://github.com/opticodev/servchart/issues
7
+ Author: OptiCoDev
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: docker,homelab,infrastructure,monitoring,self-hosted,topology
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: System Administrators
14
+ Classifier: Operating System :: POSIX :: Linux
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: System :: Monitoring
17
+ Classifier: Topic :: System :: Systems Administration
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+
21
+ # servchart-agent
22
+
23
+ The agent for [ServChart](https://github.com/opticodev/servchart), a self-hosted map of your servers, containers and Compose stacks.
24
+
25
+ It reads this host's Docker state (GET requests only), drops anything sensitive, and sends it to your ServChart hub every 60 seconds. It has no dependencies beyond Python's standard library.
26
+
27
+ ## Run it
28
+
29
+ **With Docker** (recommended):
30
+
31
+ ```bash
32
+ docker run -d --name servchart-agent --restart unless-stopped \
33
+ -v /var/run/docker.sock:/var/run/docker.sock:ro \
34
+ -e SERVCHART_HUB=https://servchart.example.com \
35
+ -e SERVCHART_TOKEN=sc_... \
36
+ opticodev/servchart-agent:latest
37
+ ```
38
+
39
+ **Without Docker**, from PyPI. The user running it needs access to the Docker socket, for example by being in the `docker` group:
40
+
41
+ ```bash
42
+ pipx install servchart-agent # or: pip install servchart-agent
43
+ servchart-agent --hub https://servchart.example.com --token sc_...
44
+ servchart-agent --stdout # print what would be sent, send nothing
45
+ ```
46
+
47
+ Get the token from the hub: **+ Add host** in the map.
48
+
49
+ ## What it sends
50
+
51
+ 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.
52
+
53
+ All options are in the [main README](https://github.com/opticodev/servchart#agent).
@@ -0,0 +1,33 @@
1
+ # servchart-agent
2
+
3
+ The agent for [ServChart](https://github.com/opticodev/servchart), a self-hosted map of your servers, containers and Compose stacks.
4
+
5
+ It reads this host's Docker state (GET requests only), drops anything sensitive, and sends it to your ServChart hub every 60 seconds. It has no dependencies beyond Python's standard library.
6
+
7
+ ## Run it
8
+
9
+ **With Docker** (recommended):
10
+
11
+ ```bash
12
+ docker run -d --name servchart-agent --restart unless-stopped \
13
+ -v /var/run/docker.sock:/var/run/docker.sock:ro \
14
+ -e SERVCHART_HUB=https://servchart.example.com \
15
+ -e SERVCHART_TOKEN=sc_... \
16
+ opticodev/servchart-agent:latest
17
+ ```
18
+
19
+ **Without Docker**, from PyPI. The user running it needs access to the Docker socket, for example by being in the `docker` group:
20
+
21
+ ```bash
22
+ pipx install servchart-agent # or: pip install servchart-agent
23
+ servchart-agent --hub https://servchart.example.com --token sc_...
24
+ servchart-agent --stdout # print what would be sent, send nothing
25
+ ```
26
+
27
+ Get the token from the hub: **+ Add host** in the map.
28
+
29
+ ## What it sends
30
+
31
+ 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.
32
+
33
+ All options are in the [main README](https://github.com/opticodev/servchart#agent).
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "servchart-agent"
7
+ dynamic = ["version"]
8
+ description = "ServChart agent: reports a host's Docker containers, networks and Compose stacks to a ServChart hub."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.9"
13
+ authors = [{ name = "OptiCoDev" }]
14
+ keywords = ["docker", "homelab", "self-hosted", "infrastructure", "monitoring", "topology"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Environment :: Console",
18
+ "Intended Audience :: System Administrators",
19
+ "Operating System :: POSIX :: Linux",
20
+ "Programming Language :: Python :: 3",
21
+ "Topic :: System :: Monitoring",
22
+ "Topic :: System :: Systems Administration",
23
+ ]
24
+ dependencies = [] # standard library only
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/opticodev/servchart"
28
+ Issues = "https://github.com/opticodev/servchart/issues"
29
+
30
+ [project.scripts]
31
+ servchart-agent = "servchart_agent.__main__:main"
32
+
33
+ [tool.hatch.version]
34
+ path = "servchart_agent/__init__.py"
35
+
36
+ [tool.hatch.build.targets.sdist]
37
+ include = ["servchart_agent", "tests", "README.md", "LICENSE"]
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["servchart_agent"]
@@ -0,0 +1,4 @@
1
+ """ServChart agent: reads Docker state on one host and reports it to a ServChart hub."""
2
+
3
+ __version__ = "0.2.0"
4
+ SCHEMA_VERSION = 1
@@ -0,0 +1,108 @@
1
+ """ServChart agent entry point.
2
+
3
+ python -m servchart_agent --hub https://servchart.example.com --token sc_xxx
4
+ python -m servchart_agent --stdout # print one snapshot and exit
5
+
6
+ Every option can also be set with an environment variable (shown in --help).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import logging
14
+ import os
15
+ import ssl
16
+ import sys
17
+ import time
18
+ import urllib.error
19
+ import urllib.request
20
+
21
+ from . import __version__
22
+ from .collect import collect
23
+ from .docker_api import DockerClient, DockerError
24
+
25
+ log = logging.getLogger("servchart.agent")
26
+
27
+
28
+ def push(snapshot: dict, hub: str, token: str, ca_file: str | None = None, insecure: bool = False) -> None:
29
+ url = hub.rstrip("/") + "/api/v1/report"
30
+ data = json.dumps(snapshot).encode()
31
+ req = urllib.request.Request(
32
+ url,
33
+ data=data,
34
+ method="POST",
35
+ headers={
36
+ "Content-Type": "application/json",
37
+ "Authorization": f"Bearer {token}",
38
+ "User-Agent": f"servchart-agent/{__version__}",
39
+ },
40
+ )
41
+ ctx = None
42
+ if url.startswith("https://"):
43
+ ctx = ssl.create_default_context(cafile=ca_file) if ca_file else ssl.create_default_context()
44
+ if insecure:
45
+ ctx.check_hostname = False
46
+ ctx.verify_mode = ssl.CERT_NONE
47
+ with urllib.request.urlopen(req, timeout=20, context=ctx) as resp:
48
+ if resp.status >= 300:
49
+ raise RuntimeError(f"hub answered HTTP {resp.status}")
50
+
51
+
52
+ def parse_args(argv=None):
53
+ env = os.environ.get
54
+ p = argparse.ArgumentParser(prog="servchart-agent", description="Report this host's Docker state to a ServChart hub.")
55
+ p.add_argument("--hub", default=env("SERVCHART_HUB"), help="Hub URL (env SERVCHART_HUB)")
56
+ p.add_argument("--token", default=env("SERVCHART_TOKEN"), help="Host token from the hub (env SERVCHART_TOKEN)")
57
+ p.add_argument("--interval", type=int, default=int(env("SERVCHART_INTERVAL", "60")),
58
+ help="Seconds between reports (env SERVCHART_INTERVAL, default 60)")
59
+ p.add_argument("--hostname", default=env("SERVCHART_HOSTNAME"),
60
+ help="Override the reported host name (env SERVCHART_HOSTNAME)")
61
+ p.add_argument("--ca-file", default=env("SERVCHART_CA_FILE"), help="CA bundle for a self-signed hub (env SERVCHART_CA_FILE)")
62
+ p.add_argument("--insecure", action="store_true", default=env("SERVCHART_INSECURE") == "1",
63
+ help="Skip TLS verification (env SERVCHART_INSECURE=1). Not recommended.")
64
+ p.add_argument("--once", action="store_true", help="Report once and exit")
65
+ p.add_argument("--stdout", action="store_true", help="Print the snapshot as JSON instead of sending it")
66
+ p.add_argument("--version", action="version", version=f"servchart-agent {__version__}")
67
+ return p.parse_args(argv)
68
+
69
+
70
+ def main(argv=None) -> int:
71
+ logging.basicConfig(level=os.environ.get("SERVCHART_LOG_LEVEL", "INFO"),
72
+ format="%(asctime)s %(levelname)s %(message)s")
73
+ args = parse_args(argv)
74
+ client = DockerClient()
75
+
76
+ if args.stdout:
77
+ json.dump(collect(client, args.hostname), sys.stdout, indent=2)
78
+ sys.stdout.write("\n")
79
+ return 0
80
+
81
+ if not args.hub or not args.token:
82
+ log.error("--hub and --token are required (or SERVCHART_HUB / SERVCHART_TOKEN)")
83
+ return 2
84
+
85
+ log.info("servchart-agent %s reporting to %s every %ss", __version__, args.hub, args.interval)
86
+ backoff = 0
87
+ while True:
88
+ try:
89
+ snap = collect(client, args.hostname)
90
+ push(snap, args.hub, args.token, args.ca_file, args.insecure)
91
+ log.info("reported %d containers, %d networks", len(snap["containers"]), len(snap["networks"]))
92
+ backoff = 0
93
+ except urllib.error.HTTPError as exc:
94
+ if exc.code in (401, 403):
95
+ log.error("hub rejected the token (HTTP %s) - check SERVCHART_TOKEN", exc.code)
96
+ else:
97
+ log.warning("hub error: HTTP %s", exc.code)
98
+ backoff = min(max(backoff * 2, args.interval), 900)
99
+ except (DockerError, OSError, RuntimeError) as exc:
100
+ log.warning("report failed: %s", exc)
101
+ backoff = min(max(backoff * 2, args.interval), 900)
102
+ if args.once:
103
+ return 0 if backoff == 0 else 1
104
+ time.sleep(backoff or args.interval)
105
+
106
+
107
+ if __name__ == "__main__":
108
+ sys.exit(main())
@@ -0,0 +1,170 @@
1
+ """Build a sanitized inventory snapshot of one Docker host.
2
+
3
+ Privacy rules (enforced here, before anything leaves the host):
4
+ * Container environment variables (Config.Env) are never read into the snapshot.
5
+ * Command lines / entrypoint arguments are dropped (they often carry secrets).
6
+ * Label values whose key looks secret-ish are replaced with "[redacted]".
7
+ * Bind-mount source paths are kept (they are useful for the map) but can be
8
+ hidden entirely with SERVCHART_HIDE_PATHS=1.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import re
15
+ from datetime import datetime, timezone
16
+
17
+ from . import SCHEMA_VERSION, __version__
18
+ from .docker_api import DockerClient
19
+
20
+ SECRET_KEY_RE = re.compile(r"(pass|secret|token|key|auth|credential|cookie|private)", re.IGNORECASE)
21
+ REDACTED = "[redacted]"
22
+
23
+
24
+ def _now() -> str:
25
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
26
+
27
+
28
+ def sanitize_labels(labels: dict | None) -> dict:
29
+ out = {}
30
+ for key, value in (labels or {}).items():
31
+ out[key] = REDACTED if SECRET_KEY_RE.search(key) else value
32
+ return out
33
+
34
+
35
+ def _ports(inspect: dict) -> list[dict]:
36
+ """One entry per (private port, protocol, public port); host IPs are merged.
37
+
38
+ Docker reports IPv4 and IPv6 bindings of the same port separately; the map
39
+ only needs to know the port is published and on which addresses.
40
+ """
41
+ merged: dict[tuple, dict] = {}
42
+ bindings = (inspect.get("NetworkSettings") or {}).get("Ports") or {}
43
+ for spec, published in bindings.items():
44
+ private, _, proto = spec.partition("/")
45
+ proto = proto or "tcp"
46
+ for b in published or [None]:
47
+ public = int(b["HostPort"]) if b and b.get("HostPort") else None
48
+ entry = merged.setdefault(
49
+ (int(private), proto, public),
50
+ {"private": int(private), "protocol": proto, "public": public, "ips": []},
51
+ )
52
+ ip = (b or {}).get("HostIp")
53
+ if ip and ip not in entry["ips"]:
54
+ entry["ips"].append(ip)
55
+ return [merged[k] for k in sorted(merged, key=lambda k: (k[0], k[1], k[2] or 0))]
56
+
57
+
58
+ def _mounts(inspect: dict, hide_paths: bool) -> list[dict]:
59
+ mounts = []
60
+ for m in inspect.get("Mounts") or []:
61
+ kind = m.get("Type")
62
+ if kind == "volume":
63
+ source = m.get("Name")
64
+ elif hide_paths:
65
+ source = None
66
+ else:
67
+ source = m.get("Source")
68
+ mounts.append(
69
+ {"type": kind, "source": source, "destination": m.get("Destination"), "rw": bool(m.get("RW"))}
70
+ )
71
+ return mounts
72
+
73
+
74
+ def _networks(inspect: dict) -> list[dict]:
75
+ nets = []
76
+ for name, cfg in sorted(((inspect.get("NetworkSettings") or {}).get("Networks") or {}).items()):
77
+ cfg = cfg or {}
78
+ nets.append(
79
+ {
80
+ "name": name,
81
+ "ip": cfg.get("IPAddress") or None,
82
+ "aliases": sorted(set(cfg.get("Aliases") or []) - {inspect.get("Id", "")[:12]}),
83
+ }
84
+ )
85
+ return nets
86
+
87
+
88
+ def compose_info(labels: dict, hide_paths: bool = False) -> dict:
89
+ """Which Compose stack a container belongs to, and the folder/files it was started from.
90
+
91
+ Docker Compose stamps every container with these labels, so no access to the
92
+ compose files themselves is needed. Portainer and Dockge stacks use Compose too.
93
+ """
94
+ config_files = labels.get("com.docker.compose.project.config_files") or ""
95
+ return {
96
+ "project": labels.get("com.docker.compose.project"),
97
+ "service": labels.get("com.docker.compose.service"),
98
+ "working_dir": None if hide_paths else labels.get("com.docker.compose.project.working_dir"),
99
+ "config_files": [] if hide_paths else [f for f in config_files.split(",") if f],
100
+ }
101
+
102
+
103
+ def container_record(inspect: dict, hide_paths: bool = False) -> dict:
104
+ """Convert a `docker inspect` document into a sanitized ServChart record.
105
+
106
+ Deliberately whitelists fields instead of copying the document, so new
107
+ Docker fields (or Env) can never leak by accident.
108
+ """
109
+ config = inspect.get("Config") or {}
110
+ state = inspect.get("State") or {}
111
+ host_config = inspect.get("HostConfig") or {}
112
+ labels = config.get("Labels") or {}
113
+ health = (state.get("Health") or {}).get("Status")
114
+ return {
115
+ "id": (inspect.get("Id") or "")[:12],
116
+ "name": (inspect.get("Name") or "").lstrip("/"),
117
+ "image": config.get("Image"),
118
+ "state": state.get("Status"),
119
+ "health": health,
120
+ "started_at": state.get("StartedAt"),
121
+ "restart_policy": (host_config.get("RestartPolicy") or {}).get("Name") or None,
122
+ "network_mode": host_config.get("NetworkMode"),
123
+ "compose": compose_info(labels, hide_paths),
124
+ "labels": sanitize_labels(labels),
125
+ "networks": _networks(inspect),
126
+ "ports": _ports(inspect),
127
+ "mounts": _mounts(inspect, hide_paths),
128
+ }
129
+
130
+
131
+ def network_record(net: dict) -> dict:
132
+ ipam = (net.get("IPAM") or {}).get("Config") or []
133
+ return {
134
+ "id": (net.get("Id") or "")[:12],
135
+ "name": net.get("Name"),
136
+ "driver": net.get("Driver"),
137
+ "scope": net.get("Scope"),
138
+ "internal": bool(net.get("Internal")),
139
+ "subnets": [c.get("Subnet") for c in ipam if c.get("Subnet")],
140
+ }
141
+
142
+
143
+ def collect(client: DockerClient | None = None, hostname: str | None = None) -> dict:
144
+ client = client or DockerClient()
145
+ hide_paths = os.environ.get("SERVCHART_HIDE_PATHS", "0") == "1"
146
+ info = client.info()
147
+ version = client.version()
148
+ containers = []
149
+ for summary in client.containers():
150
+ try:
151
+ containers.append(container_record(client.inspect_container(summary["Id"]), hide_paths))
152
+ except Exception: # container vanished between list and inspect
153
+ continue
154
+ networks = [network_record(n) for n in client.networks()]
155
+ return {
156
+ "schema": SCHEMA_VERSION,
157
+ "agent_version": __version__,
158
+ "collected_at": _now(),
159
+ "host": {
160
+ "hostname": hostname or os.environ.get("SERVCHART_HOSTNAME") or info.get("Name"),
161
+ "os": info.get("OperatingSystem"),
162
+ "kernel": info.get("KernelVersion"),
163
+ "arch": info.get("Architecture"),
164
+ "cpus": info.get("NCPU"),
165
+ "memory_bytes": info.get("MemTotal"),
166
+ "docker_version": version.get("Version"),
167
+ },
168
+ "containers": sorted(containers, key=lambda c: c["name"]),
169
+ "networks": sorted(networks, key=lambda n: n["name"] or ""),
170
+ }
@@ -0,0 +1,83 @@
1
+ """Minimal, dependency-free, read-only client for the Docker Engine API.
2
+
3
+ Only GET requests are ever issued. Supports unix sockets (default) and plain
4
+ TCP (e.g. a docker-socket-proxy sidecar) via the standard DOCKER_HOST variable.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import http.client
10
+ import json
11
+ import os
12
+ import socket
13
+ from urllib.parse import quote, urlparse
14
+
15
+ DEFAULT_DOCKER_HOST = "unix:///var/run/docker.sock"
16
+
17
+
18
+ class DockerError(RuntimeError):
19
+ pass
20
+
21
+
22
+ class _UnixHTTPConnection(http.client.HTTPConnection):
23
+ def __init__(self, socket_path: str, timeout: float = 10.0):
24
+ super().__init__("localhost", timeout=timeout)
25
+ self.socket_path = socket_path
26
+
27
+ def connect(self) -> None: # pragma: no cover - exercised against a real daemon
28
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
29
+ sock.settimeout(self.timeout)
30
+ sock.connect(self.socket_path)
31
+ self.sock = sock
32
+
33
+
34
+ class DockerClient:
35
+ """Read-only Docker Engine API client."""
36
+
37
+ def __init__(self, docker_host: str | None = None, timeout: float = 10.0):
38
+ self.docker_host = docker_host or os.environ.get("DOCKER_HOST") or DEFAULT_DOCKER_HOST
39
+ self.timeout = timeout
40
+ parsed = urlparse(self.docker_host)
41
+ if parsed.scheme == "unix":
42
+ self._target = ("unix", parsed.path)
43
+ elif parsed.scheme in ("tcp", "http"):
44
+ self._target = ("tcp", (parsed.hostname or "localhost", parsed.port or 2375))
45
+ else:
46
+ raise DockerError(f"Unsupported DOCKER_HOST scheme: {self.docker_host!r} (use unix:// or tcp://)")
47
+
48
+ def _connection(self) -> http.client.HTTPConnection:
49
+ kind, target = self._target
50
+ if kind == "unix":
51
+ return _UnixHTTPConnection(target, timeout=self.timeout)
52
+ host, port = target
53
+ return http.client.HTTPConnection(host, port, timeout=self.timeout)
54
+
55
+ def get(self, path: str):
56
+ conn = self._connection()
57
+ try:
58
+ conn.request("GET", path, headers={"Accept": "application/json"})
59
+ resp = conn.getresponse()
60
+ body = resp.read()
61
+ except OSError as exc:
62
+ raise DockerError(f"Cannot reach Docker at {self.docker_host}: {exc}") from exc
63
+ finally:
64
+ conn.close()
65
+ if resp.status >= 400:
66
+ raise DockerError(f"Docker API GET {path} -> HTTP {resp.status}: {body[:200]!r}")
67
+ return json.loads(body or b"null")
68
+
69
+ # Convenience wrappers -------------------------------------------------
70
+ def info(self):
71
+ return self.get("/info")
72
+
73
+ def version(self):
74
+ return self.get("/version")
75
+
76
+ def containers(self):
77
+ return self.get("/containers/json?all=1")
78
+
79
+ def inspect_container(self, container_id: str):
80
+ return self.get(f"/containers/{quote(container_id)}/json")
81
+
82
+ def networks(self):
83
+ return self.get("/networks")
@@ -0,0 +1,100 @@
1
+ import json
2
+ import os
3
+ import sys
4
+ import unittest
5
+
6
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
7
+
8
+ from servchart_agent.collect import REDACTED, collect, container_record, sanitize_labels # noqa: E402
9
+
10
+ INSPECT = {
11
+ "Id": "abcdef1234567890",
12
+ "Name": "/vaultwarden",
13
+ "Config": {
14
+ "Image": "vaultwarden/server:latest",
15
+ "Env": ["ADMIN_TOKEN=supersecret", "DATABASE_URL=postgres://u:p@db/x"],
16
+ "Cmd": ["--password", "hunter2"],
17
+ "Labels": {
18
+ "com.docker.compose.project": "vault",
19
+ "com.docker.compose.service": "web",
20
+ "com.docker.compose.project.working_dir": "/opt/stacks/vault",
21
+ "com.docker.compose.project.config_files": "/opt/stacks/vault/compose.yml,/opt/stacks/vault/override.yml",
22
+ "traefik.http.middlewares.auth.basicauth.users": "admin:$apr1$hash",
23
+ "org.opencontainers.image.title": "vaultwarden",
24
+ },
25
+ },
26
+ "State": {"Status": "running", "StartedAt": "2026-10-01T00:00:00Z", "Health": {"Status": "healthy"}},
27
+ "HostConfig": {"RestartPolicy": {"Name": "unless-stopped"}, "NetworkMode": "vault_default"},
28
+ "NetworkSettings": {
29
+ "Ports": {
30
+ "80/tcp": [{"HostIp": "0.0.0.0", "HostPort": "8082"}, {"HostIp": "::", "HostPort": "8082"}],
31
+ "3012/tcp": None,
32
+ },
33
+ "Networks": {"vault_default": {"IPAddress": "172.20.0.2", "Aliases": ["web", "abcdef123456"]}},
34
+ },
35
+ "Mounts": [
36
+ {"Type": "bind", "Source": "/srv/vw/data", "Destination": "/data", "RW": True},
37
+ {"Type": "volume", "Name": "vw_cache", "Source": "/var/lib/docker/volumes/vw_cache/_data",
38
+ "Destination": "/cache", "RW": False},
39
+ ],
40
+ }
41
+
42
+
43
+ class FakeClient:
44
+ def info(self):
45
+ return {"Name": "docker-host", "OperatingSystem": "Ubuntu 24.04", "NCPU": 4, "MemTotal": 16 * 2**30}
46
+
47
+ def version(self):
48
+ return {"Version": "29.0.0"}
49
+
50
+ def containers(self):
51
+ return [{"Id": INSPECT["Id"]}]
52
+
53
+ def inspect_container(self, cid):
54
+ return INSPECT
55
+
56
+ def networks(self):
57
+ return [{"Id": "n1", "Name": "vault_default", "Driver": "bridge", "Scope": "local",
58
+ "IPAM": {"Config": [{"Subnet": "172.20.0.0/16"}]}}]
59
+
60
+
61
+ class CollectTests(unittest.TestCase):
62
+ def test_no_env_or_command_leaks(self):
63
+ blob = json.dumps(collect(FakeClient()))
64
+ for secret in ("supersecret", "postgres://", "hunter2", "$apr1$hash"):
65
+ self.assertNotIn(secret, blob)
66
+
67
+ def test_secretish_labels_redacted(self):
68
+ labels = sanitize_labels({"x.password": "a", "x.title": "b", "API_TOKEN": "c"})
69
+ self.assertEqual(labels, {"x.password": REDACTED, "x.title": "b", "API_TOKEN": REDACTED})
70
+
71
+ def test_record_shape(self):
72
+ rec = container_record(INSPECT)
73
+ self.assertEqual(rec["name"], "vaultwarden")
74
+ self.assertEqual(rec["compose"]["project"], "vault")
75
+ self.assertEqual(rec["compose"]["working_dir"], "/opt/stacks/vault")
76
+ self.assertEqual(rec["compose"]["config_files"], ["/opt/stacks/vault/compose.yml", "/opt/stacks/vault/override.yml"])
77
+ self.assertEqual(rec["health"], "healthy")
78
+ # IPv4 + IPv6 bindings of the same port merge into one entry; unpublished port kept
79
+ self.assertEqual([(p["private"], p["public"]) for p in rec["ports"]], [(80, 8082), (3012, None)])
80
+ self.assertEqual(rec["ports"][0]["ips"], ["0.0.0.0", "::"])
81
+ self.assertEqual(rec["networks"][0]["aliases"], ["web"])
82
+ self.assertEqual(rec["mounts"][1]["source"], "vw_cache")
83
+
84
+ def test_hide_paths(self):
85
+ rec = container_record(INSPECT, hide_paths=True)
86
+ self.assertIsNone(rec["mounts"][0]["source"])
87
+ self.assertEqual(rec["mounts"][1]["source"], "vw_cache")
88
+ self.assertIsNone(rec["compose"]["working_dir"])
89
+ self.assertEqual(rec["compose"]["config_files"], [])
90
+ self.assertEqual(rec["compose"]["project"], "vault")
91
+
92
+ def test_snapshot_header(self):
93
+ snap = collect(FakeClient(), hostname="override")
94
+ self.assertEqual(snap["schema"], 1)
95
+ self.assertEqual(snap["host"]["hostname"], "override")
96
+ self.assertEqual(snap["networks"][0]["subnets"], ["172.20.0.0/16"])
97
+
98
+
99
+ if __name__ == "__main__":
100
+ unittest.main()