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/__init__.py ADDED
File without changes
File without changes
@@ -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