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.
- adapters/__init__.py +0 -0
- adapters/aws/__init__.py +0 -0
- adapters/aws/adapter.py +85 -0
- adapters/aws/auth.py +57 -0
- adapters/aws/cloudtrail.py +83 -0
- adapters/aws/cloudwatch.py +732 -0
- adapters/aws/config.py +9 -0
- adapters/aws/cost_explorer.py +116 -0
- adapters/aws/resource_explorer.py +186 -0
- adapters/aws/retry.py +55 -0
- adapters/azure/__init__.py +0 -0
- adapters/azure/activity_log.py +159 -0
- adapters/azure/adapter.py +117 -0
- adapters/azure/cost_management.py +125 -0
- adapters/azure/monitor.py +311 -0
- adapters/azure/resource_graph.py +113 -0
- adapters/azure/retry.py +57 -0
- adapters/base.py +105 -0
- adapters/gcp/__init__.py +0 -0
- adapters/gcp/adapter.py +86 -0
- adapters/gcp/asset_inventory.py +116 -0
- adapters/gcp/billing.py +118 -0
- adapters/gcp/cloud_logging.py +93 -0
- adapters/gcp/cloud_monitoring.py +276 -0
- adapters/gcp/retry.py +46 -0
- ai/__init__.py +0 -0
- ai/anthropic.py +174 -0
- ai/azure_openai.py +241 -0
- ai/base.py +78 -0
- ai/bedrock.py +169 -0
- ai/vertexai.py +234 -0
- argus_cloud_optimizer-0.2.0.dist-info/METADATA +433 -0
- argus_cloud_optimizer-0.2.0.dist-info/RECORD +62 -0
- argus_cloud_optimizer-0.2.0.dist-info/WHEEL +5 -0
- argus_cloud_optimizer-0.2.0.dist-info/entry_points.txt +2 -0
- argus_cloud_optimizer-0.2.0.dist-info/licenses/LICENSE +21 -0
- argus_cloud_optimizer-0.2.0.dist-info/top_level.txt +4 -0
- core/__init__.py +0 -0
- core/__version__.py +1 -0
- core/agent/__init__.py +0 -0
- core/agent/loop.py +390 -0
- core/agent/prompts.py +317 -0
- core/config.py +235 -0
- core/log.py +69 -0
- core/models/__init__.py +0 -0
- core/models/finding.py +76 -0
- core/py.typed +0 -0
- core/reports/__init__.py +0 -0
- core/reports/comparison.py +49 -0
- core/reports/delivery.py +323 -0
- core/reports/export.py +111 -0
- core/reports/generator.py +168 -0
- core/reports/html.py +286 -0
- core/reports/multi_cloud.py +162 -0
- core/secrets.py +145 -0
- core/token_tracker.py +97 -0
- core/validation.py +214 -0
- entrypoints/__init__.py +0 -0
- entrypoints/aws_lambda.py +299 -0
- entrypoints/azure_function.py +257 -0
- entrypoints/cli.py +156 -0
- entrypoints/gcp_cloudrun.py +209 -0
adapters/aws/config.py
ADDED
|
@@ -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 ""
|