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
core/secrets.py ADDED
@@ -0,0 +1,145 @@
1
+ """
2
+ Secret manager integration — resolve secret references in environment variables.
3
+
4
+ If an env var's value matches a secret reference pattern, the real value is
5
+ fetched from the corresponding cloud secret manager and the env var is updated
6
+ in-place before the config layer reads it.
7
+
8
+ Supported patterns:
9
+ arn:aws:secretsmanager:<region>:<account>:secret:<name>
10
+ → AWS Secrets Manager
11
+ gcp-secret://<project>/<secret-name>[/<version>]
12
+ → GCP Secret Manager
13
+ akv://<vault-name>/<secret-name>
14
+ → Azure Key Vault
15
+
16
+ Call ``resolve_secrets()`` once at startup, before ``validate_environment()``.
17
+ It is safe to call when no secret references exist — it's a no-op.
18
+
19
+ This module lives in core/ but imports cloud SDKs lazily inside the resolver
20
+ functions (only when a matching reference is found). If the required SDK is
21
+ not installed, a clear error is raised.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ import re
28
+
29
+ import structlog
30
+
31
+ logger = structlog.get_logger(__name__)
32
+
33
+ _SECRET_VARS = (
34
+ "ANTHROPIC_API_KEY",
35
+ "SLACK_WEBHOOK_URL",
36
+ "TEAMS_WEBHOOK_URL",
37
+ "WEBHOOK_URL",
38
+ "AZURE_OPENAI_API_KEY",
39
+ "AZURE_OPENAI_ENDPOINT",
40
+ )
41
+
42
+ _AWS_ARN_PATTERN = re.compile(
43
+ r"^arn:aws:secretsmanager:[\w-]+:\d+:secret:.+"
44
+ )
45
+ _GCP_PATTERN = re.compile(r"^gcp-secret://([^/]+)/([^/]+)(?:/([^/]+))?$")
46
+ _AKV_PATTERN = re.compile(r"^akv://([^/]+)/(.+)$")
47
+
48
+
49
+ def resolve_secrets() -> None:
50
+ """
51
+ Scan secret-eligible env vars for reference patterns and resolve them.
52
+
53
+ Updates ``os.environ`` in-place so downstream code (config layer,
54
+ validation, providers) sees the real values transparently.
55
+ """
56
+ for var in _SECRET_VARS:
57
+ value = os.environ.get(var, "")
58
+ if not value:
59
+ continue
60
+
61
+ resolved = _try_resolve(var, value)
62
+ if resolved is not None:
63
+ os.environ[var] = resolved
64
+ logger.info("secret_resolved", var=var)
65
+
66
+
67
+ def _try_resolve(var: str, value: str) -> str | None:
68
+ """Return the resolved secret value, or None if the value is not a reference."""
69
+ if _AWS_ARN_PATTERN.match(value):
70
+ return _resolve_aws(var, value)
71
+ match = _GCP_PATTERN.match(value)
72
+ if match:
73
+ project, name, version = match.groups()
74
+ return _resolve_gcp(var, project, name, version or "latest")
75
+ match = _AKV_PATTERN.match(value)
76
+ if match:
77
+ vault, name = match.groups()
78
+ return _resolve_azure(var, vault, name)
79
+ return None
80
+
81
+
82
+ def _resolve_aws(var: str, arn: str) -> str:
83
+ try:
84
+ import boto3
85
+ from botocore.exceptions import ClientError
86
+ except ImportError:
87
+ raise ImportError(
88
+ f"{var} references AWS Secrets Manager ({arn}) but boto3 is not "
89
+ "installed. Install with: pip install boto3"
90
+ ) from None
91
+
92
+ region = arn.split(":")[3]
93
+ client = boto3.client("secretsmanager", region_name=region)
94
+ try:
95
+ resp = client.get_secret_value(SecretId=arn)
96
+ except ClientError as exc:
97
+ raise RuntimeError(
98
+ f"Failed to resolve {var} from AWS Secrets Manager: {exc}"
99
+ ) from exc
100
+ return resp["SecretString"]
101
+
102
+
103
+ def _resolve_gcp(var: str, project: str, name: str, version: str) -> str:
104
+ try:
105
+ from google.cloud import secretmanager
106
+ except ImportError:
107
+ raise ImportError(
108
+ f"{var} references GCP Secret Manager "
109
+ f"(gcp-secret://{project}/{name}/{version}) but "
110
+ "google-cloud-secret-manager is not installed. "
111
+ "Install with: pip install google-cloud-secret-manager"
112
+ ) from None
113
+
114
+ client = secretmanager.SecretManagerServiceClient()
115
+ resource = f"projects/{project}/secrets/{name}/versions/{version}"
116
+ try:
117
+ resp = client.access_secret_version(request={"name": resource})
118
+ except Exception as exc:
119
+ raise RuntimeError(
120
+ f"Failed to resolve {var} from GCP Secret Manager: {exc}"
121
+ ) from exc
122
+ return resp.payload.data.decode("utf-8")
123
+
124
+
125
+ def _resolve_azure(var: str, vault_name: str, secret_name: str) -> str:
126
+ try:
127
+ from azure.identity import DefaultAzureCredential
128
+ from azure.keyvault.secrets import SecretClient
129
+ except ImportError:
130
+ raise ImportError(
131
+ f"{var} references Azure Key Vault "
132
+ f"(akv://{vault_name}/{secret_name}) but azure-keyvault-secrets "
133
+ "is not installed. Install with: pip install azure-keyvault-secrets "
134
+ "azure-identity"
135
+ ) from None
136
+
137
+ vault_url = f"https://{vault_name}.vault.azure.net"
138
+ client = SecretClient(vault_url=vault_url, credential=DefaultAzureCredential())
139
+ try:
140
+ secret = client.get_secret(secret_name)
141
+ except Exception as exc:
142
+ raise RuntimeError(
143
+ f"Failed to resolve {var} from Azure Key Vault: {exc}"
144
+ ) from exc
145
+ return secret.value
core/token_tracker.py ADDED
@@ -0,0 +1,97 @@
1
+ """
2
+ LLM token and cost tracking with hard budget enforcement.
3
+
4
+ Tracks cumulative input/output tokens across agent iterations and estimates
5
+ USD cost using per-provider pricing. When ``LLM_BUDGET_USD`` is exceeded,
6
+ raises ``BudgetExceededError`` so the agent loop can abort gracefully and
7
+ still return partial findings.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+
14
+ import structlog
15
+
16
+ logger = structlog.get_logger(__name__)
17
+
18
+ # Per-million-token pricing (input, output) by provider.
19
+ # Updated 2025-05 — check provider pricing pages for current rates.
20
+ _PRICING: dict[str, tuple[float, float]] = {
21
+ "anthropic": (3.0, 15.0),
22
+ "bedrock": (3.0, 15.0),
23
+ "vertexai": (1.25, 5.0),
24
+ "azure_openai": (2.50, 10.0),
25
+ }
26
+
27
+ _DEFAULT_PRICING = (3.0, 15.0)
28
+
29
+
30
+ class BudgetExceededError(Exception):
31
+ """Raised when cumulative LLM cost exceeds the configured budget."""
32
+
33
+ def __init__(self, spent_usd: float, budget_usd: float) -> None:
34
+ self.spent_usd = spent_usd
35
+ self.budget_usd = budget_usd
36
+ super().__init__(
37
+ f"LLM budget exceeded: ${spent_usd:.4f} spent "
38
+ f"(budget: ${budget_usd:.2f})"
39
+ )
40
+
41
+
42
+ @dataclass
43
+ class TokenTracker:
44
+ """Accumulates token usage and enforces a hard USD budget."""
45
+
46
+ budget_usd: float
47
+ provider: str = "anthropic"
48
+
49
+ total_input_tokens: int = field(default=0, init=False)
50
+ total_output_tokens: int = field(default=0, init=False)
51
+ iteration_count: int = field(default=0, init=False)
52
+ _per_iteration: list[dict[str, int]] = field(default_factory=list, init=False)
53
+
54
+ def record(self, input_tokens: int, output_tokens: int) -> None:
55
+ """
56
+ Record tokens from one AI call and check the budget.
57
+
58
+ Raises ``BudgetExceededError`` if cumulative cost exceeds budget.
59
+ """
60
+ self.total_input_tokens += input_tokens
61
+ self.total_output_tokens += output_tokens
62
+ self.iteration_count += 1
63
+ self._per_iteration.append(
64
+ {"input": input_tokens, "output": output_tokens}
65
+ )
66
+
67
+ spent = self.estimated_cost_usd
68
+ logger.info(
69
+ "token_usage",
70
+ iteration=self.iteration_count,
71
+ input_tokens=input_tokens,
72
+ output_tokens=output_tokens,
73
+ cumulative_input=self.total_input_tokens,
74
+ cumulative_output=self.total_output_tokens,
75
+ spent_usd=round(spent, 4),
76
+ budget_usd=self.budget_usd,
77
+ )
78
+
79
+ if self.budget_usd > 0 and spent > self.budget_usd:
80
+ raise BudgetExceededError(round(spent, 4), self.budget_usd)
81
+
82
+ @property
83
+ def estimated_cost_usd(self) -> float:
84
+ input_rate, output_rate = _PRICING.get(self.provider, _DEFAULT_PRICING)
85
+ cost = (self.total_input_tokens / 1_000_000 * input_rate) + (
86
+ self.total_output_tokens / 1_000_000 * output_rate
87
+ )
88
+ return round(cost, 4)
89
+
90
+ def summary(self) -> dict[str, float | int]:
91
+ return {
92
+ "total_input_tokens": self.total_input_tokens,
93
+ "total_output_tokens": self.total_output_tokens,
94
+ "iterations": self.iteration_count,
95
+ "estimated_cost_usd": self.estimated_cost_usd,
96
+ "budget_usd": self.budget_usd,
97
+ }
core/validation.py ADDED
@@ -0,0 +1,214 @@
1
+ """
2
+ Startup environment validation.
3
+
4
+ Called once at the top of each entrypoint before any cloud API calls are made.
5
+ Raises ConfigurationError with a clear, actionable message if required env vars
6
+ are missing or malformed. This prevents wasting a 15-minute scan that would
7
+ fail at the very end due to a missing credential.
8
+
9
+ No cloud SDK imports here — this is core/ and must stay cloud-free.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import urllib.parse
17
+
18
+
19
+ class ConfigurationError(Exception):
20
+ """Raised when the environment is misconfigured at startup."""
21
+
22
+
23
+ def validate_environment(cloud: str) -> None:
24
+ """
25
+ Validate all required environment variables for the given cloud.
26
+
27
+ Args:
28
+ cloud: "aws" | "gcp" | "azure"
29
+
30
+ Raises:
31
+ ConfigurationError: with a human-readable message describing every
32
+ problem found (not just the first one).
33
+ """
34
+ errors: list[str] = []
35
+
36
+ _check_ai_provider(errors)
37
+ _check_slack(errors)
38
+
39
+ if cloud == "aws":
40
+ _check_aws(errors)
41
+ elif cloud == "gcp":
42
+ _check_gcp(errors)
43
+ elif cloud == "azure":
44
+ _check_azure(errors)
45
+
46
+ if errors:
47
+ bullet_list = "\n".join(f" • {e}" for e in errors)
48
+ raise ConfigurationError(
49
+ f"Argus cannot start — {len(errors)} configuration error(s) found:\n"
50
+ f"{bullet_list}\n\n"
51
+ "Fix the above and re-deploy or re-run."
52
+ )
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Shared checks
57
+ # ---------------------------------------------------------------------------
58
+
59
+
60
+ def _check_ai_provider(errors: list[str]) -> None:
61
+ provider = os.environ.get("AI_PROVIDER", "").strip().lower()
62
+
63
+ # Each cloud has its own default, so an empty AI_PROVIDER is fine —
64
+ # the entrypoint will pick the cloud-native default.
65
+ if not provider:
66
+ return
67
+
68
+ known = {"anthropic", "bedrock", "vertexai", "azure_openai"}
69
+ if provider not in known:
70
+ errors.append(
71
+ f"AI_PROVIDER={provider!r} is not recognised. "
72
+ f"Valid values: {', '.join(sorted(known))}."
73
+ )
74
+ return # No point checking credentials for an unknown provider.
75
+
76
+ if provider == "anthropic":
77
+ key = os.environ.get("ANTHROPIC_API_KEY", "").strip()
78
+ if not key:
79
+ errors.append(
80
+ "AI_PROVIDER=anthropic requires ANTHROPIC_API_KEY to be set. "
81
+ "Get a key at https://console.anthropic.com/settings/api-keys"
82
+ )
83
+ elif not key.startswith("sk-ant-"):
84
+ errors.append(
85
+ "ANTHROPIC_API_KEY looks malformed "
86
+ "(expected it to start with 'sk-ant-'). "
87
+ "Check the key in the Anthropic console."
88
+ )
89
+
90
+ if provider == "azure_openai":
91
+ endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT", "").strip()
92
+ if not endpoint:
93
+ errors.append(
94
+ "AI_PROVIDER=azure_openai requires AZURE_OPENAI_ENDPOINT to be set. "
95
+ "Example: https://<resource>.openai.azure.com/"
96
+ )
97
+ elif not _is_https_url(endpoint):
98
+ errors.append(
99
+ f"AZURE_OPENAI_ENDPOINT={endpoint!r} is not a valid HTTPS URL."
100
+ )
101
+
102
+
103
+ def _check_slack(errors: list[str]) -> None:
104
+ dry_run = os.environ.get("DRY_RUN", "false").lower() in ("true", "1", "yes")
105
+ if dry_run:
106
+ return # Webhook not needed in dry-run mode.
107
+
108
+ url = os.environ.get("SLACK_WEBHOOK_URL", "").strip()
109
+ if not url:
110
+ errors.append(
111
+ "SLACK_WEBHOOK_URL is not set. "
112
+ "Create an incoming webhook at https://api.slack.com/apps "
113
+ "or set DRY_RUN=true to skip Slack delivery."
114
+ )
115
+ return
116
+
117
+ if not _is_https_url(url):
118
+ errors.append(
119
+ f"SLACK_WEBHOOK_URL={url!r} is not a valid HTTPS URL. "
120
+ "Expected format: https://hooks.slack.com/services/..."
121
+ )
122
+
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # Cloud-specific checks
126
+ # ---------------------------------------------------------------------------
127
+
128
+
129
+ def _check_aws(errors: list[str]) -> None:
130
+ accounts_mode = os.environ.get("ACCOUNTS_MODE", "single").lower()
131
+
132
+ if accounts_mode not in ("single", "multi"):
133
+ errors.append(
134
+ f"ACCOUNTS_MODE={accounts_mode!r} is not valid. " "Use 'single' or 'multi'."
135
+ )
136
+
137
+ if accounts_mode == "multi":
138
+ raw = os.environ.get("ACCOUNTS_CONFIG", "").strip()
139
+ if not raw:
140
+ errors.append(
141
+ "ACCOUNTS_MODE=multi requires ACCOUNTS_CONFIG to be set. "
142
+ "Set it to a JSON array of account objects: "
143
+ '[{"id":"123456789012","name":"prod","role_arn":"arn:aws:iam::..."}]'
144
+ )
145
+ return
146
+
147
+ try:
148
+ accounts = json.loads(raw)
149
+ except json.JSONDecodeError as exc:
150
+ errors.append(
151
+ f"ACCOUNTS_CONFIG is not valid JSON: {exc}. "
152
+ "Expected a JSON array of account objects."
153
+ )
154
+ return
155
+
156
+ if not isinstance(accounts, list) or len(accounts) == 0:
157
+ errors.append(
158
+ "ACCOUNTS_CONFIG must be a non-empty JSON array of account objects."
159
+ )
160
+ return
161
+
162
+ for i, acct in enumerate(accounts):
163
+ if not isinstance(acct, dict):
164
+ errors.append(
165
+ f"ACCOUNTS_CONFIG[{i}] is not an object — each account must be "
166
+ '{"id": "...", "name": "...", "role_arn": "..."}.'
167
+ )
168
+ continue
169
+ missing = [f for f in ("id", "role_arn") if not acct.get(f)]
170
+ if missing:
171
+ name = acct.get("name", f"index {i}")
172
+ errors.append(
173
+ f"ACCOUNTS_CONFIG account '{name}' is missing required "
174
+ f"field(s): {', '.join(missing)}."
175
+ )
176
+
177
+
178
+ def _check_gcp(errors: list[str]) -> None:
179
+ project_id = os.environ.get("GCP_PROJECT_ID", "").strip()
180
+ if not project_id:
181
+ errors.append(
182
+ "GCP_PROJECT_ID is required for GCP scans. "
183
+ "Set it to your GCP project ID (e.g. my-project-123)."
184
+ )
185
+
186
+
187
+ def _check_azure(errors: list[str]) -> None:
188
+ raw = os.environ.get("AZURE_SUBSCRIPTION_IDS", "").strip()
189
+ if not raw:
190
+ errors.append(
191
+ "AZURE_SUBSCRIPTION_IDS is required for Azure scans. "
192
+ "Set it to one or more subscription IDs separated by commas."
193
+ )
194
+ return
195
+
196
+ subscription_ids = [s.strip() for s in raw.split(",") if s.strip()]
197
+ if not subscription_ids:
198
+ errors.append(
199
+ "AZURE_SUBSCRIPTION_IDS is set but contains no valid IDs. "
200
+ "Expected one or more GUIDs separated by commas."
201
+ )
202
+
203
+
204
+ # ---------------------------------------------------------------------------
205
+ # Helpers
206
+ # ---------------------------------------------------------------------------
207
+
208
+
209
+ def _is_https_url(value: str) -> bool:
210
+ try:
211
+ parsed = urllib.parse.urlparse(value)
212
+ return parsed.scheme == "https" and bool(parsed.netloc)
213
+ except ValueError:
214
+ return False
File without changes