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,620 @@
|
|
|
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
|
+
"""WorkSpaces Personal performance metrics & bundle right-sizing (read-only, IAM Tier 0).
|
|
5
|
+
|
|
6
|
+
The AWS/WorkSpaces namespace publishes per-desktop resource metrics (CPUUsage, MemoryUsage,
|
|
7
|
+
GPUUsage, disk usage, latency, uptime, …) natively, keyed by WorkspaceId — no CloudWatch agent
|
|
8
|
+
required. ``get_workspace_performance`` surfaces them; ``recommend_bundle_rightsizing`` uses CPU and
|
|
9
|
+
memory headroom to suggest a smaller/larger compute type.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from datetime import UTC, datetime, timedelta
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .. import consts
|
|
18
|
+
from ..clients import ClientFactory
|
|
19
|
+
from ..models import (
|
|
20
|
+
FleetMetricSeries,
|
|
21
|
+
FleetUsage,
|
|
22
|
+
MetricStat,
|
|
23
|
+
PerformanceReport,
|
|
24
|
+
Recommendation,
|
|
25
|
+
RecommendationReport,
|
|
26
|
+
ServiceError,
|
|
27
|
+
UsageHistory,
|
|
28
|
+
UsagePoint,
|
|
29
|
+
WorkspacePerformance,
|
|
30
|
+
)
|
|
31
|
+
from . import pricing
|
|
32
|
+
from ._common import paginate, read_only, try_call
|
|
33
|
+
|
|
34
|
+
# Right-sizing thresholds on window peak (%). Conservative: only suggest a downsize when there is
|
|
35
|
+
# clear headroom, and an upsize when the desktop is genuinely pressured.
|
|
36
|
+
_DOWNSIZE_PEAK_CPU = 30.0
|
|
37
|
+
_DOWNSIZE_PEAK_MEM = 40.0
|
|
38
|
+
_UPSIZE_PEAK_CPU = 85.0
|
|
39
|
+
_UPSIZE_PEAK_MEM = 85.0
|
|
40
|
+
_MIN_DATAPOINTS = 6 # need at least this many hourly points to judge
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _fetch_metrics(
|
|
44
|
+
cloudwatch: Any,
|
|
45
|
+
workspace_id: str,
|
|
46
|
+
metric_names: list[tuple[str, str]],
|
|
47
|
+
lookback_hours: int,
|
|
48
|
+
period: int = 300,
|
|
49
|
+
) -> dict[str, MetricStat]:
|
|
50
|
+
"""Fetch Average + Maximum series for each metric and reduce to latest/average/peak."""
|
|
51
|
+
end = datetime.now(UTC)
|
|
52
|
+
start = end - timedelta(hours=lookback_hours)
|
|
53
|
+
stat_suffix = {"Average": "avg", "Maximum": "max"}
|
|
54
|
+
queries: list[dict[str, Any]] = []
|
|
55
|
+
for i, (name, _unit) in enumerate(metric_names):
|
|
56
|
+
for stat in ("Average", "Maximum"):
|
|
57
|
+
queries.append(
|
|
58
|
+
{
|
|
59
|
+
"Id": f"m{i}_{stat_suffix[stat]}",
|
|
60
|
+
"MetricStat": {
|
|
61
|
+
"Metric": {
|
|
62
|
+
"Namespace": "AWS/WorkSpaces",
|
|
63
|
+
"MetricName": name,
|
|
64
|
+
"Dimensions": [{"Name": "WorkspaceId", "Value": workspace_id}],
|
|
65
|
+
},
|
|
66
|
+
"Period": period,
|
|
67
|
+
"Stat": stat,
|
|
68
|
+
},
|
|
69
|
+
"ReturnData": True,
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
response = cloudwatch.get_metric_data(
|
|
73
|
+
MetricDataQueries=queries,
|
|
74
|
+
StartTime=start,
|
|
75
|
+
EndTime=end,
|
|
76
|
+
ScanBy="TimestampAscending",
|
|
77
|
+
)
|
|
78
|
+
by_id = {r.get("Id"): r.get("Values", []) for r in response.get("MetricDataResults", [])}
|
|
79
|
+
|
|
80
|
+
out: dict[str, MetricStat] = {}
|
|
81
|
+
for i, (name, unit) in enumerate(metric_names):
|
|
82
|
+
avg_vals = by_id.get(f"m{i}_avg", [])
|
|
83
|
+
max_vals = by_id.get(f"m{i}_max", [])
|
|
84
|
+
if not avg_vals and not max_vals:
|
|
85
|
+
continue
|
|
86
|
+
out[name] = MetricStat(
|
|
87
|
+
latest=avg_vals[-1] if avg_vals else None,
|
|
88
|
+
average=(sum(avg_vals) / len(avg_vals)) if avg_vals else None,
|
|
89
|
+
peak=max(max_vals) if max_vals else None,
|
|
90
|
+
unit=unit,
|
|
91
|
+
)
|
|
92
|
+
return out
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def get_workspace_performance_core(
|
|
96
|
+
factory: ClientFactory,
|
|
97
|
+
workspace_ids: list[str],
|
|
98
|
+
region: str | None,
|
|
99
|
+
lookback_hours: int = 3,
|
|
100
|
+
) -> PerformanceReport:
|
|
101
|
+
errors: list[ServiceError] = []
|
|
102
|
+
cloudwatch = factory.client(consts.CLOUDWATCH_API, region=region)
|
|
103
|
+
results: list[WorkspacePerformance] = []
|
|
104
|
+
for wid in workspace_ids:
|
|
105
|
+
metrics = try_call(
|
|
106
|
+
errors,
|
|
107
|
+
"Amazon CloudWatch",
|
|
108
|
+
"GetMetricData",
|
|
109
|
+
lambda wid=wid: _fetch_metrics(
|
|
110
|
+
cloudwatch, wid, consts.WORKSPACES_PERFORMANCE_METRICS, lookback_hours
|
|
111
|
+
),
|
|
112
|
+
default={},
|
|
113
|
+
)
|
|
114
|
+
results.append(
|
|
115
|
+
WorkspacePerformance(
|
|
116
|
+
workspace_id=wid,
|
|
117
|
+
lookback_hours=lookback_hours,
|
|
118
|
+
metrics=metrics or {},
|
|
119
|
+
note=None if metrics else "No performance datapoints (desktop may be stopped).",
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
return PerformanceReport(
|
|
123
|
+
region=region,
|
|
124
|
+
lookback_hours=lookback_hours,
|
|
125
|
+
workspaces=results,
|
|
126
|
+
errors=errors,
|
|
127
|
+
notes=[
|
|
128
|
+
"Metrics come from the native AWS/WorkSpaces namespace; CPUUsage/MemoryUsage are "
|
|
129
|
+
"percentages. Stopped desktops report no datapoints."
|
|
130
|
+
],
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _rightsizing_recommendation(
|
|
135
|
+
workspace_id: str,
|
|
136
|
+
compute_type: str | None,
|
|
137
|
+
cpu: MetricStat | None,
|
|
138
|
+
mem: MetricStat | None,
|
|
139
|
+
lookback_days: int,
|
|
140
|
+
) -> Recommendation | None:
|
|
141
|
+
if not compute_type:
|
|
142
|
+
return None
|
|
143
|
+
order = consts.WORKSPACES_COMPUTE_ORDER
|
|
144
|
+
if compute_type not in order:
|
|
145
|
+
return None # graphics / unknown family — not safe to auto-suggest
|
|
146
|
+
idx = order.index(compute_type)
|
|
147
|
+
if cpu is None or mem is None or cpu.peak is None or mem.peak is None:
|
|
148
|
+
return None # insufficient data
|
|
149
|
+
cpu_peak = cpu.peak
|
|
150
|
+
mem_peak = mem.peak
|
|
151
|
+
|
|
152
|
+
def stats() -> str:
|
|
153
|
+
return (
|
|
154
|
+
f"over {lookback_days}d peak CPU {cpu_peak:.0f}% / peak memory {mem_peak:.0f}% "
|
|
155
|
+
f"(avg CPU {cpu.average:.0f}% / mem {mem.average:.0f}%)"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
if cpu_peak < _DOWNSIZE_PEAK_CPU and mem_peak < _DOWNSIZE_PEAK_MEM and idx > 0:
|
|
159
|
+
return Recommendation(
|
|
160
|
+
target_id=workspace_id,
|
|
161
|
+
kind="bundle_rightsizing",
|
|
162
|
+
current=compute_type,
|
|
163
|
+
recommended=order[idx - 1],
|
|
164
|
+
rationale=f"Consistently low utilization ({stats()}); a smaller compute type should "
|
|
165
|
+
"cope and cost less.",
|
|
166
|
+
confidence="high" if cpu_peak < _DOWNSIZE_PEAK_CPU / 2 else "medium",
|
|
167
|
+
)
|
|
168
|
+
if (cpu_peak > _UPSIZE_PEAK_CPU or mem_peak > _UPSIZE_PEAK_MEM) and idx < len(order) - 1:
|
|
169
|
+
return Recommendation(
|
|
170
|
+
target_id=workspace_id,
|
|
171
|
+
kind="bundle_rightsizing",
|
|
172
|
+
current=compute_type,
|
|
173
|
+
recommended=order[idx + 1],
|
|
174
|
+
rationale=f"Resource pressure ({stats()}); a larger compute type would improve the "
|
|
175
|
+
"user experience.",
|
|
176
|
+
confidence="high" if (cpu_peak > 95 or mem_peak > 95) else "medium",
|
|
177
|
+
)
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def recommend_bundle_rightsizing_core(
|
|
182
|
+
factory: ClientFactory, region: str | None, lookback_days: int = 7
|
|
183
|
+
) -> RecommendationReport:
|
|
184
|
+
errors: list[ServiceError] = []
|
|
185
|
+
workspaces_client = factory.client(consts.WORKSPACES_API, region=region)
|
|
186
|
+
cloudwatch = factory.client(consts.CLOUDWATCH_API, region=region)
|
|
187
|
+
|
|
188
|
+
workspaces = try_call(
|
|
189
|
+
errors,
|
|
190
|
+
consts.PRODUCT_WORKSPACES_PERSONAL,
|
|
191
|
+
"DescribeWorkspaces",
|
|
192
|
+
lambda: paginate(workspaces_client.describe_workspaces, "Workspaces"),
|
|
193
|
+
default=[],
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
lookback_hours = lookback_days * 24
|
|
197
|
+
recommendations: list[Recommendation] = []
|
|
198
|
+
skipped_no_data = 0
|
|
199
|
+
for ws in workspaces or []:
|
|
200
|
+
wid = ws.get("WorkspaceId", "")
|
|
201
|
+
props = ws.get("WorkspaceProperties", {})
|
|
202
|
+
compute_type = props.get("ComputeTypeName")
|
|
203
|
+
metrics = try_call(
|
|
204
|
+
errors,
|
|
205
|
+
"Amazon CloudWatch",
|
|
206
|
+
"GetMetricData",
|
|
207
|
+
lambda wid=wid: _fetch_metrics(
|
|
208
|
+
cloudwatch,
|
|
209
|
+
wid,
|
|
210
|
+
[("CPUUsage", "Percent"), ("MemoryUsage", "Percent")],
|
|
211
|
+
lookback_hours,
|
|
212
|
+
period=3600,
|
|
213
|
+
),
|
|
214
|
+
default={},
|
|
215
|
+
)
|
|
216
|
+
cpu = (metrics or {}).get("CPUUsage")
|
|
217
|
+
mem = (metrics or {}).get("MemoryUsage")
|
|
218
|
+
datapoints_ok = cpu is not None and mem is not None
|
|
219
|
+
if not datapoints_ok:
|
|
220
|
+
skipped_no_data += 1
|
|
221
|
+
continue
|
|
222
|
+
rec = _rightsizing_recommendation(wid, compute_type, cpu, mem, lookback_days)
|
|
223
|
+
if rec:
|
|
224
|
+
# Best-effort $ estimate for AlwaysOn desktops: monthly compute-tier difference.
|
|
225
|
+
if props.get("RunningMode") == "ALWAYS_ON":
|
|
226
|
+
cur = pricing.get_workspace_prices(
|
|
227
|
+
factory,
|
|
228
|
+
region,
|
|
229
|
+
props.get("OperatingSystemName"),
|
|
230
|
+
rec.current,
|
|
231
|
+
props.get("RootVolumeSizeGib"),
|
|
232
|
+
props.get("UserVolumeSizeGib"),
|
|
233
|
+
)
|
|
234
|
+
new = pricing.get_workspace_prices(
|
|
235
|
+
factory,
|
|
236
|
+
region,
|
|
237
|
+
props.get("OperatingSystemName"),
|
|
238
|
+
rec.recommended,
|
|
239
|
+
props.get("RootVolumeSizeGib"),
|
|
240
|
+
props.get("UserVolumeSizeGib"),
|
|
241
|
+
)
|
|
242
|
+
if cur and new and cur.alwayson_monthly and new.alwayson_monthly:
|
|
243
|
+
diff = round(cur.alwayson_monthly - new.alwayson_monthly, 2)
|
|
244
|
+
rec.estimated_monthly_savings_usd = diff if diff > 0 else None
|
|
245
|
+
recommendations.append(rec)
|
|
246
|
+
|
|
247
|
+
notes = [
|
|
248
|
+
"Based on native AWS/WorkSpaces CPUUsage/MemoryUsage (window peak). "
|
|
249
|
+
"estimated_monthly_savings_usd is a best-effort AlwaysOn monthly compute-tier difference "
|
|
250
|
+
"(Price List, Included license); null for AutoStop desktops or unmatched prices.",
|
|
251
|
+
"Graphics compute families are excluded from automatic suggestions.",
|
|
252
|
+
]
|
|
253
|
+
if skipped_no_data:
|
|
254
|
+
notes.append(
|
|
255
|
+
f"{skipped_no_data} desktop(s) skipped for insufficient metrics (likely stopped "
|
|
256
|
+
"for the whole window)."
|
|
257
|
+
)
|
|
258
|
+
return RecommendationReport(
|
|
259
|
+
region=region,
|
|
260
|
+
lookback_days=lookback_days,
|
|
261
|
+
recommendations=recommendations,
|
|
262
|
+
errors=errors,
|
|
263
|
+
notes=notes,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _fetch_metric_series(
|
|
268
|
+
cloudwatch: Any,
|
|
269
|
+
namespace: str,
|
|
270
|
+
dimension_name: str,
|
|
271
|
+
dimension_value: str,
|
|
272
|
+
metric_specs: list[tuple[str, str]],
|
|
273
|
+
lookback_days: int,
|
|
274
|
+
period_hours: int,
|
|
275
|
+
) -> dict[str, FleetMetricSeries]:
|
|
276
|
+
"""Fetch Average + Maximum time-series for each metric and reduce to aggregates + series."""
|
|
277
|
+
end = datetime.now(UTC)
|
|
278
|
+
start = end - timedelta(days=lookback_days)
|
|
279
|
+
period = period_hours * 3600
|
|
280
|
+
stat_suffix = {"Average": "avg", "Maximum": "max"}
|
|
281
|
+
queries: list[dict[str, Any]] = []
|
|
282
|
+
for i, (name, _unit) in enumerate(metric_specs):
|
|
283
|
+
for stat in ("Average", "Maximum"):
|
|
284
|
+
queries.append(
|
|
285
|
+
{
|
|
286
|
+
"Id": f"s{i}_{stat_suffix[stat]}",
|
|
287
|
+
"MetricStat": {
|
|
288
|
+
"Metric": {
|
|
289
|
+
"Namespace": namespace,
|
|
290
|
+
"MetricName": name,
|
|
291
|
+
"Dimensions": [{"Name": dimension_name, "Value": dimension_value}],
|
|
292
|
+
},
|
|
293
|
+
"Period": period,
|
|
294
|
+
"Stat": stat,
|
|
295
|
+
},
|
|
296
|
+
"ReturnData": True,
|
|
297
|
+
}
|
|
298
|
+
)
|
|
299
|
+
response = cloudwatch.get_metric_data(
|
|
300
|
+
MetricDataQueries=queries,
|
|
301
|
+
StartTime=start,
|
|
302
|
+
EndTime=end,
|
|
303
|
+
ScanBy="TimestampAscending",
|
|
304
|
+
)
|
|
305
|
+
by_id = {r.get("Id"): r for r in response.get("MetricDataResults", [])}
|
|
306
|
+
|
|
307
|
+
out: dict[str, FleetMetricSeries] = {}
|
|
308
|
+
for i, (name, unit) in enumerate(metric_specs):
|
|
309
|
+
avg = by_id.get(f"s{i}_avg", {})
|
|
310
|
+
mx = by_id.get(f"s{i}_max", {})
|
|
311
|
+
avg_vals = avg.get("Values", [])
|
|
312
|
+
avg_ts = avg.get("Timestamps", [])
|
|
313
|
+
max_vals = mx.get("Values", [])
|
|
314
|
+
if not avg_vals and not max_vals:
|
|
315
|
+
continue
|
|
316
|
+
series = [
|
|
317
|
+
UsagePoint(
|
|
318
|
+
timestamp=str(avg_ts[j]) if j < len(avg_ts) else str(j),
|
|
319
|
+
average=avg_vals[j] if j < len(avg_vals) else None,
|
|
320
|
+
peak=max_vals[j] if j < len(max_vals) else None,
|
|
321
|
+
)
|
|
322
|
+
for j in range(max(len(avg_vals), len(max_vals)))
|
|
323
|
+
]
|
|
324
|
+
out[name] = FleetMetricSeries(
|
|
325
|
+
unit=unit,
|
|
326
|
+
latest=avg_vals[-1] if avg_vals else None,
|
|
327
|
+
average=(sum(avg_vals) / len(avg_vals)) if avg_vals else None,
|
|
328
|
+
peak=max(max_vals) if max_vals else None,
|
|
329
|
+
series=series,
|
|
330
|
+
)
|
|
331
|
+
return out
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _fetch_fleet_usage(
|
|
335
|
+
cloudwatch: Any, fleet_name: str, lookback_days: int, period_hours: int
|
|
336
|
+
) -> dict[str, FleetMetricSeries]:
|
|
337
|
+
return _fetch_metric_series(
|
|
338
|
+
cloudwatch,
|
|
339
|
+
"AWS/AppStream",
|
|
340
|
+
"Fleet",
|
|
341
|
+
fleet_name,
|
|
342
|
+
consts.APPSTREAM_FLEET_METRICS,
|
|
343
|
+
lookback_days,
|
|
344
|
+
period_hours,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _summarize_fleet_usage(metrics: dict[str, FleetMetricSeries], lookback_days: int) -> str | None:
|
|
349
|
+
if not metrics:
|
|
350
|
+
return "No usage datapoints — the fleet was likely stopped for the whole window."
|
|
351
|
+
in_use = metrics.get("InUseCapacity")
|
|
352
|
+
running = metrics.get("RunningCapacity") or metrics.get("ActualCapacity")
|
|
353
|
+
util = metrics.get("CapacityUtilization")
|
|
354
|
+
if in_use and (in_use.peak or 0) == 0 and running and (running.peak or 0) > 0:
|
|
355
|
+
return (
|
|
356
|
+
f"Zero sessions in use across {lookback_days}d, yet up to {running.peak:.0f} "
|
|
357
|
+
"instance(s) were kept running — idle running capacity (cost with no usage). Consider "
|
|
358
|
+
"stopping the fleet or lowering desired capacity."
|
|
359
|
+
)
|
|
360
|
+
if in_use and in_use.peak is not None:
|
|
361
|
+
util_txt = f"; peak utilization {util.peak:.0f}%" if util and util.peak is not None else ""
|
|
362
|
+
return f"Peak {in_use.peak:.0f} instance(s) in use over {lookback_days}d{util_txt}."
|
|
363
|
+
return None
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def get_application_fleet_usage_core(
|
|
367
|
+
factory: ClientFactory,
|
|
368
|
+
fleet_name: str,
|
|
369
|
+
region: str | None,
|
|
370
|
+
lookback_days: int = 7,
|
|
371
|
+
period_hours: int = 24,
|
|
372
|
+
) -> FleetUsage:
|
|
373
|
+
errors: list[ServiceError] = []
|
|
374
|
+
cloudwatch = factory.client(consts.CLOUDWATCH_API, region=region)
|
|
375
|
+
metrics = try_call(
|
|
376
|
+
errors,
|
|
377
|
+
"Amazon CloudWatch",
|
|
378
|
+
"GetMetricData",
|
|
379
|
+
lambda: _fetch_fleet_usage(cloudwatch, fleet_name, lookback_days, period_hours),
|
|
380
|
+
default={},
|
|
381
|
+
)
|
|
382
|
+
return FleetUsage(
|
|
383
|
+
fleet_name=fleet_name,
|
|
384
|
+
lookback_days=lookback_days,
|
|
385
|
+
period_hours=period_hours,
|
|
386
|
+
metrics=metrics or {},
|
|
387
|
+
summary=_summarize_fleet_usage(metrics or {}, lookback_days),
|
|
388
|
+
errors=errors,
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _count_active_buckets(series: list[UsagePoint]) -> int:
|
|
393
|
+
return sum(1 for p in series if (p.peak or 0) >= 1)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _summarize_connection_history(metrics: dict[str, FleetMetricSeries], lookback_days: int) -> str:
|
|
397
|
+
connected = metrics.get("UserConnected")
|
|
398
|
+
if not connected or not connected.series:
|
|
399
|
+
return f"No connection datapoints in {lookback_days}d (desktop may be stopped/unused)."
|
|
400
|
+
active = _count_active_buckets(connected.series)
|
|
401
|
+
total = len(connected.series)
|
|
402
|
+
if active == 0:
|
|
403
|
+
return f"No user connections in any of the {total} buckets over {lookback_days}d (unused)."
|
|
404
|
+
failures = metrics.get("ConnectionFailure")
|
|
405
|
+
fail_txt = ""
|
|
406
|
+
if failures and (failures.peak or 0) > 0:
|
|
407
|
+
fail_txt = " Connection failures were recorded — check connectivity."
|
|
408
|
+
return f"Connected in {active} of {total} buckets over {lookback_days}d.{fail_txt}"
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def get_workspace_connection_history_core(
|
|
412
|
+
factory: ClientFactory,
|
|
413
|
+
workspace_id: str,
|
|
414
|
+
region: str | None,
|
|
415
|
+
lookback_days: int = 7,
|
|
416
|
+
period_hours: int = 24,
|
|
417
|
+
) -> UsageHistory:
|
|
418
|
+
errors: list[ServiceError] = []
|
|
419
|
+
cloudwatch = factory.client(consts.CLOUDWATCH_API, region=region)
|
|
420
|
+
metrics = try_call(
|
|
421
|
+
errors,
|
|
422
|
+
"Amazon CloudWatch",
|
|
423
|
+
"GetMetricData",
|
|
424
|
+
lambda: _fetch_metric_series(
|
|
425
|
+
cloudwatch,
|
|
426
|
+
"AWS/WorkSpaces",
|
|
427
|
+
"WorkspaceId",
|
|
428
|
+
workspace_id,
|
|
429
|
+
consts.WORKSPACES_CONNECTION_METRICS,
|
|
430
|
+
lookback_days,
|
|
431
|
+
period_hours,
|
|
432
|
+
),
|
|
433
|
+
default={},
|
|
434
|
+
)
|
|
435
|
+
return UsageHistory(
|
|
436
|
+
target_type=consts.PRODUCT_WORKSPACES_PERSONAL,
|
|
437
|
+
target_id=workspace_id,
|
|
438
|
+
lookback_days=lookback_days,
|
|
439
|
+
period_hours=period_hours,
|
|
440
|
+
metrics=metrics or {},
|
|
441
|
+
summary=_summarize_connection_history(metrics or {}, lookback_days),
|
|
442
|
+
errors=errors,
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _summarize_pool_session_history(
|
|
447
|
+
metrics: dict[str, FleetMetricSeries], lookback_days: int
|
|
448
|
+
) -> str | None:
|
|
449
|
+
if not metrics:
|
|
450
|
+
return f"No session datapoints in {lookback_days}d (pool may be stopped)."
|
|
451
|
+
active = metrics.get("ActiveUserSessionCapacity")
|
|
452
|
+
util = metrics.get("UserSessionsCapacityUtilization")
|
|
453
|
+
actual = metrics.get("ActualUserSessionCapacity")
|
|
454
|
+
if active and (active.peak or 0) == 0 and actual and (actual.peak or 0) > 0:
|
|
455
|
+
return (
|
|
456
|
+
f"Zero active sessions across {lookback_days}d, yet up to {actual.peak:.0f} session "
|
|
457
|
+
"slot(s) were kept available — idle pool capacity (cost with no usage)."
|
|
458
|
+
)
|
|
459
|
+
if active and active.peak is not None:
|
|
460
|
+
util_txt = f"; peak utilization {util.peak:.0f}%" if util and util.peak is not None else ""
|
|
461
|
+
return f"Peak {active.peak:.0f} active session(s) over {lookback_days}d{util_txt}."
|
|
462
|
+
return None
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def get_pool_session_history_core(
|
|
466
|
+
factory: ClientFactory,
|
|
467
|
+
pool_id: str,
|
|
468
|
+
region: str | None,
|
|
469
|
+
lookback_days: int = 7,
|
|
470
|
+
period_hours: int = 24,
|
|
471
|
+
) -> UsageHistory:
|
|
472
|
+
errors: list[ServiceError] = []
|
|
473
|
+
cloudwatch = factory.client(consts.CLOUDWATCH_API, region=region)
|
|
474
|
+
metrics = try_call(
|
|
475
|
+
errors,
|
|
476
|
+
"Amazon CloudWatch",
|
|
477
|
+
"GetMetricData",
|
|
478
|
+
lambda: _fetch_metric_series(
|
|
479
|
+
cloudwatch,
|
|
480
|
+
"AWS/WorkSpaces",
|
|
481
|
+
consts.WORKSPACES_POOL_DIMENSION,
|
|
482
|
+
pool_id,
|
|
483
|
+
consts.WORKSPACES_POOL_SESSION_METRICS,
|
|
484
|
+
lookback_days,
|
|
485
|
+
period_hours,
|
|
486
|
+
),
|
|
487
|
+
default={},
|
|
488
|
+
)
|
|
489
|
+
return UsageHistory(
|
|
490
|
+
target_type=consts.PRODUCT_WORKSPACES_POOLS,
|
|
491
|
+
target_id=pool_id,
|
|
492
|
+
lookback_days=lookback_days,
|
|
493
|
+
period_hours=period_hours,
|
|
494
|
+
metrics=metrics or {},
|
|
495
|
+
summary=_summarize_pool_session_history(metrics or {}, lookback_days),
|
|
496
|
+
errors=errors,
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def register(mcp: Any, factory: ClientFactory) -> None:
|
|
501
|
+
"""Register performance & right-sizing tools on the FastMCP app."""
|
|
502
|
+
|
|
503
|
+
async def get_workspace_performance(
|
|
504
|
+
workspace_ids: list[str], region: str | None = None, lookback_hours: int = 3
|
|
505
|
+
) -> dict[str, Any]:
|
|
506
|
+
"""Get native CPU/memory/disk/GPU/latency performance metrics for WorkSpaces Personal.
|
|
507
|
+
|
|
508
|
+
Reads the AWS/WorkSpaces namespace (no CloudWatch agent needed) and returns latest, window
|
|
509
|
+
average, and window peak for each metric per desktop. Stopped desktops report no data.
|
|
510
|
+
|
|
511
|
+
Args:
|
|
512
|
+
workspace_ids: One or more WorkSpace IDs.
|
|
513
|
+
region: AWS region. Defaults to the server's configured region.
|
|
514
|
+
lookback_hours: Window for the metrics (default 3).
|
|
515
|
+
"""
|
|
516
|
+
report = get_workspace_performance_core(
|
|
517
|
+
factory, workspace_ids, region or factory.region, lookback_hours
|
|
518
|
+
)
|
|
519
|
+
return report.model_dump()
|
|
520
|
+
|
|
521
|
+
async def recommend_bundle_rightsizing(
|
|
522
|
+
region: str | None = None, lookback_days: int = 7
|
|
523
|
+
) -> dict[str, Any]:
|
|
524
|
+
"""Recommend smaller/larger WorkSpace compute types from CPU & memory headroom.
|
|
525
|
+
|
|
526
|
+
Uses native AWS/WorkSpaces CPUUsage/MemoryUsage over the window: flags desktops with ample
|
|
527
|
+
headroom to downsize and pressured ones to upsize (general compute families only;
|
|
528
|
+
graphics excluded). Read-only.
|
|
529
|
+
|
|
530
|
+
Args:
|
|
531
|
+
region: AWS region. Defaults to the server's configured region.
|
|
532
|
+
lookback_days: Window for the analysis (default 7).
|
|
533
|
+
"""
|
|
534
|
+
report = recommend_bundle_rightsizing_core(factory, region or factory.region, lookback_days)
|
|
535
|
+
return report.model_dump()
|
|
536
|
+
|
|
537
|
+
async def get_application_fleet_usage(
|
|
538
|
+
fleet_name: str,
|
|
539
|
+
region: str | None = None,
|
|
540
|
+
lookback_days: int = 7,
|
|
541
|
+
period_hours: int = 24,
|
|
542
|
+
) -> dict[str, Any]:
|
|
543
|
+
"""Get a WorkSpaces Applications (formerly AppStream 2.0) fleet's usage history.
|
|
544
|
+
|
|
545
|
+
Returns the AWS/AppStream capacity time-series for the fleet (InUseCapacity,
|
|
546
|
+
CapacityUtilization, Running/Available/Actual/Desired/PendingCapacity) over the window, as
|
|
547
|
+
per-bucket points plus latest/average/peak, with a plain-language summary (e.g. flags idle
|
|
548
|
+
running capacity). For a stack, resolve its fleet first (see generate_inventory_report).
|
|
549
|
+
Read-only.
|
|
550
|
+
|
|
551
|
+
Args:
|
|
552
|
+
fleet_name: The fleet name (the fleet behind the stack, not the stack name).
|
|
553
|
+
region: AWS region. Defaults to the server's configured region.
|
|
554
|
+
lookback_days: Window length (default 7).
|
|
555
|
+
period_hours: Time-bucket size in hours (default 24 = daily; use 1 for hourly).
|
|
556
|
+
"""
|
|
557
|
+
usage = get_application_fleet_usage_core(
|
|
558
|
+
factory, fleet_name, region or factory.region, lookback_days, period_hours
|
|
559
|
+
)
|
|
560
|
+
return usage.model_dump()
|
|
561
|
+
|
|
562
|
+
async def get_workspace_connection_history(
|
|
563
|
+
workspace_id: str,
|
|
564
|
+
region: str | None = None,
|
|
565
|
+
lookback_days: int = 7,
|
|
566
|
+
period_hours: int = 24,
|
|
567
|
+
) -> dict[str, Any]:
|
|
568
|
+
"""Get a WorkSpaces Personal desktop's connection/session history.
|
|
569
|
+
|
|
570
|
+
Returns the AWS/WorkSpaces connection time-series for the desktop (UserConnected, and —
|
|
571
|
+
when present — ConnectionAttempt/Success/Failure, SessionLaunchTime, InSessionLatency) as
|
|
572
|
+
per-bucket points plus latest/average/peak, with a plain-language summary. Read-only.
|
|
573
|
+
|
|
574
|
+
Args:
|
|
575
|
+
workspace_id: The WorkSpace ID (ws-...).
|
|
576
|
+
region: AWS region. Defaults to the server's configured region.
|
|
577
|
+
lookback_days: Window length (default 7).
|
|
578
|
+
period_hours: Bucket size in hours (default 24 = daily; use 1 for hourly).
|
|
579
|
+
"""
|
|
580
|
+
usage = get_workspace_connection_history_core(
|
|
581
|
+
factory, workspace_id, region or factory.region, lookback_days, period_hours
|
|
582
|
+
)
|
|
583
|
+
return usage.model_dump()
|
|
584
|
+
|
|
585
|
+
async def get_pool_session_history(
|
|
586
|
+
pool_id: str,
|
|
587
|
+
region: str | None = None,
|
|
588
|
+
lookback_days: int = 7,
|
|
589
|
+
period_hours: int = 24,
|
|
590
|
+
) -> dict[str, Any]:
|
|
591
|
+
"""Get a WorkSpaces Pool's user-session history.
|
|
592
|
+
|
|
593
|
+
Returns the AWS/WorkSpaces session-capacity time-series for the pool
|
|
594
|
+
(Active/Actual/Available/Desired/PendingUserSessionCapacity and
|
|
595
|
+
UserSessionsCapacityUtilization) as per-bucket points plus latest/average/peak, with a
|
|
596
|
+
plain-language summary that flags idle pool capacity. Read-only.
|
|
597
|
+
|
|
598
|
+
Args:
|
|
599
|
+
pool_id: The WorkSpaces Pool ID (wspool-...).
|
|
600
|
+
region: AWS region. Defaults to the server's configured region.
|
|
601
|
+
lookback_days: Window length (default 7).
|
|
602
|
+
period_hours: Bucket size in hours (default 24 = daily; use 1 for hourly).
|
|
603
|
+
"""
|
|
604
|
+
usage = get_pool_session_history_core(
|
|
605
|
+
factory, pool_id, region or factory.region, lookback_days, period_hours
|
|
606
|
+
)
|
|
607
|
+
return usage.model_dump()
|
|
608
|
+
|
|
609
|
+
mcp.add_tool(get_workspace_performance, annotations=read_only("WorkSpace performance"))
|
|
610
|
+
mcp.add_tool(
|
|
611
|
+
recommend_bundle_rightsizing, annotations=read_only("Recommend bundle right-sizing")
|
|
612
|
+
)
|
|
613
|
+
mcp.add_tool(
|
|
614
|
+
get_application_fleet_usage, annotations=read_only("Applications fleet usage history")
|
|
615
|
+
)
|
|
616
|
+
mcp.add_tool(
|
|
617
|
+
get_workspace_connection_history,
|
|
618
|
+
annotations=read_only("WorkSpace connection history"),
|
|
619
|
+
)
|
|
620
|
+
mcp.add_tool(get_pool_session_history, annotations=read_only("Pool session history"))
|