argus-cloud-optimizer 0.2.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.
Files changed (62) hide show
  1. adapters/__init__.py +0 -0
  2. adapters/aws/__init__.py +0 -0
  3. adapters/aws/adapter.py +85 -0
  4. adapters/aws/auth.py +57 -0
  5. adapters/aws/cloudtrail.py +83 -0
  6. adapters/aws/cloudwatch.py +732 -0
  7. adapters/aws/config.py +9 -0
  8. adapters/aws/cost_explorer.py +116 -0
  9. adapters/aws/resource_explorer.py +186 -0
  10. adapters/aws/retry.py +55 -0
  11. adapters/azure/__init__.py +0 -0
  12. adapters/azure/activity_log.py +159 -0
  13. adapters/azure/adapter.py +117 -0
  14. adapters/azure/cost_management.py +125 -0
  15. adapters/azure/monitor.py +311 -0
  16. adapters/azure/resource_graph.py +113 -0
  17. adapters/azure/retry.py +57 -0
  18. adapters/base.py +105 -0
  19. adapters/gcp/__init__.py +0 -0
  20. adapters/gcp/adapter.py +86 -0
  21. adapters/gcp/asset_inventory.py +116 -0
  22. adapters/gcp/billing.py +118 -0
  23. adapters/gcp/cloud_logging.py +93 -0
  24. adapters/gcp/cloud_monitoring.py +276 -0
  25. adapters/gcp/retry.py +46 -0
  26. ai/__init__.py +0 -0
  27. ai/anthropic.py +174 -0
  28. ai/azure_openai.py +241 -0
  29. ai/base.py +78 -0
  30. ai/bedrock.py +169 -0
  31. ai/vertexai.py +234 -0
  32. argus_cloud_optimizer-0.2.0.dist-info/METADATA +433 -0
  33. argus_cloud_optimizer-0.2.0.dist-info/RECORD +62 -0
  34. argus_cloud_optimizer-0.2.0.dist-info/WHEEL +5 -0
  35. argus_cloud_optimizer-0.2.0.dist-info/entry_points.txt +2 -0
  36. argus_cloud_optimizer-0.2.0.dist-info/licenses/LICENSE +21 -0
  37. argus_cloud_optimizer-0.2.0.dist-info/top_level.txt +4 -0
  38. core/__init__.py +0 -0
  39. core/__version__.py +1 -0
  40. core/agent/__init__.py +0 -0
  41. core/agent/loop.py +390 -0
  42. core/agent/prompts.py +317 -0
  43. core/config.py +235 -0
  44. core/log.py +69 -0
  45. core/models/__init__.py +0 -0
  46. core/models/finding.py +76 -0
  47. core/py.typed +0 -0
  48. core/reports/__init__.py +0 -0
  49. core/reports/comparison.py +49 -0
  50. core/reports/delivery.py +323 -0
  51. core/reports/export.py +111 -0
  52. core/reports/generator.py +168 -0
  53. core/reports/html.py +286 -0
  54. core/reports/multi_cloud.py +162 -0
  55. core/secrets.py +145 -0
  56. core/token_tracker.py +97 -0
  57. core/validation.py +214 -0
  58. entrypoints/__init__.py +0 -0
  59. entrypoints/aws_lambda.py +299 -0
  60. entrypoints/azure_function.py +257 -0
  61. entrypoints/cli.py +156 -0
  62. entrypoints/gcp_cloudrun.py +209 -0
adapters/aws/config.py ADDED
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ from botocore.config import Config
4
+
5
+ BOTO_TIMEOUT_CONFIG = Config(
6
+ connect_timeout=10,
7
+ read_timeout=60,
8
+ retries={"max_attempts": 0},
9
+ )
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timedelta, timezone
4
+
5
+ import boto3
6
+ import structlog
7
+ from botocore.exceptions import ClientError
8
+
9
+ from adapters.aws.config import BOTO_TIMEOUT_CONFIG
10
+ from adapters.aws.retry import retry_on_transient
11
+
12
+ logger = structlog.get_logger(__name__)
13
+
14
+ # Cost Explorer is a global service — always us-east-1
15
+ _CE_REGION = "us-east-1"
16
+
17
+
18
+ def get_cost(
19
+ session: boto3.Session,
20
+ resource_ids: list[str],
21
+ days: int = 30,
22
+ ) -> dict[str, float]:
23
+ """
24
+ Return estimated monthly cost in USD per resource ID.
25
+
26
+ Uses GetCostAndUsageWithResources which requires resource-level cost
27
+ allocation to be enabled in the AWS Cost Management console.
28
+ If not enabled, returns zeros and logs a warning — the agent will
29
+ note that cost data is unavailable for these resources.
30
+
31
+ IMPORTANT: Always batch resource_ids — this is one API call regardless
32
+ of how many IDs are passed. Cost Explorer charges $0.01 per API call.
33
+ """
34
+ if not resource_ids:
35
+ return {}
36
+
37
+ client = session.client("ce", region_name=_CE_REGION, config=BOTO_TIMEOUT_CONFIG)
38
+
39
+ end_date = datetime.now(tz=timezone.utc).date()
40
+ start_date = end_date - timedelta(days=days)
41
+
42
+ try:
43
+ response = retry_on_transient(
44
+ client.get_cost_and_usage_with_resources,
45
+ TimePeriod={
46
+ "Start": start_date.strftime("%Y-%m-%d"),
47
+ "End": end_date.strftime("%Y-%m-%d"),
48
+ },
49
+ Granularity="MONTHLY",
50
+ Filter={
51
+ "Dimensions": {
52
+ "Key": "RESOURCE_ID",
53
+ "Values": resource_ids,
54
+ }
55
+ },
56
+ GroupBy=[{"Type": "DIMENSION", "Key": "RESOURCE_ID"}],
57
+ Metrics=["UnblendedCost"],
58
+ )
59
+ except ClientError as exc:
60
+ code = exc.response["Error"]["Code"]
61
+ message = exc.response["Error"].get("Message", "")
62
+
63
+ if code == "DataUnavailableException":
64
+ logger.warning(
65
+ "cost_explorer_resource_granularity_disabled",
66
+ hint=(
67
+ "Enable resource-level data in AWS Cost Management console "
68
+ "(Preferences → Resource-level data)."
69
+ ),
70
+ )
71
+ return {rid: 0.0 for rid in resource_ids}
72
+
73
+ if (
74
+ code == "AccessDeniedException"
75
+ and "not enabled for cost explorer" in message.lower()
76
+ ):
77
+ logger.warning(
78
+ "cost_explorer_not_activated",
79
+ hint=(
80
+ "Cost Explorer has not been enabled for this AWS account. "
81
+ "Activate it in the AWS Cost Management console "
82
+ "(takes up to 24 hours after first activation)."
83
+ ),
84
+ )
85
+ return {rid: 0.0 for rid in resource_ids}
86
+
87
+ if code == "AccessDeniedException":
88
+ logger.warning(
89
+ "cost_explorer_access_denied",
90
+ hint=(
91
+ "IAM principal is missing "
92
+ "ce:GetCostAndUsageWithResources permission. "
93
+ "Add it to the Argus IAM role."
94
+ ),
95
+ error=str(exc),
96
+ )
97
+ return {rid: 0.0 for rid in resource_ids}
98
+
99
+ logger.error("cost_explorer_failed", error=str(exc), code=code)
100
+ return {rid: 0.0 for rid in resource_ids}
101
+
102
+ costs: dict[str, float] = {rid: 0.0 for rid in resource_ids}
103
+
104
+ for time_period in response.get("ResultsByTime", []):
105
+ for group in time_period.get("Groups", []):
106
+ resource_id = group["Keys"][0]
107
+ amount = float(group["Metrics"]["UnblendedCost"]["Amount"])
108
+ # Accumulate across months if days > 31
109
+ costs[resource_id] = costs.get(resource_id, 0.0) + amount
110
+
111
+ logger.info(
112
+ "cost_explorer_complete",
113
+ resources_queried=len(resource_ids),
114
+ resources_with_cost=sum(1 for v in costs.values() if v > 0),
115
+ )
116
+ return costs
@@ -0,0 +1,186 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ import boto3
7
+ import structlog
8
+ from botocore.exceptions import ClientError
9
+
10
+ from adapters.aws.config import BOTO_TIMEOUT_CONFIG
11
+ from adapters.base import Resource
12
+
13
+ logger = structlog.get_logger(__name__)
14
+
15
+ # Resource Explorer aggregator index lives in one region per account.
16
+ # This is created by our CloudFormation template. Users can override
17
+ # via RESOURCE_EXPLORER_REGION env var if their aggregator is elsewhere.
18
+ DEFAULT_AGGREGATOR_REGION = "us-east-1"
19
+
20
+ # -----------------------------------------------------------------------
21
+ # Non-billable resource type filter
22
+ # -----------------------------------------------------------------------
23
+ # These types never appear on an AWS bill (or cost < $0.01/month and carry
24
+ # no useful idle signal), so we strip them before the AI ever sees them.
25
+ # This cuts token count by 50-70% on a typical account.
26
+ #
27
+ # Rule: when in doubt, KEEP the type (the AI can always decide it's free).
28
+ # Only list types that are definitively free infrastructure primitives.
29
+ # -----------------------------------------------------------------------
30
+ _NON_BILLABLE_PREFIXES: frozenset[str] = frozenset(
31
+ [
32
+ # IAM — all objects are $0
33
+ "aws::iam::",
34
+ # CloudFormation — stacks/stacksets are metadata, not billed resources
35
+ "aws::cloudformation::",
36
+ # SSM parameters and documents ($0 for standard tier parameters)
37
+ "aws::ssm::parameter",
38
+ "aws::ssm::document",
39
+ "aws::ssm::patchbaseline",
40
+ "aws::ssm::maintenancewindow",
41
+ "aws::ssm::resourcedatasync",
42
+ "aws::ssm::association",
43
+ # EC2 free infrastructure primitives
44
+ "aws::ec2::routetable",
45
+ "aws::ec2::subnet",
46
+ "aws::ec2::networkacl",
47
+ "aws::ec2::dhcpoptions",
48
+ "aws::ec2::internetgateway",
49
+ "aws::ec2::keypair",
50
+ "aws::ec2::placementgroup",
51
+ "aws::ec2::prefixlist",
52
+ "aws::ec2::vpcpeeringconnection",
53
+ # Config — rule/recorder metadata ($0)
54
+ "aws::config::configrule",
55
+ "aws::config::configurationrecorder",
56
+ "aws::config::deliverychannel",
57
+ "aws::config::conformancepack",
58
+ # Lambda auxiliary objects (the function itself stays)
59
+ "aws::lambda::eventsourcemapping",
60
+ "aws::lambda::layerversion",
61
+ # SNS subscriptions ($0 — the topic itself stays)
62
+ "aws::sns::subscription",
63
+ # CloudWatch alarms and dashboards ($0.10/alarm but no idle signal)
64
+ "aws::cloudwatch::alarm",
65
+ # Events — rules stay (EventBridge charges), but event buses default is free
66
+ "aws::events::eventbus",
67
+ # WAF — web ACL associations are metadata
68
+ "aws::wafv2::webaclassociation",
69
+ # Tagging — resource groups are free metadata
70
+ "aws::resourcegroups::group",
71
+ # Macie, GuardDuty, SecurityHub — findings/members are metadata
72
+ "aws::guardduty::detector",
73
+ "aws::guardduty::member",
74
+ "aws::macie2::",
75
+ "aws::securityhub::hub",
76
+ "aws::securityhub::standard",
77
+ # Service Catalog — products/portfolios are metadata
78
+ "aws::servicecatalog::",
79
+ # Organizations — accounts/OUs are metadata
80
+ "aws::organizations::",
81
+ # Access Analyzer
82
+ "aws::accessanalyzer::analyzer",
83
+ ]
84
+ )
85
+
86
+
87
+ def _is_billable(resource_type: str) -> bool:
88
+ """Return True if a resource type could appear on an AWS bill."""
89
+ lower = resource_type.lower()
90
+ return not any(lower.startswith(prefix) for prefix in _NON_BILLABLE_PREFIXES)
91
+
92
+
93
+ def list_resources(
94
+ session: boto3.Session,
95
+ ignore_regions: list[str] | None = None,
96
+ aggregator_region: str = DEFAULT_AGGREGATOR_REGION,
97
+ ) -> list[Resource]:
98
+ """
99
+ Return every resource in the account across ALL regions,
100
+ minus any in ignore_regions.
101
+ Uses AWS Resource Explorer v2 aggregator index — returns all resource
102
+ types in a single paginated API call. No per-type enumeration needed.
103
+
104
+ Requires: Resource Explorer aggregator index set up in aggregator_region.
105
+ The CloudFormation template handles this automatically.
106
+
107
+ Any region not in ignore_regions is scanned automatically — including newly
108
+ launched AWS regions — so regional failures never block the scan.
109
+ """
110
+ client = session.client(
111
+ "resource-explorer-2", region_name=aggregator_region, config=BOTO_TIMEOUT_CONFIG
112
+ )
113
+ ignore_set = set(ignore_regions) if ignore_regions else set()
114
+ resources: list[Resource] = []
115
+
116
+ try:
117
+ paginator = client.get_paginator("search")
118
+ for page in paginator.paginate(QueryString="*"):
119
+ for raw in page.get("Resources", []):
120
+ if raw.get("Region") in ignore_set:
121
+ continue
122
+ resource_type = raw.get("ResourceType", "")
123
+ if not _is_billable(resource_type):
124
+ continue
125
+ parsed = _parse_resource(raw)
126
+ if parsed:
127
+ resources.append(parsed)
128
+ except ClientError as exc:
129
+ code = exc.response["Error"]["Code"]
130
+ if code == "AccessDeniedException":
131
+ raise PermissionError(
132
+ "Argus IAM role is missing resource-explorer-2:Search permission."
133
+ ) from exc
134
+ if code in ("ResourceNotFoundException", "ValidationException"):
135
+ raise RuntimeError(
136
+ "No Resource Explorer aggregator index found. "
137
+ "Deploy the Argus CloudFormation template to create one, "
138
+ "or enable Resource Explorer manually in the AWS console."
139
+ ) from exc
140
+ raise
141
+
142
+ logger.info(
143
+ "resource_explorer_search_complete",
144
+ total=len(resources),
145
+ ignored_regions=list(ignore_set),
146
+ )
147
+ return resources
148
+
149
+
150
+ def _parse_resource(raw: dict[str, Any]) -> Resource | None:
151
+ arn = raw.get("Arn", "")
152
+ resource_type = raw.get("ResourceType", "")
153
+ region = raw.get("Region", "")
154
+
155
+ if not arn or not resource_type:
156
+ return None
157
+
158
+ tags = _parse_tags(raw.get("Properties", []))
159
+
160
+ return Resource(
161
+ resource_id=arn,
162
+ resource_type=resource_type,
163
+ cloud="aws",
164
+ region=region,
165
+ name=tags.get("Name"),
166
+ tags=tags,
167
+ )
168
+
169
+
170
+ def _parse_tags(properties: list[dict[str, Any]]) -> dict[str, str]:
171
+ """
172
+ Resource Explorer returns tags as JSON-encoded string in Properties.
173
+ Example: {"Name": "tags", "Data": "[{\"Key\":\"Env\",\"Value\":\"prod\"}]"}
174
+ """
175
+ for prop in properties:
176
+ if prop.get("Name") == "tags":
177
+ try:
178
+ tag_list = json.loads(prop.get("Data", "[]"))
179
+ return {
180
+ t["Key"]: t["Value"]
181
+ for t in tag_list
182
+ if "Key" in t and "Value" in t
183
+ }
184
+ except (json.JSONDecodeError, TypeError):
185
+ return {}
186
+ return {}
adapters/aws/retry.py ADDED
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ import random
4
+ import time
5
+ from collections.abc import Callable
6
+ from typing import Any, TypeVar
7
+
8
+ import structlog
9
+ from botocore.exceptions import ClientError
10
+
11
+ logger = structlog.get_logger(__name__)
12
+
13
+ T = TypeVar("T")
14
+
15
+ _MAX_RETRIES = 3
16
+ _BASE_DELAY = 1.0
17
+
18
+ _RETRYABLE_CODES = frozenset(
19
+ {
20
+ "ThrottlingException",
21
+ "RequestLimitExceeded",
22
+ "TooManyRequestsException",
23
+ "Throttling",
24
+ "InternalError",
25
+ "ServiceUnavailable",
26
+ }
27
+ )
28
+
29
+
30
+ def retry_on_transient(
31
+ fn: Callable[..., T],
32
+ *args: Any,
33
+ **kwargs: Any,
34
+ ) -> T:
35
+ delay = _BASE_DELAY
36
+ for attempt in range(_MAX_RETRIES):
37
+ try:
38
+ return fn(*args, **kwargs)
39
+ except ClientError as exc:
40
+ code = exc.response["Error"]["Code"]
41
+ if code in _RETRYABLE_CODES and attempt < _MAX_RETRIES - 1:
42
+ jitter = random.uniform(0, delay * 0.5) # noqa: S311
43
+ sleep_time = delay + jitter
44
+ logger.warning(
45
+ "aws_transient_error_retrying",
46
+ error_code=code,
47
+ attempt=attempt + 1,
48
+ max_retries=_MAX_RETRIES,
49
+ retry_in=round(sleep_time, 1),
50
+ )
51
+ time.sleep(sleep_time)
52
+ delay *= 2
53
+ else:
54
+ raise
55
+ raise RuntimeError("Unreachable") # pragma: no cover
File without changes
@@ -0,0 +1,159 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timedelta, timezone
4
+ from typing import Any
5
+
6
+ import structlog
7
+ from azure.core.exceptions import HttpResponseError
8
+ from azure.identity import DefaultAzureCredential
9
+ from azure.monitor.query import LogsQueryClient, LogsQueryStatus
10
+
11
+ from adapters.azure.retry import retry_on_transient
12
+
13
+ logger = structlog.get_logger(__name__)
14
+
15
+ _LOOKBACK_DAYS = 90 # Azure Activity Log retention is 90 days
16
+
17
+
18
+ def get_last_activity(
19
+ subscription_id: str,
20
+ resource_id: str,
21
+ resource_type: str,
22
+ credential: Any = None,
23
+ ) -> datetime | None:
24
+ """
25
+ Return the timestamp of the most recent activity for an Azure resource.
26
+ Queries Azure Monitor Activity Log via the Logs Query (Log Analytics) API.
27
+
28
+ Falls back to None if:
29
+ - Log Analytics workspace is not configured
30
+ - No activity found in the 90-day window
31
+ - API call fails
32
+
33
+ resource_id is the full Azure resource ID:
34
+ /subscriptions/{sub}/resourceGroups/{rg}/providers/{type}/{name}
35
+ """
36
+ cred = credential or DefaultAzureCredential()
37
+ client = LogsQueryClient(cred, connection_timeout=10, read_timeout=60)
38
+
39
+ # Log Analytics workspace for the subscription — set via env var.
40
+ import os
41
+
42
+ workspace_id = os.environ.get("AZURE_LOG_ANALYTICS_WORKSPACE_ID", "")
43
+
44
+ if not workspace_id:
45
+ logger.debug(
46
+ "azure_activity_log_skipped",
47
+ extra={
48
+ "resource_id": resource_id,
49
+ "reason": "AZURE_LOG_ANALYTICS_WORKSPACE_ID not set",
50
+ },
51
+ )
52
+ return _fallback_from_activity_log_api(subscription_id, resource_id, credential)
53
+
54
+ end_time = datetime.now(tz=timezone.utc)
55
+ start_time = end_time - timedelta(days=_LOOKBACK_DAYS)
56
+
57
+ # KQL query — finds the most recent write/action operation on this resource
58
+ query = f"""
59
+ AzureActivity
60
+ | where ResourceId =~ "{resource_id}"
61
+ | where OperationNameValue !endswith "/read"
62
+ | order by TimeGenerated desc
63
+ | take 1
64
+ | project TimeGenerated
65
+ """
66
+
67
+ try:
68
+ response = retry_on_transient(
69
+ client.query_workspace,
70
+ workspace_id=workspace_id,
71
+ query=query,
72
+ timespan=(start_time, end_time),
73
+ )
74
+ except HttpResponseError as exc:
75
+ logger.warning(
76
+ "azure_log_analytics_failed",
77
+ extra={"resource_id": resource_id, "error": str(exc)},
78
+ )
79
+ return None
80
+
81
+ if response.status != LogsQueryStatus.SUCCESS:
82
+ return None
83
+
84
+ for table in response.tables:
85
+ for row in table.rows:
86
+ event_time = row[0]
87
+ if isinstance(event_time, str):
88
+ from dateutil.parser import parse
89
+
90
+ event_time = parse(event_time)
91
+ if event_time and event_time.tzinfo is None:
92
+ event_time = event_time.replace(tzinfo=timezone.utc)
93
+ return event_time # type: ignore[no-any-return]
94
+
95
+ return None
96
+
97
+
98
+ def _fallback_from_activity_log_api(
99
+ subscription_id: str,
100
+ resource_id: str,
101
+ credential: Any,
102
+ ) -> datetime | None:
103
+ """
104
+ Direct Activity Log API fallback when Log Analytics workspace isn't configured.
105
+ Uses azure-mgmt-monitor to query the activity log REST endpoint directly.
106
+ Only available if azure-mgmt-monitor is installed.
107
+ """
108
+ try:
109
+ from azure.mgmt.monitor import (
110
+ MonitorManagementClient, # type: ignore[import-untyped]
111
+ )
112
+ except ImportError:
113
+ return None
114
+
115
+ cred = credential or DefaultAzureCredential()
116
+ client = MonitorManagementClient(
117
+ cred, subscription_id, connection_timeout=10, read_timeout=60
118
+ )
119
+
120
+ end_time = datetime.now(tz=timezone.utc)
121
+ start_time = end_time - timedelta(days=_LOOKBACK_DAYS)
122
+
123
+ filter_str = (
124
+ f"eventTimestamp ge '{start_time.isoformat()}' "
125
+ f"and eventTimestamp le '{end_time.isoformat()}' "
126
+ f"and resourceUri eq '{resource_id}'"
127
+ )
128
+
129
+ try:
130
+ events = list(
131
+ retry_on_transient(
132
+ client.activity_logs.list,
133
+ filter=filter_str,
134
+ select="eventTimestamp,operationName",
135
+ )
136
+ )
137
+ except HttpResponseError as exc:
138
+ logger.warning(
139
+ "azure_activity_log_api_failed",
140
+ extra={"resource_id": resource_id, "error": str(exc)},
141
+ )
142
+ return None
143
+
144
+ # Filter out read-only operations
145
+ write_events = [
146
+ e
147
+ for e in events
148
+ if e.operation_name
149
+ and not str(e.operation_name.value or "").lower().endswith("/read")
150
+ ]
151
+
152
+ if not write_events:
153
+ return None
154
+
155
+ # Events come back newest-first
156
+ event_time: datetime = write_events[0].event_timestamp
157
+ if event_time and event_time.tzinfo is None:
158
+ event_time = event_time.replace(tzinfo=timezone.utc)
159
+ return event_time
@@ -0,0 +1,117 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from datetime import datetime
5
+ from typing import Any
6
+
7
+ from adapters.azure import activity_log, cost_management, monitor, resource_graph
8
+ from adapters.base import CloudAdapter, MetricSummary, Resource
9
+
10
+
11
+ class AzureAdapter(CloudAdapter):
12
+ """
13
+ Azure implementation of CloudAdapter.
14
+ Wires together Resource Graph, Azure Monitor, Cost Management, and Activity Log.
15
+ All API calls are read-only.
16
+
17
+ Auth: DefaultAzureCredential — Managed Identity in production,
18
+ az login / env vars for local dev.
19
+
20
+ Usage:
21
+ adapter = AzureAdapter(subscription_ids=["sub-id-1", "sub-id-2"])
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ subscription_ids: list[str] | None = None,
27
+ credential: Any = None,
28
+ ) -> None:
29
+ resolved = subscription_ids or _parse_subscription_ids()
30
+ if not resolved:
31
+ raise EnvironmentError(
32
+ "No Azure subscription IDs configured. "
33
+ "Pass subscription_ids= or set AZURE_SUBSCRIPTION_IDS "
34
+ "(comma-separated)."
35
+ )
36
+ self._subscription_ids = resolved
37
+ self._credential = credential
38
+
39
+ def list_resources(self, ignore_regions: list[str] | None = None) -> list[Resource]:
40
+ return resource_graph.list_resources(
41
+ subscription_ids=self._subscription_ids,
42
+ ignore_regions=ignore_regions,
43
+ credential=self._credential,
44
+ )
45
+
46
+ def get_metrics(
47
+ self,
48
+ resource_id: str,
49
+ resource_type: str,
50
+ days: int = 90,
51
+ ) -> MetricSummary:
52
+ return monitor.get_metrics(
53
+ resource_id=resource_id,
54
+ resource_type=resource_type,
55
+ days=days,
56
+ credential=self._credential,
57
+ )
58
+
59
+ def get_cost(
60
+ self,
61
+ resource_ids: list[str],
62
+ days: int = 30,
63
+ ) -> dict[str, float]:
64
+ # Cost Management is scoped per subscription — group by subscription
65
+ # extracted from the resource ID and fan out.
66
+ by_sub: dict[str, list[str]] = {}
67
+ for rid in resource_ids:
68
+ sub = _subscription_from_resource_id(rid)
69
+ by_sub.setdefault(sub, []).append(rid)
70
+
71
+ costs: dict[str, float] = {}
72
+ for sub_id, rids in by_sub.items():
73
+ costs.update(
74
+ cost_management.get_cost(
75
+ subscription_id=sub_id,
76
+ resource_ids=rids,
77
+ days=days,
78
+ credential=self._credential,
79
+ )
80
+ )
81
+ return costs
82
+
83
+ def get_last_activity(
84
+ self,
85
+ resource_id: str,
86
+ resource_type: str,
87
+ ) -> datetime | None:
88
+ sub = _subscription_from_resource_id(resource_id)
89
+ return activity_log.get_last_activity(
90
+ subscription_id=sub,
91
+ resource_id=resource_id,
92
+ resource_type=resource_type,
93
+ credential=self._credential,
94
+ )
95
+
96
+ @classmethod
97
+ def from_env(cls) -> "AzureAdapter":
98
+ """Convenience constructor — reads all config from env vars."""
99
+ return cls(subscription_ids=_parse_subscription_ids())
100
+
101
+
102
+ def _parse_subscription_ids() -> list[str]:
103
+ raw = os.environ.get("AZURE_SUBSCRIPTION_IDS", "")
104
+ return [s.strip() for s in raw.split(",") if s.strip()]
105
+
106
+
107
+ def _subscription_from_resource_id(resource_id: str) -> str:
108
+ """
109
+ Extract subscription ID from an Azure resource ID.
110
+ Format: /subscriptions/{sub}/resourceGroups/{rg}/providers/...
111
+ """
112
+ parts = resource_id.lower().split("/")
113
+ try:
114
+ idx = parts.index("subscriptions")
115
+ return parts[idx + 1]
116
+ except (ValueError, IndexError):
117
+ return ""