servchart-agent 0.2.0__py3-none-any.whl
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/__init__.py +4 -0
- servchart_agent/__main__.py +108 -0
- servchart_agent/collect.py +170 -0
- servchart_agent/docker_api.py +83 -0
- servchart_agent-0.2.0.dist-info/METADATA +53 -0
- servchart_agent-0.2.0.dist-info/RECORD +9 -0
- servchart_agent-0.2.0.dist-info/WHEEL +4 -0
- servchart_agent-0.2.0.dist-info/entry_points.txt +2 -0
- servchart_agent-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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,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,9 @@
|
|
|
1
|
+
servchart_agent/__init__.py,sha256=9Zd5KB0SHiCdH4k9Kjzcq1H1vDnMIsZA2NdUT-ZQKhA,131
|
|
2
|
+
servchart_agent/__main__.py,sha256=qkf4e9GeJvulSL70GVUilmADpB2rjzX8nmDrid_z2MI,4418
|
|
3
|
+
servchart_agent/collect.py,sha256=aZ1CQbSmKUaVfsqH4iR7JPicj04rbGOnv_L19XNbX4M,6545
|
|
4
|
+
servchart_agent/docker_api.py,sha256=FwZKGtJXRuDpCBHBkBA0-Fau_w2Flr2URuf-_k72YmI,2901
|
|
5
|
+
servchart_agent-0.2.0.dist-info/METADATA,sha256=PHNg8D6OhTSsSw-_FbmQazQPstF5PT88QzvlvZuRq8Y,2335
|
|
6
|
+
servchart_agent-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
7
|
+
servchart_agent-0.2.0.dist-info/entry_points.txt,sha256=3UWxVJcW8zzuYepv4WpeJJofPuiIt_36DQGtrBMB3wc,66
|
|
8
|
+
servchart_agent-0.2.0.dist-info/licenses/LICENSE,sha256=kAQrAjhxxejwnDdhzTFv7HaAMeL250OOWcW-AlY_eHc,1066
|
|
9
|
+
servchart_agent-0.2.0.dist-info/RECORD,,
|
|
@@ -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.
|