mcp-devops-assistant 0.1.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.
@@ -0,0 +1,259 @@
1
+ import re
2
+ from typing import Any
3
+
4
+ import docker
5
+ from docker.errors import APIError, DockerException, NotFound
6
+ from fastmcp.exceptions import ToolError
7
+
8
+ from ..config import Settings
9
+ from ..errors import PolicyDeniedError
10
+ from ..security import limit, redact
11
+
12
+ _MAX_RESULTS = 100
13
+
14
+
15
+ class DockerClient:
16
+ def __init__(self, settings: Settings) -> None:
17
+ self.settings = settings
18
+ self._client: docker.DockerClient | None = None
19
+
20
+ def _get_client(self) -> docker.DockerClient:
21
+ if self._client is None:
22
+ try:
23
+ self._client = docker.from_env(timeout=10)
24
+ except DockerException as exc:
25
+ raise ToolError(
26
+ "Docker is unavailable; check the daemon and connection settings"
27
+ ) from exc
28
+ except OSError as exc:
29
+ raise ToolError(
30
+ "Docker connection settings are invalid or unavailable"
31
+ ) from exc
32
+ return self._client
33
+
34
+ def list_containers(
35
+ self, include_stopped: bool, limit_count: int
36
+ ) -> list[dict[str, Any]]:
37
+ self._validate_limit(limit_count)
38
+ try:
39
+ containers = self._get_client().containers.list(all=include_stopped)
40
+ results: list[dict[str, Any]] = []
41
+ allowed = {
42
+ name.casefold() for name in self.settings.allowed_docker_containers
43
+ }
44
+ for container in containers:
45
+ name = self._container_name(container)
46
+ if allowed and name.casefold() not in allowed:
47
+ continue
48
+ results.append(
49
+ self._shape(
50
+ {
51
+ "name": name,
52
+ "id": getattr(container, "short_id", None),
53
+ "image": self._image_name(container),
54
+ "status": getattr(container, "status", None),
55
+ }
56
+ )
57
+ )
58
+ if len(results) >= limit_count:
59
+ break
60
+ return results
61
+ except DockerException as exc:
62
+ raise self._map_docker_error(exc) from exc
63
+
64
+ def list_images(self, limit_count: int) -> list[dict[str, Any]]:
65
+ self._validate_limit(limit_count)
66
+ try:
67
+ images = self._get_client().images.list()
68
+ return [
69
+ self._shape(
70
+ {
71
+ "id": getattr(image, "short_id", None),
72
+ "tags": list(getattr(image, "tags", []) or []),
73
+ "created": getattr(image, "attrs", {}).get("Created"),
74
+ "size": getattr(image, "attrs", {}).get("Size"),
75
+ }
76
+ )
77
+ for image in images[:limit_count]
78
+ ]
79
+ except DockerException as exc:
80
+ raise self._map_docker_error(exc) from exc
81
+
82
+ def container_status(self, name: str) -> dict[str, Any]:
83
+ container = self._get_container(name)
84
+ try:
85
+ container.reload()
86
+ except DockerException as exc:
87
+ raise self._map_docker_error(exc) from exc
88
+
89
+ attrs = getattr(container, "attrs", {})
90
+ state = attrs.get("State") or {}
91
+ return self._shape(
92
+ {
93
+ "name": self._container_name(container),
94
+ "id": getattr(container, "short_id", None),
95
+ "image": self._image_name(container),
96
+ "status": state.get("Status", getattr(container, "status", None)),
97
+ "running": state.get("Running"),
98
+ "exit_code": state.get("ExitCode"),
99
+ "started_at": state.get("StartedAt"),
100
+ "finished_at": state.get("FinishedAt"),
101
+ "restart_count": attrs.get("RestartCount"),
102
+ }
103
+ )
104
+
105
+ def container_logs(self, name: str, tail: int) -> str:
106
+ if not 1 <= tail <= self.settings.max_log_lines:
107
+ raise ToolError(f"tail must be between 1 and {self.settings.max_log_lines}")
108
+ container = self._get_container(name)
109
+ try:
110
+ raw_logs = container.logs(tail=tail)
111
+ except DockerException as exc:
112
+ raise self._map_docker_error(exc) from exc
113
+ if isinstance(raw_logs, bytes):
114
+ logs = raw_logs.decode("utf-8", "replace")
115
+ else:
116
+ logs = str(raw_logs)
117
+ return limit(redact(logs), self.settings.max_output_chars)
118
+
119
+ def inspect_container(self, name: str) -> dict[str, Any]:
120
+ container = self._get_container(name)
121
+ try:
122
+ container.reload()
123
+ except DockerException as exc:
124
+ raise self._map_docker_error(exc) from exc
125
+
126
+ attrs = getattr(container, "attrs", {})
127
+ state = attrs.get("State") or {}
128
+ config = attrs.get("Config") or {}
129
+ return self._shape(
130
+ {
131
+ "name": self._container_name(container),
132
+ "id": getattr(container, "short_id", None),
133
+ "image": config.get("Image") or self._image_name(container),
134
+ "created": attrs.get("Created"),
135
+ "state": {
136
+ "status": state.get("Status"),
137
+ "running": state.get("Running"),
138
+ "exit_code": state.get("ExitCode"),
139
+ "started_at": state.get("StartedAt"),
140
+ "finished_at": state.get("FinishedAt"),
141
+ },
142
+ "ports": (attrs.get("NetworkSettings") or {}).get("Ports") or {},
143
+ "mounts": [
144
+ {
145
+ "type": mount.get("Type"),
146
+ "destination": mount.get("Destination"),
147
+ "read_only": not mount.get("RW", True),
148
+ }
149
+ for mount in attrs.get("Mounts", [])
150
+ if isinstance(mount, dict)
151
+ ],
152
+ }
153
+ )
154
+
155
+ def prepare_container_restart(self, name: str) -> dict[str, Any]:
156
+ container = self._get_write_container(name)
157
+ attrs = getattr(container, "attrs", {})
158
+ self._deny_production_container(name, attrs)
159
+ state = attrs.get("State") or {}
160
+ return {
161
+ "name": self._container_name(container),
162
+ "status": state.get("Status", getattr(container, "status", None)),
163
+ }
164
+
165
+ def restart_container(self, name: str) -> dict[str, Any]:
166
+ container = self._get_write_container(name)
167
+ self._deny_production_container(name, getattr(container, "attrs", {}))
168
+ try:
169
+ container.restart(timeout=10)
170
+ except DockerException as exc:
171
+ raise self._map_docker_error(exc) from exc
172
+ return {"name": self._container_name(container), "status": "restart_requested"}
173
+
174
+ def _get_container(self, name: str) -> Any:
175
+ self._require_allowed(name)
176
+ try:
177
+ return self._get_client().containers.get(name)
178
+ except NotFound as exc:
179
+ raise ToolError(f"Container '{name}' was not found") from exc
180
+ except DockerException as exc:
181
+ raise self._map_docker_error(exc) from exc
182
+
183
+ def _get_write_container(self, name: str) -> Any:
184
+ allowed = {item.casefold() for item in self.settings.allowed_docker_containers}
185
+ if not allowed:
186
+ raise PolicyDeniedError(
187
+ "Docker writes require an explicit container allowlist"
188
+ )
189
+ if not name or name.casefold() not in allowed:
190
+ raise PolicyDeniedError("Container is not on the configured allowlist")
191
+ container = self._get_container(name)
192
+ try:
193
+ container.reload()
194
+ except DockerException as exc:
195
+ raise self._map_docker_error(exc) from exc
196
+ return container
197
+
198
+ @staticmethod
199
+ def _deny_production_container(name: str, attrs: dict[str, Any]) -> None:
200
+ production_marker = re.compile(
201
+ r"(^|[-_.])(prod|production)([-_.]|$)", re.IGNORECASE
202
+ )
203
+ labels = (attrs.get("Config") or {}).get("Labels") or {}
204
+ environment = " ".join(
205
+ str(value)
206
+ for key, value in labels.items()
207
+ if re.search(
208
+ r"(?:^|[._-])(environment|env|stage|tier)(?:$|[._-])",
209
+ key,
210
+ re.IGNORECASE,
211
+ )
212
+ )
213
+ if production_marker.search(name) or production_marker.search(environment):
214
+ raise PolicyDeniedError(
215
+ "Docker write operations are blocked for production containers"
216
+ )
217
+
218
+ def _require_allowed(self, name: str) -> None:
219
+ if not name or not name.strip():
220
+ raise ToolError("A container name or ID is required")
221
+ allowed = {item.casefold() for item in self.settings.allowed_docker_containers}
222
+ if allowed and name.casefold() not in allowed:
223
+ raise PolicyDeniedError("Container is not on the configured allowlist")
224
+
225
+ @staticmethod
226
+ def _validate_limit(limit_count: int) -> None:
227
+ if not 1 <= limit_count <= _MAX_RESULTS:
228
+ raise ToolError(f"limit must be between 1 and {_MAX_RESULTS}")
229
+
230
+ @staticmethod
231
+ def _container_name(container: Any) -> str:
232
+ names = getattr(container, "attrs", {}).get("Name")
233
+ return str(names or getattr(container, "name", "")).lstrip("/")
234
+
235
+ @staticmethod
236
+ def _image_name(container: Any) -> str | None:
237
+ image = getattr(container, "image", None)
238
+ tags = getattr(image, "tags", None)
239
+ if tags:
240
+ return redact(str(tags[0]))
241
+ return getattr(image, "short_id", None)
242
+
243
+ @staticmethod
244
+ def _shape(data: dict[str, Any]) -> dict[str, Any]:
245
+ return {
246
+ key: redact(value) if isinstance(value, str) else value
247
+ for key, value in data.items()
248
+ }
249
+
250
+ @staticmethod
251
+ def _map_docker_error(error: DockerException) -> ToolError:
252
+ if isinstance(error, NotFound):
253
+ return ToolError("Docker resource was not found")
254
+ if isinstance(error, APIError) and getattr(error, "status_code", None) in {
255
+ 401,
256
+ 403,
257
+ }:
258
+ return ToolError("Docker denied the request; check daemon permissions")
259
+ return ToolError("Docker is unavailable or returned an error")