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/__init__.py
ADDED
|
File without changes
|
adapters/aws/__init__.py
ADDED
|
File without changes
|
adapters/aws/adapter.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
import boto3
|
|
7
|
+
|
|
8
|
+
from adapters.aws import auth, cloudtrail, cloudwatch, cost_explorer, resource_explorer
|
|
9
|
+
from adapters.base import CloudAdapter, MetricSummary, Resource
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AWSAdapter(CloudAdapter):
|
|
13
|
+
"""
|
|
14
|
+
AWS implementation of CloudAdapter.
|
|
15
|
+
Wires together Resource Explorer, CloudWatch, Cost Explorer, and CloudTrail.
|
|
16
|
+
All boto3 calls are read-only — no mutations to cloud resources.
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
session = auth.get_session(account=account_config, region="us-east-1")
|
|
20
|
+
adapter = AWSAdapter(session)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
session: boto3.Session,
|
|
26
|
+
aggregator_region: str | None = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
self._session = session
|
|
29
|
+
self._aggregator_region: str = (
|
|
30
|
+
aggregator_region
|
|
31
|
+
or os.environ.get("RESOURCE_EXPLORER_REGION")
|
|
32
|
+
or "us-east-1"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def list_resources(self, ignore_regions: list[str] | None = None) -> list[Resource]:
|
|
36
|
+
return resource_explorer.list_resources(
|
|
37
|
+
session=self._session,
|
|
38
|
+
ignore_regions=ignore_regions,
|
|
39
|
+
aggregator_region=self._aggregator_region,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def get_metrics(
|
|
43
|
+
self,
|
|
44
|
+
resource_id: str,
|
|
45
|
+
resource_type: str,
|
|
46
|
+
days: int = 90,
|
|
47
|
+
) -> MetricSummary:
|
|
48
|
+
return cloudwatch.get_metrics(
|
|
49
|
+
session=self._session,
|
|
50
|
+
resource_id=resource_id,
|
|
51
|
+
resource_type=resource_type,
|
|
52
|
+
days=days,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def get_cost(
|
|
56
|
+
self,
|
|
57
|
+
resource_ids: list[str],
|
|
58
|
+
days: int = 30,
|
|
59
|
+
) -> dict[str, float]:
|
|
60
|
+
return cost_explorer.get_cost(
|
|
61
|
+
session=self._session,
|
|
62
|
+
resource_ids=resource_ids,
|
|
63
|
+
days=days,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def get_last_activity(
|
|
67
|
+
self,
|
|
68
|
+
resource_id: str,
|
|
69
|
+
resource_type: str,
|
|
70
|
+
) -> datetime | None:
|
|
71
|
+
return cloudtrail.get_last_activity(
|
|
72
|
+
session=self._session,
|
|
73
|
+
resource_id=resource_id,
|
|
74
|
+
resource_type=resource_type,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def for_account(
|
|
79
|
+
cls,
|
|
80
|
+
account: dict | None = None,
|
|
81
|
+
region: str = "us-east-1",
|
|
82
|
+
) -> "AWSAdapter":
|
|
83
|
+
"""Convenience constructor — resolves auth and returns a ready adapter."""
|
|
84
|
+
session = auth.get_session(account=account, region=region)
|
|
85
|
+
return cls(session=session, aggregator_region=region)
|
adapters/aws/auth.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import boto3
|
|
4
|
+
import structlog
|
|
5
|
+
from botocore.exceptions import ClientError
|
|
6
|
+
|
|
7
|
+
logger = structlog.get_logger(__name__)
|
|
8
|
+
|
|
9
|
+
SESSION_DURATION_SECONDS = 3600 # 1 hour — short-lived, auto-expires
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_session(
|
|
13
|
+
account: dict | None = None,
|
|
14
|
+
region: str = "us-east-1",
|
|
15
|
+
) -> boto3.Session:
|
|
16
|
+
"""
|
|
17
|
+
Return an authenticated boto3 Session for the given account config.
|
|
18
|
+
|
|
19
|
+
Single-account (account is None or has no role_arn):
|
|
20
|
+
Uses the ambient credential chain — Lambda execution role in production,
|
|
21
|
+
~/.aws/credentials profile in local dev.
|
|
22
|
+
|
|
23
|
+
Multi-account (account has role_arn):
|
|
24
|
+
Assumes the specified IAM role via STS. Credentials are temporary
|
|
25
|
+
(1 hour), scoped to read-only permissions in the target account,
|
|
26
|
+
and never stored anywhere.
|
|
27
|
+
"""
|
|
28
|
+
if not account or not account.get("role_arn"):
|
|
29
|
+
logger.debug("auth_single_account", region=region)
|
|
30
|
+
return boto3.Session(region_name=region)
|
|
31
|
+
|
|
32
|
+
role_arn = account["role_arn"]
|
|
33
|
+
account_name = account.get("name", account.get("id", "unknown"))
|
|
34
|
+
|
|
35
|
+
logger.info("auth_assuming_role", account=account_name, role_arn=role_arn)
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
sts = boto3.client("sts", region_name=region)
|
|
39
|
+
response = sts.assume_role(
|
|
40
|
+
RoleArn=role_arn,
|
|
41
|
+
RoleSessionName=f"ArgusScan-{account_name}",
|
|
42
|
+
DurationSeconds=SESSION_DURATION_SECONDS,
|
|
43
|
+
)
|
|
44
|
+
except ClientError as exc:
|
|
45
|
+
raise PermissionError(
|
|
46
|
+
f"Failed to assume role {role_arn} for account '{account_name}'. "
|
|
47
|
+
f"Check that the spoke IAM role exists and trusts this account. "
|
|
48
|
+
f"AWS error: {exc}"
|
|
49
|
+
) from exc
|
|
50
|
+
|
|
51
|
+
creds = response["Credentials"]
|
|
52
|
+
return boto3.Session(
|
|
53
|
+
aws_access_key_id=creds["AccessKeyId"],
|
|
54
|
+
aws_secret_access_key=creds["SecretAccessKey"],
|
|
55
|
+
aws_session_token=creds["SessionToken"],
|
|
56
|
+
region_name=region,
|
|
57
|
+
)
|
|
@@ -0,0 +1,83 @@
|
|
|
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
|
+
_LOOKBACK_DAYS = 90 # CloudTrail LookupEvents max window is 90 days
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_last_activity(
|
|
18
|
+
session: boto3.Session,
|
|
19
|
+
resource_id: str,
|
|
20
|
+
resource_type: str,
|
|
21
|
+
) -> datetime | None:
|
|
22
|
+
"""
|
|
23
|
+
Return the timestamp of the most recent CloudTrail event for a resource.
|
|
24
|
+
Returns None if no activity was found in the last 90 days.
|
|
25
|
+
|
|
26
|
+
Uses the resource name (extracted from ARN) as the lookup attribute
|
|
27
|
+
since CloudTrail indexes by resource name, not full ARN.
|
|
28
|
+
"""
|
|
29
|
+
region = _region_from_arn(resource_id)
|
|
30
|
+
resource_name = _resource_name_from_arn(resource_id)
|
|
31
|
+
client = session.client(
|
|
32
|
+
"cloudtrail", region_name=region, config=BOTO_TIMEOUT_CONFIG
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
end_time = datetime.now(tz=timezone.utc)
|
|
36
|
+
start_time = end_time - timedelta(days=_LOOKBACK_DAYS)
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
response = retry_on_transient(
|
|
40
|
+
client.lookup_events,
|
|
41
|
+
LookupAttributes=[
|
|
42
|
+
{"AttributeKey": "ResourceName", "AttributeValue": resource_name}
|
|
43
|
+
],
|
|
44
|
+
StartTime=start_time,
|
|
45
|
+
EndTime=end_time,
|
|
46
|
+
MaxResults=1,
|
|
47
|
+
)
|
|
48
|
+
except ClientError as exc:
|
|
49
|
+
logger.warning(
|
|
50
|
+
"cloudtrail_lookup_failed",
|
|
51
|
+
extra={"resource_id": resource_id, "error": str(exc)},
|
|
52
|
+
)
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
events = response.get("Events", [])
|
|
56
|
+
if not events:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
event_time = events[0]["EventTime"]
|
|
60
|
+
if event_time.tzinfo is None:
|
|
61
|
+
event_time = event_time.replace(tzinfo=timezone.utc)
|
|
62
|
+
return event_time # type: ignore[no-any-return]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _region_from_arn(arn: str) -> str:
|
|
66
|
+
parts = arn.split(":")
|
|
67
|
+
region = parts[3] if len(parts) > 3 else ""
|
|
68
|
+
return region or "us-east-1"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _resource_name_from_arn(arn: str) -> str:
|
|
72
|
+
"""
|
|
73
|
+
Extract the short resource name from an ARN for CloudTrail lookup.
|
|
74
|
+
CloudTrail indexes by resource name (e.g. 'i-0abc123'), not full ARN.
|
|
75
|
+
"""
|
|
76
|
+
parts = arn.split(":")
|
|
77
|
+
resource_part = ":".join(parts[5:])
|
|
78
|
+
|
|
79
|
+
if "/" in resource_part:
|
|
80
|
+
return resource_part.split("/")[-1]
|
|
81
|
+
if ":" in resource_part:
|
|
82
|
+
return resource_part.split(":")[-1]
|
|
83
|
+
return resource_part
|