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,152 @@
|
|
|
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
|
+
"""Best-effort WorkSpaces price lookups (AWS Price List API) for savings estimates.
|
|
5
|
+
|
|
6
|
+
The Price List API is messy, so this is deliberately conservative: it matches the canonical
|
|
7
|
+
Included-license hardware SKU for a given region / OS / compute type / volume sizes and returns the
|
|
8
|
+
AlwaysOn monthly price plus the AutoStop monthly-base and hourly prices. Anything it cannot match
|
|
9
|
+
cleanly returns None, so callers degrade to "no estimate" rather than a wrong number.
|
|
10
|
+
|
|
11
|
+
Needs ``pricing:GetProducts`` (IAM Tier 1). The pricing client is global (queried from us-east-1).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from typing import NamedTuple
|
|
18
|
+
|
|
19
|
+
from loguru import logger
|
|
20
|
+
|
|
21
|
+
from ..clients import ClientFactory
|
|
22
|
+
|
|
23
|
+
_PRICING_REGION = "us-east-1"
|
|
24
|
+
|
|
25
|
+
# Region -> Price List "location" long name. Extend as needed; unknown regions -> no estimate.
|
|
26
|
+
_REGION_LOCATIONS = {
|
|
27
|
+
"us-east-1": "US East (N. Virginia)",
|
|
28
|
+
"us-west-2": "US West (Oregon)",
|
|
29
|
+
"eu-west-1": "Europe (Ireland)",
|
|
30
|
+
"eu-central-1": "Europe (Frankfurt)",
|
|
31
|
+
"ap-southeast-1": "Asia Pacific (Singapore)",
|
|
32
|
+
"ap-southeast-2": "Asia Pacific (Sydney)",
|
|
33
|
+
"ap-northeast-1": "Asia Pacific (Tokyo)",
|
|
34
|
+
"ap-south-1": "Asia Pacific (Mumbai)",
|
|
35
|
+
"ca-central-1": "Canada (Central)",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# WorkSpaces compute type -> Price List "bundle" name.
|
|
39
|
+
_COMPUTE_BUNDLE = {
|
|
40
|
+
"VALUE": "Value",
|
|
41
|
+
"STANDARD": "Standard",
|
|
42
|
+
"PERFORMANCE": "Performance",
|
|
43
|
+
"POWER": "Power",
|
|
44
|
+
"POWERPRO": "PowerPro",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
_cache: dict[tuple, WorkspacePrices | None] = {}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class WorkspacePrices(NamedTuple):
|
|
51
|
+
alwayson_monthly: float | None
|
|
52
|
+
autostop_monthly_base: float | None
|
|
53
|
+
autostop_hourly: float | None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _os_filter(operating_system: str | None) -> str | None:
|
|
57
|
+
if not operating_system:
|
|
58
|
+
return "Windows"
|
|
59
|
+
o = operating_system.upper()
|
|
60
|
+
if "WIN" in o:
|
|
61
|
+
return "Windows"
|
|
62
|
+
if any(k in o for k in ("LINUX", "UBUNTU", "RHEL", "ROCKY", "AMAZON")):
|
|
63
|
+
return "Linux"
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _storage_str(root_gib: int | None, user_gib: int | None) -> str | None:
|
|
68
|
+
if root_gib is None or user_gib is None:
|
|
69
|
+
return None
|
|
70
|
+
return f"Root:{root_gib} GB,User:{user_gib} GB"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_workspace_prices(
|
|
74
|
+
factory: ClientFactory,
|
|
75
|
+
region: str | None,
|
|
76
|
+
operating_system: str | None,
|
|
77
|
+
compute_type: str | None,
|
|
78
|
+
root_gib: int | None,
|
|
79
|
+
user_gib: int | None,
|
|
80
|
+
) -> WorkspacePrices | None:
|
|
81
|
+
"""Resolve AlwaysOn monthly + AutoStop base/hourly prices for a desktop (None if unmatched)."""
|
|
82
|
+
location = _REGION_LOCATIONS.get(region or "")
|
|
83
|
+
os_value = _os_filter(operating_system)
|
|
84
|
+
bundle = _COMPUTE_BUNDLE.get((compute_type or "").upper())
|
|
85
|
+
storage = _storage_str(root_gib, user_gib)
|
|
86
|
+
if not (location and os_value and bundle and storage):
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
key = (location, os_value, bundle, storage)
|
|
90
|
+
if key in _cache:
|
|
91
|
+
return _cache[key]
|
|
92
|
+
|
|
93
|
+
base_filters = [
|
|
94
|
+
{"Type": "TERM_MATCH", "Field": "location", "Value": location},
|
|
95
|
+
{"Type": "TERM_MATCH", "Field": "operatingSystem", "Value": os_value},
|
|
96
|
+
{"Type": "TERM_MATCH", "Field": "license", "Value": "Included"},
|
|
97
|
+
{"Type": "TERM_MATCH", "Field": "bundle", "Value": bundle},
|
|
98
|
+
]
|
|
99
|
+
alwayson = autostop_base = hourly = None
|
|
100
|
+
try:
|
|
101
|
+
pricing = factory.client("pricing", region=_PRICING_REGION)
|
|
102
|
+
for running_mode in ("AlwaysOn", "AutoStop"):
|
|
103
|
+
filters = base_filters + [
|
|
104
|
+
{"Type": "TERM_MATCH", "Field": "runningMode", "Value": running_mode}
|
|
105
|
+
]
|
|
106
|
+
resp = pricing.get_products(
|
|
107
|
+
ServiceCode="AmazonWorkSpaces", Filters=filters, MaxResults=100
|
|
108
|
+
)
|
|
109
|
+
for raw in resp.get("PriceList", []):
|
|
110
|
+
product = json.loads(raw)
|
|
111
|
+
attrs = product["product"]["attributes"]
|
|
112
|
+
if attrs.get("storage") != storage:
|
|
113
|
+
continue
|
|
114
|
+
for term in product.get("terms", {}).get("OnDemand", {}).values():
|
|
115
|
+
for pd in term["priceDimensions"].values():
|
|
116
|
+
unit = (pd.get("unit") or "").lower()
|
|
117
|
+
usd = float(pd.get("pricePerUnit", {}).get("USD", 0) or 0)
|
|
118
|
+
if usd <= 0:
|
|
119
|
+
continue
|
|
120
|
+
if running_mode == "AlwaysOn" and unit == "month":
|
|
121
|
+
alwayson = usd
|
|
122
|
+
elif running_mode == "AutoStop" and unit == "hour":
|
|
123
|
+
hourly = usd
|
|
124
|
+
elif running_mode == "AutoStop" and unit == "month":
|
|
125
|
+
autostop_base = usd
|
|
126
|
+
except Exception as exc: # pricing is best-effort; never fail the recommendation
|
|
127
|
+
logger.warning("Pricing lookup failed for {}: {}", key, exc)
|
|
128
|
+
_cache[key] = None
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
prices = WorkspacePrices(alwayson, autostop_base, hourly)
|
|
132
|
+
_cache[key] = prices
|
|
133
|
+
return prices
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _estimated_monthly_hours(active_days: int | None, lookback_days: int) -> float:
|
|
137
|
+
"""Rough monthly connected-hours estimate from active days (assume ~8h per active day)."""
|
|
138
|
+
if not active_days or lookback_days <= 0:
|
|
139
|
+
return 0.0
|
|
140
|
+
return (active_days / lookback_days) * 30.0 * 8.0
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def estimate_alwayson_to_autostop_savings(
|
|
144
|
+
prices: WorkspacePrices | None, active_days: int | None, lookback_days: int
|
|
145
|
+
) -> float | None:
|
|
146
|
+
"""Monthly saving from moving an AlwaysOn desktop to AutoStop, given its usage."""
|
|
147
|
+
if not prices or prices.alwayson_monthly is None or prices.autostop_monthly_base is None:
|
|
148
|
+
return None
|
|
149
|
+
hours = _estimated_monthly_hours(active_days, lookback_days)
|
|
150
|
+
autostop_cost = prices.autostop_monthly_base + (prices.autostop_hourly or 0.0) * hours
|
|
151
|
+
saving = prices.alwayson_monthly - autostop_cost
|
|
152
|
+
return round(saving, 2) if saving > 0 else None
|
|
@@ -0,0 +1,529 @@
|
|
|
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
|
+
"""Reporting & audit tools (read-only).
|
|
5
|
+
|
|
6
|
+
`generate_inventory_report` and `list_unused_resources` are Tier 0; `audit_security_posture` is
|
|
7
|
+
Tier 0 too. All synthesize across services and degrade gracefully when a signal is unavailable.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .. import consts
|
|
15
|
+
from ..clients import ClientFactory
|
|
16
|
+
from ..models import (
|
|
17
|
+
AuditReport,
|
|
18
|
+
Finding,
|
|
19
|
+
InventoryReport,
|
|
20
|
+
InventoryReportSection,
|
|
21
|
+
ResourceRecord,
|
|
22
|
+
ServiceError,
|
|
23
|
+
UnusedResource,
|
|
24
|
+
UnusedResourcesReport,
|
|
25
|
+
)
|
|
26
|
+
from . import cost
|
|
27
|
+
from ._common import paginate, read_only, try_call
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _managed_instance_record(instance: dict, ec2_by_id: dict[str, dict]) -> ResourceRecord:
|
|
31
|
+
ec2_id = (instance.get("EC2ManagedInstance") or {}).get("InstanceId")
|
|
32
|
+
ec2 = ec2_by_id.get(ec2_id or "", {})
|
|
33
|
+
return ResourceRecord(
|
|
34
|
+
id=instance.get("WorkspaceInstanceId", ""),
|
|
35
|
+
state=instance.get("ProvisionState"),
|
|
36
|
+
attributes={
|
|
37
|
+
"ec2_instance_id": ec2_id,
|
|
38
|
+
"ec2_instance_type": ec2.get("InstanceType"),
|
|
39
|
+
"ec2_state": (ec2.get("State") or {}).get("Name"),
|
|
40
|
+
"ec2_launch_time": str(ec2.get("LaunchTime")) if ec2.get("LaunchTime") else None,
|
|
41
|
+
"ec2_private_ip": ec2.get("PrivateIpAddress"),
|
|
42
|
+
"ec2_platform": ec2.get("PlatformDetails"),
|
|
43
|
+
},
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def generate_inventory_report_core(factory: ClientFactory, region: str | None) -> InventoryReport:
|
|
48
|
+
errors: list[ServiceError] = []
|
|
49
|
+
sections: list[InventoryReportSection] = []
|
|
50
|
+
|
|
51
|
+
workspaces = factory.client(consts.WORKSPACES_API, region=region)
|
|
52
|
+
appstream = factory.client(consts.APPSTREAM_API, region=region)
|
|
53
|
+
secure_browser = factory.client(consts.SECURE_BROWSER_API, region=region)
|
|
54
|
+
|
|
55
|
+
personal = try_call(
|
|
56
|
+
errors,
|
|
57
|
+
consts.PRODUCT_WORKSPACES_PERSONAL,
|
|
58
|
+
"DescribeWorkspaces",
|
|
59
|
+
lambda: paginate(workspaces.describe_workspaces, "Workspaces"),
|
|
60
|
+
default=[],
|
|
61
|
+
)
|
|
62
|
+
sections.append(
|
|
63
|
+
InventoryReportSection(
|
|
64
|
+
service=consts.PRODUCT_WORKSPACES_PERSONAL,
|
|
65
|
+
resource_type="WorkSpace",
|
|
66
|
+
resources=[
|
|
67
|
+
ResourceRecord(
|
|
68
|
+
id=w.get("WorkspaceId", ""),
|
|
69
|
+
name=w.get("UserName"),
|
|
70
|
+
state=w.get("State"),
|
|
71
|
+
attributes={
|
|
72
|
+
"user_name": w.get("UserName"),
|
|
73
|
+
"computer_name": w.get("ComputerName"),
|
|
74
|
+
"ip_address": w.get("IpAddress"),
|
|
75
|
+
"directory_id": w.get("DirectoryId"),
|
|
76
|
+
"bundle_id": w.get("BundleId"),
|
|
77
|
+
"compute_type": w.get("WorkspaceProperties", {}).get("ComputeTypeName"),
|
|
78
|
+
"running_mode": w.get("WorkspaceProperties", {}).get("RunningMode"),
|
|
79
|
+
"operating_system": w.get("WorkspaceProperties", {}).get(
|
|
80
|
+
"OperatingSystemName"
|
|
81
|
+
),
|
|
82
|
+
"protocols": w.get("WorkspaceProperties", {}).get("Protocols"),
|
|
83
|
+
"root_volume_gib": w.get("WorkspaceProperties", {}).get(
|
|
84
|
+
"RootVolumeSizeGib"
|
|
85
|
+
),
|
|
86
|
+
"user_volume_gib": w.get("WorkspaceProperties", {}).get(
|
|
87
|
+
"UserVolumeSizeGib"
|
|
88
|
+
),
|
|
89
|
+
"auto_stop_timeout_minutes": w.get("WorkspaceProperties", {}).get(
|
|
90
|
+
"RunningModeAutoStopTimeoutInMinutes"
|
|
91
|
+
),
|
|
92
|
+
"root_volume_encrypted": w.get("RootVolumeEncryptionEnabled"),
|
|
93
|
+
"user_volume_encrypted": w.get("UserVolumeEncryptionEnabled"),
|
|
94
|
+
"subnet_id": w.get("SubnetId"),
|
|
95
|
+
},
|
|
96
|
+
)
|
|
97
|
+
for w in (personal or [])
|
|
98
|
+
],
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
pools = try_call(
|
|
103
|
+
errors,
|
|
104
|
+
consts.PRODUCT_WORKSPACES_POOLS,
|
|
105
|
+
"DescribeWorkspacesPools",
|
|
106
|
+
lambda: paginate(workspaces.describe_workspaces_pools, "WorkspacesPools"),
|
|
107
|
+
default=[],
|
|
108
|
+
)
|
|
109
|
+
sections.append(
|
|
110
|
+
InventoryReportSection(
|
|
111
|
+
service=consts.PRODUCT_WORKSPACES_POOLS,
|
|
112
|
+
resource_type="WorkSpacesPool",
|
|
113
|
+
resources=[
|
|
114
|
+
ResourceRecord(
|
|
115
|
+
id=p.get("PoolId", p.get("PoolName", "")),
|
|
116
|
+
name=p.get("PoolName"),
|
|
117
|
+
state=p.get("State"),
|
|
118
|
+
attributes={
|
|
119
|
+
"capacity": p.get("CapacityStatus"),
|
|
120
|
+
"bundle_id": p.get("BundleId"),
|
|
121
|
+
"directory_id": p.get("DirectoryId"),
|
|
122
|
+
"running_mode": p.get("RunningMode"),
|
|
123
|
+
"description": p.get("Description"),
|
|
124
|
+
"created_at": str(p.get("CreatedAt")) if p.get("CreatedAt") else None,
|
|
125
|
+
"errors": p.get("Errors"),
|
|
126
|
+
},
|
|
127
|
+
)
|
|
128
|
+
for p in (pools or [])
|
|
129
|
+
],
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
fleets = try_call(
|
|
134
|
+
errors,
|
|
135
|
+
consts.PRODUCT_WORKSPACES_APPLICATIONS,
|
|
136
|
+
"DescribeFleets",
|
|
137
|
+
lambda: paginate(appstream.describe_fleets, "Fleets"),
|
|
138
|
+
default=[],
|
|
139
|
+
)
|
|
140
|
+
sections.append(
|
|
141
|
+
InventoryReportSection(
|
|
142
|
+
service=consts.PRODUCT_WORKSPACES_APPLICATIONS,
|
|
143
|
+
resource_type="Fleet",
|
|
144
|
+
resources=[
|
|
145
|
+
ResourceRecord(
|
|
146
|
+
id=f.get("Name", ""),
|
|
147
|
+
name=f.get("DisplayName"),
|
|
148
|
+
state=f.get("State"),
|
|
149
|
+
attributes={
|
|
150
|
+
"instance_type": f.get("InstanceType"),
|
|
151
|
+
"fleet_type": f.get("FleetType"),
|
|
152
|
+
"capacity": f.get("ComputeCapacityStatus"),
|
|
153
|
+
"image_name": f.get("ImageName"),
|
|
154
|
+
"max_user_duration_seconds": f.get("MaxUserDurationInSeconds"),
|
|
155
|
+
"idle_disconnect_timeout_seconds": f.get("IdleDisconnectTimeoutInSeconds"),
|
|
156
|
+
"disconnect_timeout_seconds": f.get("DisconnectTimeoutInSeconds"),
|
|
157
|
+
"max_sessions_per_instance": f.get("MaxSessionsPerInstance"),
|
|
158
|
+
"default_internet_access": f.get("EnableDefaultInternetAccess"),
|
|
159
|
+
},
|
|
160
|
+
)
|
|
161
|
+
for f in (fleets or [])
|
|
162
|
+
],
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
stacks = try_call(
|
|
167
|
+
errors,
|
|
168
|
+
consts.PRODUCT_WORKSPACES_APPLICATIONS,
|
|
169
|
+
"DescribeStacks",
|
|
170
|
+
lambda: paginate(appstream.describe_stacks, "Stacks"),
|
|
171
|
+
default=[],
|
|
172
|
+
)
|
|
173
|
+
stack_records: list[ResourceRecord] = []
|
|
174
|
+
for s in stacks or []:
|
|
175
|
+
stack_name = s.get("Name", "")
|
|
176
|
+
associated = try_call(
|
|
177
|
+
errors,
|
|
178
|
+
consts.PRODUCT_WORKSPACES_APPLICATIONS,
|
|
179
|
+
"ListAssociatedFleets",
|
|
180
|
+
lambda stack_name=stack_name: paginate(
|
|
181
|
+
appstream.list_associated_fleets, "Names", StackName=stack_name
|
|
182
|
+
),
|
|
183
|
+
default=[],
|
|
184
|
+
)
|
|
185
|
+
stack_records.append(
|
|
186
|
+
ResourceRecord(
|
|
187
|
+
id=stack_name,
|
|
188
|
+
name=s.get("DisplayName"),
|
|
189
|
+
attributes={
|
|
190
|
+
"description": s.get("Description"),
|
|
191
|
+
"associated_fleets": associated or [],
|
|
192
|
+
"user_settings": s.get("UserSettings"),
|
|
193
|
+
"storage_connectors": s.get("StorageConnectors"),
|
|
194
|
+
"application_settings": s.get("ApplicationSettings"),
|
|
195
|
+
},
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
sections.append(
|
|
199
|
+
InventoryReportSection(
|
|
200
|
+
service=consts.PRODUCT_WORKSPACES_APPLICATIONS,
|
|
201
|
+
resource_type="Stack",
|
|
202
|
+
resources=stack_records,
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
portals = try_call(
|
|
207
|
+
errors,
|
|
208
|
+
consts.PRODUCT_SECURE_BROWSER,
|
|
209
|
+
"ListPortals",
|
|
210
|
+
lambda: paginate(
|
|
211
|
+
secure_browser.list_portals,
|
|
212
|
+
"portals",
|
|
213
|
+
pagination_in="nextToken",
|
|
214
|
+
pagination_out="nextToken",
|
|
215
|
+
),
|
|
216
|
+
default=[],
|
|
217
|
+
)
|
|
218
|
+
sections.append(
|
|
219
|
+
InventoryReportSection(
|
|
220
|
+
service=consts.PRODUCT_SECURE_BROWSER,
|
|
221
|
+
resource_type="Portal",
|
|
222
|
+
resources=[
|
|
223
|
+
ResourceRecord(
|
|
224
|
+
id=p.get("portalArn", p.get("portalId", "")),
|
|
225
|
+
name=p.get("displayName"),
|
|
226
|
+
state=p.get("portalStatus"),
|
|
227
|
+
attributes={
|
|
228
|
+
"browser_type": p.get("browserType"),
|
|
229
|
+
"authentication_type": p.get("authenticationType"),
|
|
230
|
+
"max_concurrent_sessions": p.get("maxConcurrentSessions"),
|
|
231
|
+
"instance_type": p.get("instanceType"),
|
|
232
|
+
"renderer_type": p.get("rendererType"),
|
|
233
|
+
"portal_endpoint": p.get("portalEndpoint"),
|
|
234
|
+
},
|
|
235
|
+
)
|
|
236
|
+
for p in (portals or [])
|
|
237
|
+
],
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
instances_client = factory.client(consts.WORKSPACES_INSTANCES_API, region=region)
|
|
242
|
+
instances = try_call(
|
|
243
|
+
errors,
|
|
244
|
+
consts.PRODUCT_WORKSPACES_CORE_INSTANCES,
|
|
245
|
+
"ListWorkspaceInstances",
|
|
246
|
+
lambda: paginate(instances_client.list_workspace_instances, "WorkspaceInstances"),
|
|
247
|
+
default=[],
|
|
248
|
+
)
|
|
249
|
+
# Enrich with EC2 details (type/state/launch/IP) for the backing instances.
|
|
250
|
+
ec2_ids = [
|
|
251
|
+
eid
|
|
252
|
+
for i in (instances or [])
|
|
253
|
+
if (eid := (i.get("EC2ManagedInstance") or {}).get("InstanceId"))
|
|
254
|
+
]
|
|
255
|
+
ec2_by_id: dict[str, dict] = {}
|
|
256
|
+
if ec2_ids:
|
|
257
|
+
ec2 = factory.client(consts.EC2_API, region=region)
|
|
258
|
+
reservations = try_call(
|
|
259
|
+
errors,
|
|
260
|
+
consts.PRODUCT_WORKSPACES_CORE_INSTANCES,
|
|
261
|
+
"DescribeInstances",
|
|
262
|
+
lambda: ec2.describe_instances(InstanceIds=ec2_ids).get("Reservations", []),
|
|
263
|
+
default=[],
|
|
264
|
+
)
|
|
265
|
+
for res in reservations or []:
|
|
266
|
+
for inst in res.get("Instances", []):
|
|
267
|
+
ec2_by_id[inst.get("InstanceId", "")] = inst
|
|
268
|
+
sections.append(
|
|
269
|
+
InventoryReportSection(
|
|
270
|
+
service=consts.PRODUCT_WORKSPACES_CORE_INSTANCES,
|
|
271
|
+
resource_type="ManagedInstance",
|
|
272
|
+
resources=[_managed_instance_record(i, ec2_by_id) for i in (instances or [])],
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
total = sum(len(s.resources) for s in sections)
|
|
277
|
+
return InventoryReport(region=region, total_resources=total, sections=sections, errors=errors)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def audit_security_posture_core(factory: ClientFactory, region: str | None) -> AuditReport:
|
|
281
|
+
errors: list[ServiceError] = []
|
|
282
|
+
findings: list[Finding] = []
|
|
283
|
+
resources_checked: dict[str, int] = {}
|
|
284
|
+
|
|
285
|
+
workspaces = factory.client(consts.WORKSPACES_API, region=region)
|
|
286
|
+
|
|
287
|
+
personal = try_call(
|
|
288
|
+
errors,
|
|
289
|
+
consts.PRODUCT_WORKSPACES_PERSONAL,
|
|
290
|
+
"DescribeWorkspaces",
|
|
291
|
+
lambda: paginate(workspaces.describe_workspaces, "Workspaces"),
|
|
292
|
+
default=[],
|
|
293
|
+
)
|
|
294
|
+
resources_checked["workspaces"] = len(personal or [])
|
|
295
|
+
for w in personal or []:
|
|
296
|
+
wid = w.get("WorkspaceId", "")
|
|
297
|
+
root_enc = w.get("RootVolumeEncryptionEnabled")
|
|
298
|
+
user_enc = w.get("UserVolumeEncryptionEnabled")
|
|
299
|
+
unencrypted = [
|
|
300
|
+
name
|
|
301
|
+
for name, enabled in (("root", root_enc), ("user", user_enc))
|
|
302
|
+
if enabled is not True
|
|
303
|
+
]
|
|
304
|
+
if unencrypted:
|
|
305
|
+
findings.append(
|
|
306
|
+
Finding(
|
|
307
|
+
severity="warning",
|
|
308
|
+
title=f"WorkSpace volumes not encrypted: {', '.join(unencrypted)}",
|
|
309
|
+
detail=f"WorkSpace {wid} has unencrypted {', '.join(unencrypted)} volume(s); "
|
|
310
|
+
"encryption can only be set at creation time.",
|
|
311
|
+
recommendation="Recreate the WorkSpace with root/user volume encryption.",
|
|
312
|
+
resource_id=wid,
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
directories = try_call(
|
|
317
|
+
errors,
|
|
318
|
+
consts.PRODUCT_WORKSPACES_PERSONAL,
|
|
319
|
+
"DescribeWorkspaceDirectories",
|
|
320
|
+
lambda: paginate(workspaces.describe_workspace_directories, "Directories"),
|
|
321
|
+
default=[],
|
|
322
|
+
)
|
|
323
|
+
resources_checked["directories"] = len(directories or [])
|
|
324
|
+
for d in directories or []:
|
|
325
|
+
did = d.get("DirectoryId", "")
|
|
326
|
+
ip_groups = d.get("ipGroupIds") or []
|
|
327
|
+
if not ip_groups:
|
|
328
|
+
findings.append(
|
|
329
|
+
Finding(
|
|
330
|
+
severity="warning",
|
|
331
|
+
title="Directory has no IP access control groups",
|
|
332
|
+
detail=f"Directory {did} has no IP access control groups, so WorkSpaces "
|
|
333
|
+
"connections are not restricted by source IP.",
|
|
334
|
+
recommendation="Attach an IP access control group to restrict trusted ranges.",
|
|
335
|
+
resource_id=did,
|
|
336
|
+
)
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# Secure Browser portals: flag relaxed data-egress controls (download/copy/print enabled).
|
|
340
|
+
secure = factory.client(consts.SECURE_BROWSER_API, region=region)
|
|
341
|
+
portals = try_call(
|
|
342
|
+
errors,
|
|
343
|
+
consts.PRODUCT_SECURE_BROWSER,
|
|
344
|
+
"ListPortals",
|
|
345
|
+
lambda: paginate(
|
|
346
|
+
secure.list_portals, "portals", pagination_in="nextToken", pagination_out="nextToken"
|
|
347
|
+
),
|
|
348
|
+
default=[],
|
|
349
|
+
)
|
|
350
|
+
resources_checked["portals"] = len(portals or [])
|
|
351
|
+
for p in portals or []:
|
|
352
|
+
arn = p.get("portalArn", "")
|
|
353
|
+
us_arn = p.get("userSettingsArn")
|
|
354
|
+
if not us_arn:
|
|
355
|
+
continue
|
|
356
|
+
us = try_call(
|
|
357
|
+
errors,
|
|
358
|
+
consts.PRODUCT_SECURE_BROWSER,
|
|
359
|
+
"GetUserSettings",
|
|
360
|
+
lambda us_arn=us_arn: secure.get_user_settings(userSettingsArn=us_arn).get(
|
|
361
|
+
"userSettings", {}
|
|
362
|
+
),
|
|
363
|
+
default={},
|
|
364
|
+
)
|
|
365
|
+
enabled = [
|
|
366
|
+
flag for flag in consts.SECURE_BROWSER_EGRESS_FLAGS if (us or {}).get(flag) == "Enabled"
|
|
367
|
+
]
|
|
368
|
+
if enabled:
|
|
369
|
+
findings.append(
|
|
370
|
+
Finding(
|
|
371
|
+
severity="warning",
|
|
372
|
+
title=f"Secure Browser portal allows data egress: {', '.join(enabled)}",
|
|
373
|
+
detail=f"Portal {p.get('displayName') or arn} permits {', '.join(enabled)} — "
|
|
374
|
+
"content can leave the managed browser session.",
|
|
375
|
+
recommendation="Disable unneeded download/copy/print in the user settings.",
|
|
376
|
+
resource_id=arn,
|
|
377
|
+
)
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
# Applications stacks: flag relaxed UserSettings that permit local data egress.
|
|
381
|
+
appstream = factory.client(consts.APPSTREAM_API, region=region)
|
|
382
|
+
stacks = try_call(
|
|
383
|
+
errors,
|
|
384
|
+
consts.PRODUCT_WORKSPACES_APPLICATIONS,
|
|
385
|
+
"DescribeStacks",
|
|
386
|
+
lambda: paginate(appstream.describe_stacks, "Stacks"),
|
|
387
|
+
default=[],
|
|
388
|
+
)
|
|
389
|
+
resources_checked["stacks"] = len(stacks or [])
|
|
390
|
+
egress_actions = {"CLIPBOARD_COPY_TO_LOCAL_DEVICE", "FILE_DOWNLOAD", "PRINTING_TO_LOCAL_DEVICE"}
|
|
391
|
+
for s in stacks or []:
|
|
392
|
+
name = s.get("Name", "")
|
|
393
|
+
enabled = [
|
|
394
|
+
u.get("Action")
|
|
395
|
+
for u in (s.get("UserSettings") or [])
|
|
396
|
+
if u.get("Action") in egress_actions and u.get("Permission") == "ENABLED"
|
|
397
|
+
]
|
|
398
|
+
if enabled:
|
|
399
|
+
findings.append(
|
|
400
|
+
Finding(
|
|
401
|
+
severity="warning",
|
|
402
|
+
title=f"WorkSpaces Applications stack allows data egress: {', '.join(enabled)}",
|
|
403
|
+
detail=f"Stack {name} permits {', '.join(enabled)} to the local device.",
|
|
404
|
+
recommendation="Disable unneeded copy-to-local/file-download/local-printing "
|
|
405
|
+
"in the stack user settings.",
|
|
406
|
+
resource_id=name,
|
|
407
|
+
)
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
if not findings and any(resources_checked.values()):
|
|
411
|
+
findings.append(
|
|
412
|
+
Finding(
|
|
413
|
+
severity="info",
|
|
414
|
+
title="No posture issues found in the checks performed",
|
|
415
|
+
detail="Checked WorkSpace volume encryption, directory IP access groups, and "
|
|
416
|
+
"Secure Browser / Applications data-egress controls.",
|
|
417
|
+
)
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
severity_counts: dict[str, int] = {}
|
|
421
|
+
for f in findings:
|
|
422
|
+
severity_counts[f.severity] = severity_counts.get(f.severity, 0) + 1
|
|
423
|
+
|
|
424
|
+
return AuditReport(
|
|
425
|
+
region=region,
|
|
426
|
+
findings=findings,
|
|
427
|
+
severity_counts=severity_counts,
|
|
428
|
+
resources_checked=resources_checked,
|
|
429
|
+
errors=errors,
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def list_unused_resources_core(
|
|
434
|
+
factory: ClientFactory, region: str | None, lookback_days: int = 14
|
|
435
|
+
) -> UnusedResourcesReport:
|
|
436
|
+
errors: list[ServiceError] = []
|
|
437
|
+
items: list[UnusedResource] = []
|
|
438
|
+
|
|
439
|
+
utilization = cost.analyze_workspace_utilization_core(factory, region, lookback_days)
|
|
440
|
+
errors.extend(utilization.errors)
|
|
441
|
+
for w in utilization.workspaces:
|
|
442
|
+
if w.classification == "unused":
|
|
443
|
+
items.append(
|
|
444
|
+
UnusedResource(
|
|
445
|
+
service=consts.PRODUCT_WORKSPACES_PERSONAL,
|
|
446
|
+
resource_type="WorkSpace",
|
|
447
|
+
id=w.workspace_id,
|
|
448
|
+
reason=f"No user connections in the last {lookback_days} days.",
|
|
449
|
+
)
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
appstream = factory.client(consts.APPSTREAM_API, region=region)
|
|
453
|
+
fleets = try_call(
|
|
454
|
+
errors,
|
|
455
|
+
consts.PRODUCT_WORKSPACES_APPLICATIONS,
|
|
456
|
+
"DescribeFleets",
|
|
457
|
+
lambda: paginate(appstream.describe_fleets, "Fleets"),
|
|
458
|
+
default=[],
|
|
459
|
+
)
|
|
460
|
+
for f in fleets or []:
|
|
461
|
+
capacity = f.get("ComputeCapacityStatus", {})
|
|
462
|
+
if f.get("State") == "STOPPED" or capacity.get("Desired") == 0:
|
|
463
|
+
items.append(
|
|
464
|
+
UnusedResource(
|
|
465
|
+
service=consts.PRODUCT_WORKSPACES_APPLICATIONS,
|
|
466
|
+
resource_type="Fleet",
|
|
467
|
+
id=f.get("Name", ""),
|
|
468
|
+
reason="Fleet is stopped or has zero desired capacity.",
|
|
469
|
+
)
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
return UnusedResourcesReport(
|
|
473
|
+
region=region,
|
|
474
|
+
lookback_days=lookback_days,
|
|
475
|
+
items=items,
|
|
476
|
+
errors=errors,
|
|
477
|
+
notes=[
|
|
478
|
+
"WorkSpace usage is from the AWS/WorkSpaces UserConnected metric; review before any "
|
|
479
|
+
"termination — recently provisioned desktops can look unused."
|
|
480
|
+
],
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def register(mcp: Any, factory: ClientFactory) -> None:
|
|
485
|
+
"""Register reporting & audit tools on the FastMCP app."""
|
|
486
|
+
|
|
487
|
+
async def generate_inventory_report(region: str | None = None) -> dict[str, Any]:
|
|
488
|
+
"""Produce a detailed per-resource inventory across the EUC portfolio.
|
|
489
|
+
|
|
490
|
+
Lists WorkSpaces Personal desktops, WorkSpaces Pools, WorkSpaces Applications (formerly
|
|
491
|
+
AppStream 2.0) fleets, and WorkSpaces Secure Browser (formerly WorkSpaces Web) portals with
|
|
492
|
+
key attributes per resource. Read-only.
|
|
493
|
+
|
|
494
|
+
Args:
|
|
495
|
+
region: AWS region. Defaults to the server's configured region.
|
|
496
|
+
"""
|
|
497
|
+
report = generate_inventory_report_core(factory, region or factory.region)
|
|
498
|
+
return report.model_dump()
|
|
499
|
+
|
|
500
|
+
async def audit_security_posture(region: str | None = None) -> dict[str, Any]:
|
|
501
|
+
"""Audit EUC security posture against common best practices.
|
|
502
|
+
|
|
503
|
+
Checks WorkSpaces Personal root/user volume encryption and whether directories have IP
|
|
504
|
+
access control groups, returning severity-ranked findings. Read-only.
|
|
505
|
+
|
|
506
|
+
Args:
|
|
507
|
+
region: AWS region. Defaults to the server's configured region.
|
|
508
|
+
"""
|
|
509
|
+
report = audit_security_posture_core(factory, region or factory.region)
|
|
510
|
+
return report.model_dump()
|
|
511
|
+
|
|
512
|
+
async def list_unused_resources(
|
|
513
|
+
region: str | None = None, lookback_days: int = 14
|
|
514
|
+
) -> dict[str, Any]:
|
|
515
|
+
"""List candidate idle/unused EUC resources worth reclaiming.
|
|
516
|
+
|
|
517
|
+
Surfaces unused WorkSpaces Personal desktops (no connections in the window) and stopped or
|
|
518
|
+
zero-capacity WorkSpaces Applications fleets. Read-only.
|
|
519
|
+
|
|
520
|
+
Args:
|
|
521
|
+
region: AWS region. Defaults to the server's configured region.
|
|
522
|
+
lookback_days: Window for WorkSpace usage (default 14).
|
|
523
|
+
"""
|
|
524
|
+
report = list_unused_resources_core(factory, region or factory.region, lookback_days)
|
|
525
|
+
return report.model_dump()
|
|
526
|
+
|
|
527
|
+
mcp.add_tool(generate_inventory_report, annotations=read_only("Inventory report"))
|
|
528
|
+
mcp.add_tool(audit_security_posture, annotations=read_only("Security posture audit"))
|
|
529
|
+
mcp.add_tool(list_unused_resources, annotations=read_only("List unused resources"))
|