workspaces-euc-mcp-server 0.1.1__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.
- workspaces_euc_mcp_server/__init__.py +6 -0
- workspaces_euc_mcp_server/clients.py +101 -0
- workspaces_euc_mcp_server/consts.py +154 -0
- workspaces_euc_mcp_server/models.py +333 -0
- workspaces_euc_mcp_server/server.py +129 -0
- workspaces_euc_mcp_server/tools/__init__.py +4 -0
- workspaces_euc_mcp_server/tools/_common.py +87 -0
- workspaces_euc_mcp_server/tools/cost.py +314 -0
- workspaces_euc_mcp_server/tools/destructive.py +307 -0
- workspaces_euc_mcp_server/tools/diagnostics.py +799 -0
- workspaces_euc_mcp_server/tools/inventory.py +158 -0
- workspaces_euc_mcp_server/tools/lifecycle.py +564 -0
- workspaces_euc_mcp_server/tools/performance.py +620 -0
- workspaces_euc_mcp_server/tools/pricing.py +152 -0
- workspaces_euc_mcp_server/tools/reporting.py +529 -0
- workspaces_euc_mcp_server/tools/secure_browser.py +190 -0
- workspaces_euc_mcp_server-0.1.1.dist-info/METADATA +270 -0
- workspaces_euc_mcp_server-0.1.1.dist-info/RECORD +21 -0
- workspaces_euc_mcp_server-0.1.1.dist-info/WHEEL +4 -0
- workspaces_euc_mcp_server-0.1.1.dist-info/entry_points.txt +2 -0
- workspaces_euc_mcp_server-0.1.1.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
# Copyright bengroeneveldsg. Licensed under the Apache License, Version 2.0 (the "License").
|
|
2
|
+
# You may not use this file except in compliance with the License.
|
|
3
|
+
# A copy of the License is located at http://www.apache.org/licenses/LICENSE-2.0
|
|
4
|
+
"""Destructive WorkSpaces Personal operations — Phase 3, IAM Tier 3.
|
|
5
|
+
|
|
6
|
+
These data-impacting / irreversible operations are gated more strictly than the Phase 2 lifecycle
|
|
7
|
+
tools. They are registered ONLY when the server is launched with both ``--enable-writes`` and
|
|
8
|
+
``--enable-destructive``, and every execution requires, in addition to ``confirm=true``:
|
|
9
|
+
|
|
10
|
+
* an exact **typed acknowledgement** phrase (e.g. ``acknowledge="TERMINATE"``), and
|
|
11
|
+
* staying within the ``--max-bulk-targets`` blast-radius cap.
|
|
12
|
+
|
|
13
|
+
Default behaviour is still a dry-run that changes nothing.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from .. import consts
|
|
21
|
+
from ..clients import ClientFactory
|
|
22
|
+
from ..models import ServiceError, TargetResult, WriteOutcome
|
|
23
|
+
from ._common import try_call, writes
|
|
24
|
+
|
|
25
|
+
# action -> (boto3 method, per-request key, required acknowledgement phrase, impact note)
|
|
26
|
+
_BATCH_DESTRUCTIVE = {
|
|
27
|
+
"terminate": (
|
|
28
|
+
"terminate_workspaces",
|
|
29
|
+
"TerminateWorkspaceRequests",
|
|
30
|
+
"TERMINATE",
|
|
31
|
+
"PERMANENT and IRREVERSIBLE: the WorkSpace(s) and their data are deleted.",
|
|
32
|
+
),
|
|
33
|
+
"rebuild": (
|
|
34
|
+
"rebuild_workspaces",
|
|
35
|
+
"RebuildWorkspaceRequests",
|
|
36
|
+
"REBUILD",
|
|
37
|
+
"Disruptive: the root volume is reset to the bundle and the user volume is restored from "
|
|
38
|
+
"the last snapshot; data since the last snapshot is lost.",
|
|
39
|
+
),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _dry_run(action: str, ids: list[str], max_bulk: int, detail: str, impact: str) -> WriteOutcome:
|
|
44
|
+
return WriteOutcome(
|
|
45
|
+
action=action,
|
|
46
|
+
dry_run=True,
|
|
47
|
+
confirmed=False,
|
|
48
|
+
requested_targets=ids,
|
|
49
|
+
max_bulk_targets=max_bulk,
|
|
50
|
+
blast_radius_ok=len(ids) <= max_bulk,
|
|
51
|
+
plan=f"Would {detail.lower()}",
|
|
52
|
+
results=[TargetResult(target_id=i, status="skipped", message="dry run") for i in ids],
|
|
53
|
+
notes=[
|
|
54
|
+
impact,
|
|
55
|
+
"Dry run — nothing was changed. Re-run with confirm=true and the exact acknowledge "
|
|
56
|
+
"phrase to execute.",
|
|
57
|
+
],
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _refuse(
|
|
62
|
+
action: str, ids: list[str], max_bulk: int, plan: str, *, acknowledgement: str | None = None
|
|
63
|
+
) -> WriteOutcome:
|
|
64
|
+
return WriteOutcome(
|
|
65
|
+
action=action,
|
|
66
|
+
dry_run=False,
|
|
67
|
+
confirmed=True,
|
|
68
|
+
requested_targets=ids,
|
|
69
|
+
max_bulk_targets=max_bulk,
|
|
70
|
+
blast_radius_ok=acknowledgement is not None or len(ids) <= max_bulk,
|
|
71
|
+
plan=plan,
|
|
72
|
+
acknowledgement_required=acknowledgement,
|
|
73
|
+
notes=["No changes were made."],
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _execute_outcome(
|
|
78
|
+
action: str,
|
|
79
|
+
ids: list[str],
|
|
80
|
+
max_bulk: int,
|
|
81
|
+
detail: str,
|
|
82
|
+
errors: list[ServiceError],
|
|
83
|
+
failed: dict[str, dict],
|
|
84
|
+
) -> WriteOutcome:
|
|
85
|
+
results = [
|
|
86
|
+
TargetResult(
|
|
87
|
+
target_id=i,
|
|
88
|
+
status="error" if i in failed else "ok",
|
|
89
|
+
message=failed.get(i, {}).get("ErrorMessage") if i in failed else None,
|
|
90
|
+
)
|
|
91
|
+
for i in ids
|
|
92
|
+
]
|
|
93
|
+
return WriteOutcome(
|
|
94
|
+
action=action,
|
|
95
|
+
dry_run=False,
|
|
96
|
+
confirmed=True,
|
|
97
|
+
requested_targets=ids,
|
|
98
|
+
max_bulk_targets=max_bulk,
|
|
99
|
+
blast_radius_ok=True,
|
|
100
|
+
plan=f"{detail} (executed).",
|
|
101
|
+
results=results,
|
|
102
|
+
errors=errors,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def batch_destructive_core(
|
|
107
|
+
factory: ClientFactory,
|
|
108
|
+
region: str | None,
|
|
109
|
+
action: str,
|
|
110
|
+
workspace_ids: list[str],
|
|
111
|
+
confirm: bool,
|
|
112
|
+
acknowledge: str,
|
|
113
|
+
max_bulk_targets: int,
|
|
114
|
+
) -> WriteOutcome:
|
|
115
|
+
method_name, request_key, required_phrase, impact = _BATCH_DESTRUCTIVE[action]
|
|
116
|
+
detail = f"{action.capitalize()} {len(workspace_ids)} WorkSpace(s): {', '.join(workspace_ids)}"
|
|
117
|
+
|
|
118
|
+
if not workspace_ids:
|
|
119
|
+
return WriteOutcome(
|
|
120
|
+
action=action,
|
|
121
|
+
dry_run=not confirm,
|
|
122
|
+
confirmed=confirm,
|
|
123
|
+
requested_targets=[],
|
|
124
|
+
max_bulk_targets=max_bulk_targets,
|
|
125
|
+
blast_radius_ok=True,
|
|
126
|
+
plan="No target WorkSpaces were provided.",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
if not confirm:
|
|
130
|
+
return _dry_run(action, workspace_ids, max_bulk_targets, detail, impact)
|
|
131
|
+
|
|
132
|
+
if len(workspace_ids) > max_bulk_targets:
|
|
133
|
+
return _refuse(
|
|
134
|
+
action,
|
|
135
|
+
workspace_ids,
|
|
136
|
+
max_bulk_targets,
|
|
137
|
+
plan=(
|
|
138
|
+
f"Refused: {len(workspace_ids)} targets exceed the blast-radius cap of "
|
|
139
|
+
f"{max_bulk_targets}."
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
if acknowledge.strip() != required_phrase:
|
|
144
|
+
return _refuse(
|
|
145
|
+
action,
|
|
146
|
+
workspace_ids,
|
|
147
|
+
max_bulk_targets,
|
|
148
|
+
plan=(
|
|
149
|
+
f"Refused: this {action} is destructive and requires the exact acknowledgement "
|
|
150
|
+
f"phrase. {impact}"
|
|
151
|
+
),
|
|
152
|
+
acknowledgement=required_phrase,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
errors: list[ServiceError] = []
|
|
156
|
+
client = factory.client(consts.WORKSPACES_API, region=region)
|
|
157
|
+
requests = [{"WorkspaceId": wid} for wid in workspace_ids]
|
|
158
|
+
|
|
159
|
+
def execute() -> Any:
|
|
160
|
+
return getattr(client, method_name)(**{request_key: requests})
|
|
161
|
+
|
|
162
|
+
response = try_call(
|
|
163
|
+
errors, consts.PRODUCT_WORKSPACES_PERSONAL, method_name, execute, default={}
|
|
164
|
+
)
|
|
165
|
+
failed = {fr.get("WorkspaceId"): fr for fr in (response or {}).get("FailedRequests", [])}
|
|
166
|
+
return _execute_outcome(action, workspace_ids, max_bulk_targets, detail, errors, failed)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def restore_workspace_core(
|
|
170
|
+
factory: ClientFactory,
|
|
171
|
+
region: str | None,
|
|
172
|
+
workspace_id: str,
|
|
173
|
+
confirm: bool,
|
|
174
|
+
acknowledge: str,
|
|
175
|
+
max_bulk_targets: int,
|
|
176
|
+
) -> WriteOutcome:
|
|
177
|
+
action = "restore"
|
|
178
|
+
required_phrase = "RESTORE"
|
|
179
|
+
impact = "Disruptive: the WorkSpace is restored from its last snapshot; unsynced data is lost."
|
|
180
|
+
detail = f"Restore WorkSpace {workspace_id}"
|
|
181
|
+
ids = [workspace_id]
|
|
182
|
+
|
|
183
|
+
if not confirm:
|
|
184
|
+
return _dry_run(action, ids, max_bulk_targets, detail, impact)
|
|
185
|
+
|
|
186
|
+
if acknowledge.strip() != required_phrase:
|
|
187
|
+
return _refuse(
|
|
188
|
+
action,
|
|
189
|
+
ids,
|
|
190
|
+
max_bulk_targets,
|
|
191
|
+
plan=f"Refused: restore is destructive and requires the exact acknowledgement phrase. "
|
|
192
|
+
f"{impact}",
|
|
193
|
+
acknowledgement=required_phrase,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
errors: list[ServiceError] = []
|
|
197
|
+
client = factory.client(consts.WORKSPACES_API, region=region)
|
|
198
|
+
try_call(
|
|
199
|
+
errors,
|
|
200
|
+
consts.PRODUCT_WORKSPACES_PERSONAL,
|
|
201
|
+
"RestoreWorkspace",
|
|
202
|
+
lambda: client.restore_workspace(WorkspaceId=workspace_id),
|
|
203
|
+
default={},
|
|
204
|
+
)
|
|
205
|
+
status = "error" if errors else "ok"
|
|
206
|
+
return WriteOutcome(
|
|
207
|
+
action=action,
|
|
208
|
+
dry_run=False,
|
|
209
|
+
confirmed=True,
|
|
210
|
+
requested_targets=ids,
|
|
211
|
+
max_bulk_targets=max_bulk_targets,
|
|
212
|
+
blast_radius_ok=True,
|
|
213
|
+
plan=f"{detail} (executed)." if not errors else f"{detail} (failed).",
|
|
214
|
+
results=[
|
|
215
|
+
TargetResult(
|
|
216
|
+
target_id=workspace_id,
|
|
217
|
+
status=status,
|
|
218
|
+
message=errors[0].message if errors else None,
|
|
219
|
+
)
|
|
220
|
+
],
|
|
221
|
+
errors=errors,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def register(mcp: Any, factory: ClientFactory, *, max_bulk_targets: int) -> None:
|
|
226
|
+
"""Register destructive tools. Only call this when destructive ops are enabled."""
|
|
227
|
+
|
|
228
|
+
async def terminate_workspaces(
|
|
229
|
+
workspace_ids: list[str],
|
|
230
|
+
confirm: bool = False,
|
|
231
|
+
acknowledge: str = "",
|
|
232
|
+
region: str | None = None,
|
|
233
|
+
) -> dict[str, Any]:
|
|
234
|
+
"""Permanently terminate (delete) WorkSpaces Personal desktops. IRREVERSIBLE.
|
|
235
|
+
|
|
236
|
+
Dry-run by default. To execute you must pass confirm=true AND acknowledge="TERMINATE", and
|
|
237
|
+
stay within the blast-radius cap. Deleted WorkSpaces and their data cannot be recovered.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
workspace_ids: WorkSpace IDs to terminate.
|
|
241
|
+
confirm: Set true to execute (still requires acknowledge).
|
|
242
|
+
acknowledge: Must be exactly "TERMINATE" to proceed.
|
|
243
|
+
region: AWS region. Defaults to the server's configured region.
|
|
244
|
+
"""
|
|
245
|
+
outcome = batch_destructive_core(
|
|
246
|
+
factory,
|
|
247
|
+
region or factory.region,
|
|
248
|
+
"terminate",
|
|
249
|
+
workspace_ids,
|
|
250
|
+
confirm,
|
|
251
|
+
acknowledge,
|
|
252
|
+
max_bulk_targets,
|
|
253
|
+
)
|
|
254
|
+
return outcome.model_dump()
|
|
255
|
+
|
|
256
|
+
async def rebuild_workspaces(
|
|
257
|
+
workspace_ids: list[str],
|
|
258
|
+
confirm: bool = False,
|
|
259
|
+
acknowledge: str = "",
|
|
260
|
+
region: str | None = None,
|
|
261
|
+
) -> dict[str, Any]:
|
|
262
|
+
"""Rebuild WorkSpaces Personal desktops (resets root volume; user volume from snapshot).
|
|
263
|
+
|
|
264
|
+
Dry-run by default. To execute you must pass confirm=true AND acknowledge="REBUILD". Data
|
|
265
|
+
written since the last snapshot is lost.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
workspace_ids: WorkSpace IDs to rebuild.
|
|
269
|
+
confirm: Set true to execute (still requires acknowledge).
|
|
270
|
+
acknowledge: Must be exactly "REBUILD" to proceed.
|
|
271
|
+
region: AWS region. Defaults to the server's configured region.
|
|
272
|
+
"""
|
|
273
|
+
outcome = batch_destructive_core(
|
|
274
|
+
factory,
|
|
275
|
+
region or factory.region,
|
|
276
|
+
"rebuild",
|
|
277
|
+
workspace_ids,
|
|
278
|
+
confirm,
|
|
279
|
+
acknowledge,
|
|
280
|
+
max_bulk_targets,
|
|
281
|
+
)
|
|
282
|
+
return outcome.model_dump()
|
|
283
|
+
|
|
284
|
+
async def restore_workspace(
|
|
285
|
+
workspace_id: str,
|
|
286
|
+
confirm: bool = False,
|
|
287
|
+
acknowledge: str = "",
|
|
288
|
+
region: str | None = None,
|
|
289
|
+
) -> dict[str, Any]:
|
|
290
|
+
"""Restore a WorkSpaces Personal desktop from its last snapshot.
|
|
291
|
+
|
|
292
|
+
Dry-run by default. To execute you must pass confirm=true AND acknowledge="RESTORE".
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
workspace_id: The WorkSpace ID to restore.
|
|
296
|
+
confirm: Set true to execute (still requires acknowledge).
|
|
297
|
+
acknowledge: Must be exactly "RESTORE" to proceed.
|
|
298
|
+
region: AWS region. Defaults to the server's configured region.
|
|
299
|
+
"""
|
|
300
|
+
outcome = restore_workspace_core(
|
|
301
|
+
factory, region or factory.region, workspace_id, confirm, acknowledge, max_bulk_targets
|
|
302
|
+
)
|
|
303
|
+
return outcome.model_dump()
|
|
304
|
+
|
|
305
|
+
mcp.add_tool(terminate_workspaces, annotations=writes("Terminate WorkSpaces", destructive=True))
|
|
306
|
+
mcp.add_tool(rebuild_workspaces, annotations=writes("Rebuild WorkSpaces", destructive=True))
|
|
307
|
+
mcp.add_tool(restore_workspace, annotations=writes("Restore WorkSpace", destructive=True))
|