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/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)
File without changes