Skip to content

Commit 97c7cd9

Browse files
authored
facts.docker: add version, container, image, network detail facts (#1668)
1 parent 8fcc5fd commit 97c7cd9

15 files changed

Lines changed: 472 additions & 1 deletion

File tree

src/pyinfra/facts/docker.py

Lines changed: 244 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
from typing_extensions import override
1212

13-
from pyinfra.api import FactBase
13+
from pyinfra.api import FactBase, ShortFactBase
1414

1515

1616
class DockerFactBase(FactBase):
@@ -28,6 +28,18 @@ def process(self, output):
2828
return json.loads(output)
2929

3030

31+
class _DockerJsonLinesFactBase(FactBase):
32+
abstract = True
33+
34+
@override
35+
def requires_command(self, *args, **kwargs) -> str:
36+
return "docker"
37+
38+
@override
39+
def process(self, output):
40+
return [json.loads(line) for line in output if line.strip()]
41+
42+
3143
class DockerSystemInfo(DockerFactBase):
3244
"""
3345
Returns ``docker system info`` output in JSON format.
@@ -38,6 +50,24 @@ def command(self) -> str:
3850
return 'docker system info --format="{{json .}}"'
3951

4052

53+
class DockerVersion(FactBase[str]):
54+
"""
55+
Returns the Docker version.
56+
"""
57+
58+
@override
59+
def requires_command(self) -> str:
60+
return "docker"
61+
62+
@override
63+
def command(self) -> str:
64+
return "docker --version"
65+
66+
@override
67+
def process(self, output):
68+
return "".join(output).replace("\n", "")
69+
70+
4171
# All Docker objects
4272
#
4373

@@ -149,3 +179,216 @@ class DockerPlugin(DockerSingleMixin):
149179
"""
150180

151181
docker_type = "plugin"
182+
183+
184+
# Derived facts: extract specific slices from the full inspect output.
185+
#
186+
187+
188+
class DockerContainerEnvs(ShortFactBase):
189+
"""
190+
Returns environment variables for all Docker containers, keyed by container ID.
191+
"""
192+
193+
fact = DockerContainers
194+
195+
@override
196+
def process_data(self, data):
197+
result = {}
198+
for container in data or []:
199+
container_id = container.get("Id")
200+
env_list = (container.get("Config") or {}).get("Env") or []
201+
env: dict = {}
202+
for entry in env_list:
203+
key, _, value = entry.partition("=")
204+
env[key] = value
205+
result[container_id] = env
206+
return result
207+
208+
209+
class DockerContainerLabels(ShortFactBase):
210+
"""
211+
Returns labels for all Docker containers, keyed by container ID.
212+
"""
213+
214+
fact = DockerContainers
215+
216+
@override
217+
def process_data(self, data):
218+
return {
219+
container.get("Id"): (container.get("Config") or {}).get("Labels") or {}
220+
for container in data or []
221+
}
222+
223+
224+
class DockerContainerMounts(ShortFactBase):
225+
"""
226+
Returns mounts for all Docker containers, keyed by container ID.
227+
"""
228+
229+
fact = DockerContainers
230+
231+
@override
232+
def process_data(self, data):
233+
return {container.get("Id"): container.get("Mounts") or [] for container in data or []}
234+
235+
236+
class DockerContainerNetworks(ShortFactBase):
237+
"""
238+
Returns networks for all Docker containers, keyed by container ID.
239+
"""
240+
241+
fact = DockerContainers
242+
243+
@override
244+
def process_data(self, data):
245+
return {
246+
container.get("Id"): ((container.get("NetworkSettings") or {}).get("Networks") or {})
247+
for container in data or []
248+
}
249+
250+
251+
class DockerContainerPorts(ShortFactBase):
252+
"""
253+
Returns published port bindings for all Docker containers, keyed by container ID.
254+
"""
255+
256+
fact = DockerContainers
257+
258+
@override
259+
def process_data(self, data):
260+
return {
261+
container.get("Id"): ((container.get("NetworkSettings") or {}).get("Ports") or {})
262+
for container in data or []
263+
}
264+
265+
266+
class DockerImageLabels(ShortFactBase):
267+
"""
268+
Returns labels for all Docker images, keyed by image ID.
269+
"""
270+
271+
fact = DockerImages
272+
273+
@override
274+
def process_data(self, data):
275+
return {
276+
image.get("Id"): (
277+
(image.get("Config") or {}).get("Labels")
278+
or (image.get("ContainerConfig") or {}).get("Labels")
279+
or {}
280+
)
281+
for image in data or []
282+
}
283+
284+
285+
class DockerImageLayers(ShortFactBase):
286+
"""
287+
Returns layer digests for all Docker images, keyed by image ID.
288+
"""
289+
290+
fact = DockerImages
291+
292+
@override
293+
def process_data(self, data):
294+
return {
295+
image.get("Id"): (image.get("RootFS") or {}).get("Layers") or [] for image in data or []
296+
}
297+
298+
299+
class DockerNetworkLabels(ShortFactBase):
300+
"""
301+
Returns labels for all Docker networks, keyed by network ID.
302+
"""
303+
304+
fact = DockerNetworks
305+
306+
@override
307+
def process_data(self, data):
308+
return {network.get("Id"): network.get("Labels") or {} for network in data or []}
309+
310+
311+
class DockerVolumeLabels(ShortFactBase):
312+
"""
313+
Returns labels for all Docker volumes, keyed by volume name.
314+
"""
315+
316+
fact = DockerVolumes
317+
318+
@override
319+
def process_data(self, data):
320+
return {volume.get("Name"): volume.get("Labels") or {} for volume in data or []}
321+
322+
323+
# Facts that need their own docker command.
324+
#
325+
326+
327+
class DockerContainerFsChanges(DockerFactBase):
328+
"""
329+
Returns filesystem changes for a single container (``docker diff``) as a list of
330+
``{"path": ..., "change": "A"|"C"|"D"}`` entries.
331+
"""
332+
333+
@override
334+
def command(self, container_id) -> str:
335+
return "docker container diff {0} 2>&- || true".format(container_id)
336+
337+
@override
338+
def process(self, output):
339+
changes = []
340+
for line in output:
341+
line = line.rstrip("\n")
342+
if not line or " " not in line:
343+
continue
344+
change, _, path = line.partition(" ")
345+
changes.append({"change": change, "path": path})
346+
return changes
347+
348+
349+
class DockerContainerProcesses(DockerFactBase):
350+
"""
351+
Returns processes running inside a single container (``docker top``) as a list of
352+
column dicts. Column names follow the ``ps`` output header.
353+
"""
354+
355+
@override
356+
def command(self, container_id) -> str:
357+
return "docker container top {0} 2>&- || true".format(container_id)
358+
359+
@override
360+
def process(self, output):
361+
lines = [line for line in (line.rstrip("\n") for line in output) if line]
362+
if not lines:
363+
return []
364+
header = lines[0].split()
365+
processes = []
366+
for line in lines[1:]:
367+
fields = line.split(None, len(header) - 1)
368+
if len(fields) < len(header):
369+
fields += [""] * (len(header) - len(fields))
370+
processes.append(dict(zip(header, fields)))
371+
return processes
372+
373+
374+
class DockerContainerStats(_DockerJsonLinesFactBase):
375+
"""
376+
Returns resource-usage stats for all running containers (``docker stats``) as a list
377+
of dicts (one entry per container).
378+
"""
379+
380+
@override
381+
def command(self) -> str:
382+
return "docker stats --no-stream --format '{{json .}}'"
383+
384+
385+
class DockerImageHistory(_DockerJsonLinesFactBase):
386+
"""
387+
Returns the layer history of a single image (``docker history``) as a list of dicts.
388+
"""
389+
390+
@override
391+
def command(self, image_id) -> str:
392+
return (
393+
"docker image history --no-trunc --format '{{{{json .}}}}' {0} 2>&- || true"
394+
).format(image_id)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"command": "ids=$(docker ps -qa) && [ -n \"$ids\" ] && docker container inspect $ids || echo \"[]\"",
3+
"requires_command": "docker",
4+
"output": ["[{\"Id\": \"abc123\", \"Config\": {\"Env\": [\"PATH=/usr/bin\", \"DEBUG=true\", \"EMPTY=\"]}}, {\"Id\": \"def456\", \"Config\": {\"Env\": null}}]"],
5+
"fact": {
6+
"abc123": {
7+
"PATH": "/usr/bin",
8+
"DEBUG": "true",
9+
"EMPTY": ""
10+
},
11+
"def456": {}
12+
}
13+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"arg": "abc123",
3+
"command": "docker container diff abc123 2>&- || true",
4+
"requires_command": "docker",
5+
"output": [
6+
"C /etc",
7+
"A /etc/new_file",
8+
"D /tmp/old_file",
9+
""
10+
],
11+
"fact": [
12+
{"change": "C", "path": "/etc"},
13+
{"change": "A", "path": "/etc/new_file"},
14+
{"change": "D", "path": "/tmp/old_file"}
15+
]
16+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"command": "ids=$(docker ps -qa) && [ -n \"$ids\" ] && docker container inspect $ids || echo \"[]\"",
3+
"requires_command": "docker",
4+
"output": ["[{\"Id\": \"abc123\", \"Config\": {\"Labels\": {\"app\": \"web\", \"tier\": \"frontend\"}}}, {\"Id\": \"def456\", \"Config\": {\"Labels\": null}}]"],
5+
"fact": {
6+
"abc123": {
7+
"app": "web",
8+
"tier": "frontend"
9+
},
10+
"def456": {}
11+
}
12+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"command": "ids=$(docker ps -qa) && [ -n \"$ids\" ] && docker container inspect $ids || echo \"[]\"",
3+
"requires_command": "docker",
4+
"output": ["[{\"Id\": \"abc123\", \"Mounts\": [{\"Type\": \"bind\", \"Source\": \"/host\", \"Destination\": \"/container\", \"Mode\": \"\", \"RW\": true, \"Propagation\": \"rprivate\"}]}, {\"Id\": \"def456\"}]"],
5+
"fact": {
6+
"abc123": [
7+
{
8+
"Type": "bind",
9+
"Source": "/host",
10+
"Destination": "/container",
11+
"Mode": "",
12+
"RW": true,
13+
"Propagation": "rprivate"
14+
}
15+
],
16+
"def456": []
17+
}
18+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"command": "ids=$(docker ps -qa) && [ -n \"$ids\" ] && docker container inspect $ids || echo \"[]\"",
3+
"requires_command": "docker",
4+
"output": ["[{\"Id\": \"abc123\", \"NetworkSettings\": {\"Networks\": {\"bridge\": {\"IPAddress\": \"172.17.0.2\", \"Gateway\": \"172.17.0.1\", \"MacAddress\": \"02:42:ac:11:00:02\"}}}}, {\"Id\": \"def456\", \"NetworkSettings\": {}}]"],
5+
"fact": {
6+
"abc123": {
7+
"bridge": {
8+
"IPAddress": "172.17.0.2",
9+
"Gateway": "172.17.0.1",
10+
"MacAddress": "02:42:ac:11:00:02"
11+
}
12+
},
13+
"def456": {}
14+
}
15+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"command": "ids=$(docker ps -qa) && [ -n \"$ids\" ] && docker container inspect $ids || echo \"[]\"",
3+
"requires_command": "docker",
4+
"output": ["[{\"Id\": \"abc123\", \"NetworkSettings\": {\"Ports\": {\"80/tcp\": [{\"HostIp\": \"0.0.0.0\", \"HostPort\": \"8080\"}], \"443/tcp\": null}}}, {\"Id\": \"def456\", \"NetworkSettings\": {\"Ports\": null}}]"],
5+
"fact": {
6+
"abc123": {
7+
"80/tcp": [
8+
{
9+
"HostIp": "0.0.0.0",
10+
"HostPort": "8080"
11+
}
12+
],
13+
"443/tcp": null
14+
},
15+
"def456": {}
16+
}
17+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"arg": "abc123",
3+
"command": "docker container top abc123 2>&- || true",
4+
"requires_command": "docker",
5+
"output": [
6+
"UID PID PPID C STIME TTY TIME CMD",
7+
"root 1234 1222 0 12:00 pts/0 00:00:00 nginx: master process",
8+
"nginx 1250 1234 0 12:00 pts/0 00:00:00 nginx: worker process"
9+
],
10+
"fact": [
11+
{
12+
"UID": "root",
13+
"PID": "1234",
14+
"PPID": "1222",
15+
"C": "0",
16+
"STIME": "12:00",
17+
"TTY": "pts/0",
18+
"TIME": "00:00:00",
19+
"CMD": "nginx: master process"
20+
},
21+
{
22+
"UID": "nginx",
23+
"PID": "1250",
24+
"PPID": "1234",
25+
"C": "0",
26+
"STIME": "12:00",
27+
"TTY": "pts/0",
28+
"TIME": "00:00:00",
29+
"CMD": "nginx: worker process"
30+
}
31+
]
32+
}

0 commit comments

Comments
 (0)