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.
- devops_assistant/__init__.py +5 -0
- devops_assistant/actions.py +150 -0
- devops_assistant/app.py +96 -0
- devops_assistant/audit.py +129 -0
- devops_assistant/config.py +78 -0
- devops_assistant/errors.py +21 -0
- devops_assistant/prompts.py +28 -0
- devops_assistant/resources.py +96 -0
- devops_assistant/security.py +26 -0
- devops_assistant/services/__init__.py +1 -0
- devops_assistant/services/docker_client.py +259 -0
- devops_assistant/services/github_client.py +587 -0
- devops_assistant/tools/__init__.py +1 -0
- devops_assistant/tools/docker_read.py +57 -0
- devops_assistant/tools/github_read.py +102 -0
- devops_assistant/tools/write.py +161 -0
- mcp_devops_assistant-0.1.0.dist-info/METADATA +15 -0
- mcp_devops_assistant-0.1.0.dist-info/RECORD +21 -0
- mcp_devops_assistant-0.1.0.dist-info/WHEEL +5 -0
- mcp_devops_assistant-0.1.0.dist-info/entry_points.txt +3 -0
- mcp_devops_assistant-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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")
|