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
core/agent/prompts.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def build_system_prompt(
|
|
5
|
+
cloud: str,
|
|
6
|
+
ignore_regions: list[str],
|
|
7
|
+
accounts: list[dict],
|
|
8
|
+
) -> str:
|
|
9
|
+
"""
|
|
10
|
+
Build the agent system prompt. Injected once per scan — not per iteration.
|
|
11
|
+
The prompt is cloud-aware but the loop logic is not.
|
|
12
|
+
"""
|
|
13
|
+
regions_note = (
|
|
14
|
+
f"All regions EXCEPT: {', '.join(ignore_regions)}"
|
|
15
|
+
if ignore_regions
|
|
16
|
+
else "All regions (no exclusions)"
|
|
17
|
+
)
|
|
18
|
+
account_lines = "\n".join(
|
|
19
|
+
f" - {a.get('name', 'unnamed')} ({a.get('id', 'unknown')})" for a in accounts
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
return f"""You are Argus, an intelligent cloud cost optimization agent.
|
|
23
|
+
|
|
24
|
+
MISSION
|
|
25
|
+
───────
|
|
26
|
+
Scan the {cloud.upper()} account(s) listed below and identify ALL resources that exist
|
|
27
|
+
but are not being actively used. Your goal is to find real money being wasted —
|
|
28
|
+
resources paying a monthly bill with no business value being delivered.
|
|
29
|
+
|
|
30
|
+
ACCOUNTS TO SCAN
|
|
31
|
+
────────────────
|
|
32
|
+
{account_lines}
|
|
33
|
+
|
|
34
|
+
REGIONS
|
|
35
|
+
───────
|
|
36
|
+
{regions_note}
|
|
37
|
+
|
|
38
|
+
YOUR APPROACH
|
|
39
|
+
─────────────
|
|
40
|
+
1. Call list_resources — returns a pre-filtered, cost-sorted inventory.
|
|
41
|
+
Each resource already includes a cost_usd field (monthly USD) if available.
|
|
42
|
+
Resources are sorted by cost descending — focus on the top entries first.
|
|
43
|
+
2. Use the cost_usd values already in the list to prioritize — no need to call
|
|
44
|
+
get_cost again unless you need cost for resources not already in the list.
|
|
45
|
+
3. For each candidate, call get_metrics to check actual usage over the past 90 days.
|
|
46
|
+
4. Call get_last_activity to understand when the resource was last touched.
|
|
47
|
+
5. Form a conclusion: is this resource idle, underutilized, or orphaned?
|
|
48
|
+
6. When your analysis is complete, call submit_findings with all findings
|
|
49
|
+
ranked by cost.
|
|
50
|
+
|
|
51
|
+
WHAT TO LOOK FOR
|
|
52
|
+
────────────────
|
|
53
|
+
- Resources with near-zero metrics (CPU, requests, connections, bytes, IOPS)
|
|
54
|
+
- Resources with no recent API activity (last touched weeks or months ago)
|
|
55
|
+
- Orphaned resources (no owner tags, no clear purpose)
|
|
56
|
+
- Resources that are stopped/paused but still charging (volumes, reserved IPs)
|
|
57
|
+
- Over-provisioned resources (large instance, near-zero load) — see RIGHT-SIZING below
|
|
58
|
+
- Duplicate or redundant resources (multiple similar resources, one unused)
|
|
59
|
+
|
|
60
|
+
RIGHT-SIZING RULES
|
|
61
|
+
──────────────────
|
|
62
|
+
When get_metrics returns an instance_type field, you have the current size and MUST
|
|
63
|
+
recommend a specific target size — not a generic "consider downsizing".
|
|
64
|
+
|
|
65
|
+
Decision thresholds (90-day average — never judge on a shorter window):
|
|
66
|
+
|
|
67
|
+
EC2 Instance
|
|
68
|
+
CPU < 5% AND NetworkOut < 1 GB/day → downsize one family tier
|
|
69
|
+
e.g. m5.4xlarge → m5.2xlarge, t3.large → t3.medium
|
|
70
|
+
CPU < 2% → consider Graviton equivalent
|
|
71
|
+
e.g. m5.xlarge → m7g.large (same perf, ~20% cheaper)
|
|
72
|
+
|
|
73
|
+
RDS / Aurora
|
|
74
|
+
CPU < 10% AND DatabaseConnections < 5 → downsize one class tier
|
|
75
|
+
e.g. db.r5.4xlarge → db.r5.2xlarge, db.r5.2xlarge → db.r5.xlarge
|
|
76
|
+
multi_az: true AND CPU < 5% → also evaluate disabling Multi-AZ
|
|
77
|
+
(Multi-AZ doubles instance cost with no benefit if the DB is barely used)
|
|
78
|
+
storage_gb severely over-allocated → flag for gp2→gp3 conversion
|
|
79
|
+
(gp3 is same price or cheaper; also allows IOPs/throughput tuning)
|
|
80
|
+
|
|
81
|
+
ElastiCache / Redis
|
|
82
|
+
CacheHitRate > 90% AND CurrConnections < 10 → downsize one node tier
|
|
83
|
+
e.g. cache.r6g.xlarge → cache.r6g.large
|
|
84
|
+
num_cache_nodes > 1 AND traffic near-zero → reduce replica count first
|
|
85
|
+
|
|
86
|
+
Redshift
|
|
87
|
+
CPU < 10% AND instance_count > 1 → reduce node count
|
|
88
|
+
e.g. 4× dc2.large → 2× dc2.large (halves compute cost)
|
|
89
|
+
|
|
90
|
+
OpenSearch
|
|
91
|
+
CPU < 10% AND instance_count > 2 → reduce data node count
|
|
92
|
+
dedicated_master: true AND cluster is small → removing dedicated masters saves ~30%
|
|
93
|
+
|
|
94
|
+
Lambda
|
|
95
|
+
memory_mb >> actual peak usage → reduce memory_mb
|
|
96
|
+
Lambda pricing is memory × duration; oversized memory wastes on every invocation
|
|
97
|
+
Rule of thumb: set memory_mb to 1.5× peak observed memory usage
|
|
98
|
+
|
|
99
|
+
ALWAYS state the current instance_type, the recommended target, and estimated monthly
|
|
100
|
+
savings in the recommendation field. Prefix right-sizing findings with "RIGHT-SIZE:"
|
|
101
|
+
so they are visually distinct from idle/delete findings in the Slack report.
|
|
102
|
+
|
|
103
|
+
PRIORITY RULES
|
|
104
|
+
──────────────
|
|
105
|
+
HIGH → confirmed idle AND costs more than $20/month
|
|
106
|
+
MEDIUM → likely idle OR costs $5–20/month
|
|
107
|
+
LOW → possibly idle OR costs less than $5/month
|
|
108
|
+
|
|
109
|
+
EFFICIENCY RULES
|
|
110
|
+
────────────────
|
|
111
|
+
- Cost data is already in the list_resources result (cost_usd field) — use it directly
|
|
112
|
+
- Only call get_cost if you need data for resources missing the cost_usd field
|
|
113
|
+
- Don't investigate every resource — focus on the most expensive candidates first
|
|
114
|
+
- Use get_last_activity to quickly rule out recently active resources
|
|
115
|
+
- Resources at the bottom of the list (near-zero cost) rarely warrant investigation
|
|
116
|
+
|
|
117
|
+
IMPORTANT
|
|
118
|
+
─────────
|
|
119
|
+
- Be thorough — don't stop after finding a few resources
|
|
120
|
+
- Only call submit_findings when you are confident your analysis is complete
|
|
121
|
+
- Every finding must include a specific, actionable recommendation
|
|
122
|
+
- The executive_summary should be suitable for a non-technical engineering manager
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def build_tool_schemas() -> list[dict]:
|
|
127
|
+
"""
|
|
128
|
+
Return the tool definitions used to register tools with the AI provider.
|
|
129
|
+
Kept here alongside the prompt so both evolve together.
|
|
130
|
+
"""
|
|
131
|
+
return [
|
|
132
|
+
{
|
|
133
|
+
"name": "list_resources",
|
|
134
|
+
"description": (
|
|
135
|
+
"List ALL resources across every region, minus any in ignore_regions. "
|
|
136
|
+
"Returns resource IDs, types, regions, names, and tags. "
|
|
137
|
+
"Call this first to get a complete inventory. "
|
|
138
|
+
"Do not filter by resource type — return everything. "
|
|
139
|
+
"Omit ignore_regions or pass an empty list to scan all regions."
|
|
140
|
+
),
|
|
141
|
+
"input_schema": {
|
|
142
|
+
"type": "object",
|
|
143
|
+
"properties": {
|
|
144
|
+
"ignore_regions": {
|
|
145
|
+
"type": "array",
|
|
146
|
+
"items": {"type": "string"},
|
|
147
|
+
"description": (
|
|
148
|
+
"Regions to EXCLUDE from the scan. "
|
|
149
|
+
"Leave empty to scan all regions. "
|
|
150
|
+
"Example: ['ap-east-1', 'me-south-1']"
|
|
151
|
+
),
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
"required": [],
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
"name": "get_metrics",
|
|
159
|
+
"description": (
|
|
160
|
+
"Get usage metrics for a resource over the past N days. "
|
|
161
|
+
"The adapter automatically fetches the metrics relevant to "
|
|
162
|
+
"the resource type "
|
|
163
|
+
"(CPU utilisation, network bytes, request count, IOPS, "
|
|
164
|
+
"DB connections, etc.). "
|
|
165
|
+
"You do not need to specify which metrics — just provide "
|
|
166
|
+
"the resource ID and type. "
|
|
167
|
+
"Do NOT pass a days value less than 90 — short windows "
|
|
168
|
+
"produce false positives "
|
|
169
|
+
"by missing weekly, monthly, or quarterly usage patterns."
|
|
170
|
+
),
|
|
171
|
+
"input_schema": {
|
|
172
|
+
"type": "object",
|
|
173
|
+
"properties": {
|
|
174
|
+
"resource_id": {
|
|
175
|
+
"type": "string",
|
|
176
|
+
"description": "Resource ID or ARN",
|
|
177
|
+
},
|
|
178
|
+
"resource_type": {
|
|
179
|
+
"type": "string",
|
|
180
|
+
"description": "Resource type string (e.g. 'AWS::EC2::Instance')",
|
|
181
|
+
},
|
|
182
|
+
"days": {
|
|
183
|
+
"type": "integer",
|
|
184
|
+
"description": (
|
|
185
|
+
"Lookback window in days. Default is 90. "
|
|
186
|
+
"Never set below 90 — shorter windows miss quarterly patterns "
|
|
187
|
+
"and produce false-positive idle findings."
|
|
188
|
+
),
|
|
189
|
+
"default": 90,
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
"required": ["resource_id", "resource_type"],
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
"name": "get_cost",
|
|
197
|
+
"description": (
|
|
198
|
+
"Get the actual cost in USD for one or more resources over the past N days. "
|
|
199
|
+
"ALWAYS batch multiple resource IDs into a single call — never call this "
|
|
200
|
+
"one resource at a time. Returns a dict mapping resource_id to monthly cost."
|
|
201
|
+
),
|
|
202
|
+
"input_schema": {
|
|
203
|
+
"type": "object",
|
|
204
|
+
"properties": {
|
|
205
|
+
"resource_ids": {
|
|
206
|
+
"type": "array",
|
|
207
|
+
"items": {"type": "string"},
|
|
208
|
+
"description": "List of resource IDs. Batch as many as possible.",
|
|
209
|
+
},
|
|
210
|
+
"days": {
|
|
211
|
+
"type": "integer",
|
|
212
|
+
"description": "Lookback window in days (default: 30)",
|
|
213
|
+
"default": 30,
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
"required": ["resource_ids"],
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
"name": "get_last_activity",
|
|
221
|
+
"description": (
|
|
222
|
+
"Get the timestamp of the last meaningful activity for a resource "
|
|
223
|
+
"(last API call, configuration change, user interaction, etc.). "
|
|
224
|
+
"Returns an ISO8601 timestamp or null if no activity was found."
|
|
225
|
+
),
|
|
226
|
+
"input_schema": {
|
|
227
|
+
"type": "object",
|
|
228
|
+
"properties": {
|
|
229
|
+
"resource_id": {
|
|
230
|
+
"type": "string",
|
|
231
|
+
"description": "Resource ID or ARN",
|
|
232
|
+
},
|
|
233
|
+
"resource_type": {
|
|
234
|
+
"type": "string",
|
|
235
|
+
"description": "Resource type string",
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
"required": ["resource_id", "resource_type"],
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
"name": "submit_findings",
|
|
243
|
+
"description": (
|
|
244
|
+
"Submit your final analysis when your investigation is complete. "
|
|
245
|
+
"Include ALL idle or wasteful resources you found, ordered by estimated_monthly_cost "
|
|
246
|
+
"descending. Calling this tool ends the analysis — do not call it until you are "
|
|
247
|
+
"confident the scan is thorough."
|
|
248
|
+
),
|
|
249
|
+
"input_schema": {
|
|
250
|
+
"type": "object",
|
|
251
|
+
"properties": {
|
|
252
|
+
"findings": {
|
|
253
|
+
"type": "array",
|
|
254
|
+
"description": "Idle/wasteful resources ordered by cost (highest first)",
|
|
255
|
+
"items": {
|
|
256
|
+
"type": "object",
|
|
257
|
+
"required": [
|
|
258
|
+
"resource_id",
|
|
259
|
+
"resource_type",
|
|
260
|
+
"cloud",
|
|
261
|
+
"region",
|
|
262
|
+
"estimated_monthly_cost",
|
|
263
|
+
"waste_reason",
|
|
264
|
+
"recommendation",
|
|
265
|
+
"priority",
|
|
266
|
+
"metrics_summary",
|
|
267
|
+
],
|
|
268
|
+
"properties": {
|
|
269
|
+
"resource_id": {"type": "string"},
|
|
270
|
+
"resource_type": {"type": "string"},
|
|
271
|
+
"cloud": {
|
|
272
|
+
"type": "string",
|
|
273
|
+
"enum": ["aws", "gcp", "azure"],
|
|
274
|
+
},
|
|
275
|
+
"region": {"type": "string"},
|
|
276
|
+
"name": {"type": "string"},
|
|
277
|
+
"estimated_monthly_cost": {
|
|
278
|
+
"type": "number",
|
|
279
|
+
"description": "Estimated monthly cost in USD",
|
|
280
|
+
},
|
|
281
|
+
"waste_reason": {
|
|
282
|
+
"type": "string",
|
|
283
|
+
"description": "Clear explanation of why this resource is idle or wasteful",
|
|
284
|
+
},
|
|
285
|
+
"recommendation": {
|
|
286
|
+
"type": "string",
|
|
287
|
+
"description": "Specific action: delete, downsize, tag for review, etc.",
|
|
288
|
+
},
|
|
289
|
+
"priority": {
|
|
290
|
+
"type": "string",
|
|
291
|
+
"enum": ["high", "medium", "low"],
|
|
292
|
+
},
|
|
293
|
+
"metrics_summary": {
|
|
294
|
+
"type": "object",
|
|
295
|
+
"description": "Key metric values that informed this decision",
|
|
296
|
+
},
|
|
297
|
+
"tags": {"type": "object"},
|
|
298
|
+
"last_activity": {
|
|
299
|
+
"type": "string",
|
|
300
|
+
"description": "ISO8601 datetime of last activity, or null",
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
"executive_summary": {
|
|
306
|
+
"type": "string",
|
|
307
|
+
"description": (
|
|
308
|
+
"3–5 sentence summary for a non-technical engineering manager. "
|
|
309
|
+
"Include: total estimated monthly waste, number of resources found, "
|
|
310
|
+
"the most impactful finding, and the single most important action to take."
|
|
311
|
+
),
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
"required": ["findings", "executive_summary"],
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
]
|
core/config.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Centralized configuration via pydantic-settings.
|
|
3
|
+
|
|
4
|
+
All environment variables are defined here with types, defaults, and validation.
|
|
5
|
+
Access the singleton via ``get_settings()``. The object is built once and cached
|
|
6
|
+
for the lifetime of the process.
|
|
7
|
+
|
|
8
|
+
Usage::
|
|
9
|
+
|
|
10
|
+
from core.config import get_settings
|
|
11
|
+
cfg = get_settings()
|
|
12
|
+
print(cfg.ai.provider) # "anthropic"
|
|
13
|
+
print(cfg.scan.max_resources) # 200
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
from functools import lru_cache
|
|
20
|
+
from typing import Literal
|
|
21
|
+
|
|
22
|
+
from pydantic import Field
|
|
23
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# AI
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AISettings(BaseSettings):
|
|
31
|
+
model_config = SettingsConfigDict(env_prefix="", env_file=".env", extra="ignore")
|
|
32
|
+
|
|
33
|
+
provider: str = Field("anthropic", alias="AI_PROVIDER")
|
|
34
|
+
model: str | None = Field(None, alias="AI_MODEL")
|
|
35
|
+
temperature: float = Field(0.0, alias="AI_TEMPERATURE")
|
|
36
|
+
|
|
37
|
+
# Anthropic
|
|
38
|
+
anthropic_api_key: str | None = Field(None, alias="ANTHROPIC_API_KEY")
|
|
39
|
+
anthropic_model: str = Field(
|
|
40
|
+
"claude-sonnet-4-6", alias="ANTHROPIC_MODEL"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Bedrock
|
|
44
|
+
bedrock_model_id: str = Field(
|
|
45
|
+
"anthropic.claude-sonnet-4-6", alias="BEDROCK_MODEL_ID"
|
|
46
|
+
)
|
|
47
|
+
bedrock_region: str = Field("us-east-1", alias="BEDROCK_REGION")
|
|
48
|
+
bedrock_max_tokens: int = Field(2048, alias="BEDROCK_MAX_TOKENS")
|
|
49
|
+
|
|
50
|
+
# Vertex AI
|
|
51
|
+
vertexai_project: str | None = Field(None, alias="VERTEXAI_PROJECT")
|
|
52
|
+
vertexai_location: str = Field("us-central1", alias="VERTEXAI_LOCATION")
|
|
53
|
+
vertexai_model: str = Field("gemini-1.5-pro-002", alias="VERTEXAI_MODEL")
|
|
54
|
+
|
|
55
|
+
# Azure OpenAI
|
|
56
|
+
azure_openai_endpoint: str | None = Field(None, alias="AZURE_OPENAI_ENDPOINT")
|
|
57
|
+
azure_openai_deployment: str = Field("gpt-4o", alias="AZURE_OPENAI_DEPLOYMENT")
|
|
58
|
+
azure_openai_api_version: str = Field(
|
|
59
|
+
"2024-10-21", alias="AZURE_OPENAI_API_VERSION"
|
|
60
|
+
)
|
|
61
|
+
azure_openai_api_key: str | None = Field(None, alias="AZURE_OPENAI_API_KEY")
|
|
62
|
+
|
|
63
|
+
def resolved_model(self, provider: str | None = None) -> str:
|
|
64
|
+
"""Return the effective model name for the active (or given) provider."""
|
|
65
|
+
p = provider or self.provider
|
|
66
|
+
if self.model:
|
|
67
|
+
return self.model
|
|
68
|
+
return {
|
|
69
|
+
"anthropic": self.anthropic_model,
|
|
70
|
+
"bedrock": self.bedrock_model_id,
|
|
71
|
+
"vertexai": self.vertexai_model,
|
|
72
|
+
"azure_openai": self.azure_openai_deployment,
|
|
73
|
+
}.get(p, self.anthropic_model)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# AWS
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class AWSSettings(BaseSettings):
|
|
82
|
+
model_config = SettingsConfigDict(env_prefix="", env_file=".env", extra="ignore")
|
|
83
|
+
|
|
84
|
+
primary_region: str = Field("us-east-1", alias="PRIMARY_REGION")
|
|
85
|
+
resource_explorer_region: str | None = Field(
|
|
86
|
+
None, alias="RESOURCE_EXPLORER_REGION"
|
|
87
|
+
)
|
|
88
|
+
ignore_regions: str = Field("", alias="IGNORE_REGIONS")
|
|
89
|
+
accounts_mode: Literal["single", "multi"] = Field("single", alias="ACCOUNTS_MODE")
|
|
90
|
+
accounts_config: str = Field("", alias="ACCOUNTS_CONFIG")
|
|
91
|
+
report_s3_bucket: str = Field("", alias="REPORT_S3_BUCKET")
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def ignore_regions_list(self) -> list[str]:
|
|
95
|
+
return [r.strip() for r in self.ignore_regions.split(",") if r.strip()]
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def accounts_list(self) -> list[dict[str, str]]:
|
|
99
|
+
if not self.accounts_config:
|
|
100
|
+
return []
|
|
101
|
+
return json.loads(self.accounts_config)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# GCP
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class GCPSettings(BaseSettings):
|
|
110
|
+
model_config = SettingsConfigDict(env_prefix="", env_file=".env", extra="ignore")
|
|
111
|
+
|
|
112
|
+
project_id: str = Field("", alias="GCP_PROJECT_ID")
|
|
113
|
+
billing_bq_table: str | None = Field(None, alias="BILLING_BQ_TABLE")
|
|
114
|
+
report_gcs_bucket: str = Field("", alias="REPORT_GCS_BUCKET")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
# Azure
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class AzureSettings(BaseSettings):
|
|
123
|
+
model_config = SettingsConfigDict(env_prefix="", env_file=".env", extra="ignore")
|
|
124
|
+
|
|
125
|
+
subscription_ids: str = Field("", alias="AZURE_SUBSCRIPTION_IDS")
|
|
126
|
+
log_analytics_workspace_id: str | None = Field(
|
|
127
|
+
None, alias="AZURE_LOG_ANALYTICS_WORKSPACE_ID"
|
|
128
|
+
)
|
|
129
|
+
report_storage_account: str = Field("", alias="REPORT_STORAGE_ACCOUNT")
|
|
130
|
+
report_storage_container: str = Field(
|
|
131
|
+
"argus-reports", alias="REPORT_STORAGE_CONTAINER"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def subscription_ids_list(self) -> list[str]:
|
|
136
|
+
return [s.strip() for s in self.subscription_ids.split(",") if s.strip()]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
# Notifications & reports
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class ReportSettings(BaseSettings):
|
|
145
|
+
model_config = SettingsConfigDict(env_prefix="", env_file=".env", extra="ignore")
|
|
146
|
+
|
|
147
|
+
slack_webhook_url: str = Field("", alias="SLACK_WEBHOOK_URL")
|
|
148
|
+
teams_webhook_url: str = Field("", alias="TEAMS_WEBHOOK_URL")
|
|
149
|
+
webhook_url: str = Field("", alias="WEBHOOK_URL")
|
|
150
|
+
dry_run: bool = Field(False, alias="DRY_RUN")
|
|
151
|
+
report_format: str = Field("json,html", alias="REPORT_FORMAT")
|
|
152
|
+
report_url_expiry: int = Field(604800, alias="REPORT_URL_EXPIRY")
|
|
153
|
+
local_report_dir: str = Field("local_reports", alias="LOCAL_REPORT_DIR")
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def formats(self) -> set[str]:
|
|
157
|
+
return {f.strip().lower() for f in self.report_format.split(",") if f.strip()}
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def has_any_notification_channel(self) -> bool:
|
|
161
|
+
return bool(
|
|
162
|
+
self.slack_webhook_url
|
|
163
|
+
or self.teams_webhook_url
|
|
164
|
+
or self.webhook_url
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
# Scan tuning
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class ScanSettings(BaseSettings):
|
|
174
|
+
model_config = SettingsConfigDict(env_prefix="", env_file=".env", extra="ignore")
|
|
175
|
+
|
|
176
|
+
max_resources: int = Field(200, alias="MAX_RESOURCES_PER_SCAN")
|
|
177
|
+
metrics_lookback_days: int = Field(90, alias="METRICS_LOOKBACK_DAYS")
|
|
178
|
+
adapter_concurrency: int = Field(10, alias="ADAPTER_CONCURRENCY")
|
|
179
|
+
max_iterations: int = Field(50, alias="MAX_AGENT_ITERATIONS")
|
|
180
|
+
llm_budget_usd: float = Field(2.0, alias="LLM_BUDGET_USD")
|
|
181
|
+
exclude_tags: str = Field("", alias="EXCLUDE_TAGS")
|
|
182
|
+
exclude_resource_types: str = Field("", alias="EXCLUDE_RESOURCE_TYPES")
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def exclude_tags_dict(self) -> dict[str, str]:
|
|
186
|
+
if not self.exclude_tags:
|
|
187
|
+
return {}
|
|
188
|
+
return json.loads(self.exclude_tags)
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def exclude_resource_types_list(self) -> list[str]:
|
|
192
|
+
return [
|
|
193
|
+
t.strip()
|
|
194
|
+
for t in self.exclude_resource_types.split(",")
|
|
195
|
+
if t.strip()
|
|
196
|
+
]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ---------------------------------------------------------------------------
|
|
200
|
+
# Logging
|
|
201
|
+
# ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class LogSettings(BaseSettings):
|
|
205
|
+
model_config = SettingsConfigDict(env_prefix="", env_file=".env", extra="ignore")
|
|
206
|
+
|
|
207
|
+
log_level: str = Field("INFO", alias="LOG_LEVEL")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
# Top-level settings
|
|
212
|
+
# ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class Settings(BaseSettings):
|
|
216
|
+
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
|
217
|
+
|
|
218
|
+
ai: AISettings = Field(default_factory=AISettings)
|
|
219
|
+
aws: AWSSettings = Field(default_factory=AWSSettings)
|
|
220
|
+
gcp: GCPSettings = Field(default_factory=GCPSettings)
|
|
221
|
+
azure: AzureSettings = Field(default_factory=AzureSettings)
|
|
222
|
+
report: ReportSettings = Field(default_factory=ReportSettings)
|
|
223
|
+
scan: ScanSettings = Field(default_factory=ScanSettings)
|
|
224
|
+
log: LogSettings = Field(default_factory=LogSettings)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@lru_cache(maxsize=1)
|
|
228
|
+
def get_settings() -> Settings:
|
|
229
|
+
"""Return the singleton Settings instance, built from env vars and .env file."""
|
|
230
|
+
return Settings()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def clear_settings_cache() -> None:
|
|
234
|
+
"""Clear the cached settings. Useful in tests."""
|
|
235
|
+
get_settings.cache_clear()
|
core/log.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import structlog
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def configure_logging() -> None:
|
|
11
|
+
"""
|
|
12
|
+
Configure structlog for the current runtime.
|
|
13
|
+
|
|
14
|
+
- JSON renderer when stdout is not a TTY (Lambda, Cloud Run, Azure Function).
|
|
15
|
+
- Pretty console renderer when running interactively (local dev / CLI).
|
|
16
|
+
- Log level controlled by LOG_LEVEL env var (default: INFO).
|
|
17
|
+
|
|
18
|
+
Call once at the top of each entrypoint before any logger is used.
|
|
19
|
+
stdlib logging is bridged so third-party libraries (boto3, google-cloud, azure)
|
|
20
|
+
also emit structured JSON.
|
|
21
|
+
"""
|
|
22
|
+
level_name = os.environ.get("LOG_LEVEL", "INFO").upper()
|
|
23
|
+
level = getattr(logging, level_name, logging.INFO)
|
|
24
|
+
|
|
25
|
+
is_tty = sys.stdout.isatty()
|
|
26
|
+
|
|
27
|
+
shared_processors: list[structlog.types.Processor] = [
|
|
28
|
+
structlog.contextvars.merge_contextvars,
|
|
29
|
+
structlog.stdlib.add_logger_name,
|
|
30
|
+
structlog.stdlib.add_log_level,
|
|
31
|
+
structlog.processors.TimeStamper(fmt="iso"),
|
|
32
|
+
structlog.processors.StackInfoRenderer(),
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
if is_tty:
|
|
36
|
+
renderer: structlog.types.Processor = structlog.dev.ConsoleRenderer()
|
|
37
|
+
else:
|
|
38
|
+
renderer = structlog.processors.JSONRenderer()
|
|
39
|
+
|
|
40
|
+
structlog.configure(
|
|
41
|
+
processors=[
|
|
42
|
+
*shared_processors,
|
|
43
|
+
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
|
44
|
+
],
|
|
45
|
+
wrapper_class=structlog.stdlib.BoundLogger,
|
|
46
|
+
context_class=dict,
|
|
47
|
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
|
48
|
+
cache_logger_on_first_use=True,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
formatter = structlog.stdlib.ProcessorFormatter(
|
|
52
|
+
processors=[
|
|
53
|
+
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
|
|
54
|
+
renderer,
|
|
55
|
+
],
|
|
56
|
+
foreign_pre_chain=shared_processors,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
handler = logging.StreamHandler()
|
|
60
|
+
handler.setFormatter(formatter)
|
|
61
|
+
|
|
62
|
+
root = logging.getLogger()
|
|
63
|
+
root.handlers.clear()
|
|
64
|
+
root.addHandler(handler)
|
|
65
|
+
root.setLevel(level)
|
|
66
|
+
|
|
67
|
+
# Suppress noisy third-party loggers
|
|
68
|
+
for noisy in ("boto3", "botocore", "urllib3", "google", "azure"):
|
|
69
|
+
logging.getLogger(noisy).setLevel(logging.WARNING)
|
core/models/__init__.py
ADDED
|
File without changes
|