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
@@ -0,0 +1,299 @@
1
+ """
2
+ AWS Lambda entrypoint for Argus.
3
+
4
+ Environment variables (set in CloudFormation template or .env for local):
5
+ IGNORE_REGIONS Comma-separated regions to exclude from the scan
6
+ (default: empty = scan all)
7
+ PRIMARY_REGION AWS region for boto3 session and Bedrock calls (default: us-east-1)
8
+ DRY_RUN "true" to skip Slack post and S3 upload (default: false)
9
+ SLACK_WEBHOOK_URL Slack incoming webhook URL
10
+ AI_PROVIDER "bedrock" | "anthropic" (default: bedrock)
11
+ ANTHROPIC_API_KEY Required when AI_PROVIDER=anthropic
12
+ ACCOUNTS_MODE "single" | "multi" (default: single)
13
+ ACCOUNTS_CONFIG JSON array of account dicts when ACCOUNTS_MODE=multi
14
+ e.g. [{"id":"123","name":"prod","role_arn":"arn:..."}]
15
+ REPORT_S3_BUCKET S3 bucket name for saving full reports (JSON + HTML) (optional)
16
+ REPORT_URL_EXPIRY Pre-signed URL expiry in seconds (default: 604800 = 7 days)
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ from datetime import datetime, timezone
24
+ from typing import Any
25
+
26
+ import boto3
27
+ import structlog
28
+ from botocore.exceptions import ClientError
29
+
30
+ from adapters.aws.adapter import AWSAdapter
31
+ from core.agent.loop import AgentLoop
32
+ from core.log import configure_logging
33
+ from core.models.finding import ResourceFinding
34
+ from core.reports.comparison import compare_scans
35
+ from core.reports.delivery import (
36
+ notify_all,
37
+ save_reports_locally,
38
+ )
39
+ from core.reports.generator import build_report, build_slack_payload
40
+ from core.reports.html import build_html_report
41
+ from core.secrets import resolve_secrets
42
+ from core.validation import ConfigurationError, validate_environment
43
+
44
+ configure_logging()
45
+ logger = structlog.get_logger(__name__)
46
+
47
+
48
+ def handler(event: dict[str, Any], context: Any) -> dict[str, Any]:
49
+ """Lambda entry point. Triggered by EventBridge on a schedule."""
50
+ cloud = "aws"
51
+ try:
52
+ resolve_secrets()
53
+ validate_environment(cloud)
54
+ except ConfigurationError as exc:
55
+ logger.error("startup_validation_failed", error=str(exc))
56
+ return {"statusCode": 500, "error": str(exc)}
57
+
58
+ ignore_regions = [
59
+ r.strip() for r in os.environ.get("IGNORE_REGIONS", "").split(",") if r.strip()
60
+ ]
61
+ primary_region = os.environ.get("PRIMARY_REGION", "us-east-1")
62
+ accounts_mode = os.environ.get("ACCOUNTS_MODE", "single")
63
+
64
+ structlog.contextvars.bind_contextvars(cloud=cloud)
65
+ logger.info(
66
+ "scan_start",
67
+ ignore_regions=ignore_regions,
68
+ primary_region=primary_region,
69
+ mode=accounts_mode,
70
+ )
71
+
72
+ ai_provider = _build_ai_provider()
73
+
74
+ if accounts_mode == "multi":
75
+ all_findings, executive_summary, account_ids, token_summary = (
76
+ _run_multi_account(ai_provider, ignore_regions, primary_region, cloud)
77
+ )
78
+ else:
79
+ all_findings, executive_summary, account_ids, token_summary = (
80
+ _run_single_account(ai_provider, ignore_regions, primary_region, cloud)
81
+ )
82
+
83
+ s3_bucket = os.environ.get("REPORT_S3_BUCKET", "").strip()
84
+ previous_report = _load_previous_report(cloud, s3_bucket)
85
+ all_findings, scan_diff = compare_scans(all_findings, previous_report)
86
+
87
+ report = build_report(
88
+ all_findings,
89
+ cloud=cloud,
90
+ executive_summary=executive_summary,
91
+ accounts_scanned=account_ids,
92
+ agent_input_tokens=token_summary.get("total_input_tokens", 0),
93
+ agent_output_tokens=token_summary.get("total_output_tokens", 0),
94
+ scan_diff=scan_diff,
95
+ )
96
+ report_url: str | None = None
97
+ if s3_bucket:
98
+ report_url = _save_reports_to_s3(report, s3_bucket)
99
+ else:
100
+ save_reports_locally(report)
101
+
102
+ structlog.contextvars.bind_contextvars(scan_id=report["scan_id"])
103
+ payload = build_slack_payload(report, report_url=report_url)
104
+ notify_all(payload)
105
+
106
+ logger.info(
107
+ "scan_complete",
108
+ findings=report["findings_count"],
109
+ total_waste_usd=round(report["total_estimated_waste_usd"], 2),
110
+ )
111
+
112
+ return {
113
+ "statusCode": 200,
114
+ "scan_id": report["scan_id"],
115
+ "findings_count": report["findings_count"],
116
+ "total_estimated_waste_usd": report["total_estimated_waste_usd"],
117
+ }
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # Scan runners
122
+ # ---------------------------------------------------------------------------
123
+
124
+
125
+ def _run_single_account(
126
+ ai_provider: Any,
127
+ ignore_regions: list[str],
128
+ primary_region: str,
129
+ cloud: str,
130
+ ) -> tuple[list[ResourceFinding], str, list[str], dict]:
131
+ account_id = _get_current_account_id()
132
+ adapter = AWSAdapter.for_account(account=None, region=primary_region)
133
+ loop = AgentLoop(ai_provider=ai_provider, cloud_adapter=adapter)
134
+ findings, summary = loop.run(
135
+ cloud=cloud, ignore_regions=ignore_regions, accounts=[{"id": account_id}]
136
+ )
137
+ return findings, summary, [account_id], loop.tracker.summary()
138
+
139
+
140
+ def _run_multi_account(
141
+ ai_provider: Any,
142
+ ignore_regions: list[str],
143
+ primary_region: str,
144
+ cloud: str,
145
+ ) -> tuple[list[ResourceFinding], str, list[str], dict]:
146
+ raw = os.environ.get("ACCOUNTS_CONFIG", "[]")
147
+ try:
148
+ accounts: list[dict[str, Any]] = json.loads(raw)
149
+ except json.JSONDecodeError as exc:
150
+ raise ValueError(f"ACCOUNTS_CONFIG is not valid JSON: {exc}") from exc
151
+
152
+ if not accounts:
153
+ logger.warning(
154
+ "ACCOUNTS_MODE=multi but ACCOUNTS_CONFIG is empty "
155
+ "— falling back to single-account mode"
156
+ )
157
+ return _run_single_account(ai_provider, ignore_regions, primary_region, cloud)
158
+
159
+ all_findings: list[ResourceFinding] = []
160
+ all_summaries: list[str] = []
161
+ account_ids: list[str] = []
162
+ total_input = 0
163
+ total_output = 0
164
+
165
+ for account in accounts:
166
+ acct_id = account["id"]
167
+ acct_name = account.get("name", acct_id)
168
+ logger.info("scanning_account", account_id=acct_id, account_name=acct_name)
169
+
170
+ try:
171
+ adapter = AWSAdapter.for_account(account=account, region=primary_region)
172
+ loop = AgentLoop(ai_provider=ai_provider, cloud_adapter=adapter)
173
+ findings, summary = loop.run(
174
+ cloud=cloud,
175
+ ignore_regions=ignore_regions,
176
+ accounts=[{"id": acct_id, "name": acct_name}],
177
+ )
178
+ all_findings.extend(findings)
179
+ all_summaries.append(f"[{acct_name}] {summary}")
180
+ account_ids.append(acct_id)
181
+ total_input += loop.tracker.total_input_tokens
182
+ total_output += loop.tracker.total_output_tokens
183
+ except (PermissionError, ClientError) as exc:
184
+ logger.error("account_scan_failed", account_id=acct_id, error=str(exc))
185
+
186
+ executive_summary = (
187
+ " ".join(all_summaries) if all_summaries else "No findings across all accounts."
188
+ )
189
+ token_summary = {
190
+ "total_input_tokens": total_input,
191
+ "total_output_tokens": total_output,
192
+ }
193
+ return all_findings, executive_summary, account_ids, token_summary
194
+
195
+
196
+ # ---------------------------------------------------------------------------
197
+ # Helpers
198
+ # ---------------------------------------------------------------------------
199
+
200
+
201
+ def _build_ai_provider() -> Any:
202
+ provider_name = os.environ.get("AI_PROVIDER", "bedrock").lower()
203
+ if provider_name == "anthropic":
204
+ from ai.anthropic import AnthropicProvider
205
+
206
+ return AnthropicProvider()
207
+ from ai.bedrock import BedrockProvider
208
+
209
+ return BedrockProvider()
210
+
211
+
212
+ def _get_current_account_id() -> str:
213
+ try:
214
+ sts = boto3.client("sts")
215
+ return str(sts.get_caller_identity()["Account"])
216
+ except ClientError as exc:
217
+ logger.warning("sts_get_account_id_failed", error=str(exc))
218
+ return "unknown"
219
+
220
+
221
+ def _load_previous_report(cloud: str, s3_bucket: str) -> dict[str, Any] | None:
222
+ """Load the most recent previous report from S3 or local storage."""
223
+ if s3_bucket:
224
+ try:
225
+ s3 = boto3.client("s3")
226
+ resp = s3.list_objects_v2(
227
+ Bucket=s3_bucket,
228
+ Prefix=f"reports/{cloud}/",
229
+ MaxKeys=1000,
230
+ )
231
+ json_keys = sorted(
232
+ (
233
+ obj["Key"]
234
+ for obj in resp.get("Contents", [])
235
+ if obj["Key"].endswith(".json")
236
+ ),
237
+ reverse=True,
238
+ )
239
+ if json_keys:
240
+ body = s3.get_object(Bucket=s3_bucket, Key=json_keys[0])["Body"].read()
241
+ return json.loads(body)
242
+ except ClientError as exc:
243
+ logger.warning("previous_report_load_failed", error=str(exc))
244
+ else:
245
+ return _load_previous_report_local(cloud)
246
+ return None
247
+
248
+
249
+ def _load_previous_report_local(cloud: str) -> dict[str, Any] | None:
250
+ """Load the most recent previous report from local_reports/."""
251
+ from pathlib import Path
252
+
253
+ base = Path(os.environ.get("LOCAL_REPORT_DIR", "local_reports")) / cloud
254
+ if not base.exists():
255
+ return None
256
+ json_files = sorted(base.rglob("*.json"), reverse=True)
257
+ if json_files:
258
+ return json.loads(json_files[0].read_text(encoding="utf-8"))
259
+ return None
260
+
261
+
262
+ def _save_reports_to_s3(report: dict[str, Any], bucket: str) -> str | None:
263
+ """Upload JSON + HTML reports to S3. Returns a pre-signed URL for the HTML."""
264
+ now = datetime.now(tz=timezone.utc)
265
+ prefix = f"reports/{report['cloud']}/{now.strftime('%Y/%m/%d')}/{report['scan_id']}"
266
+ json_key = f"{prefix}.json"
267
+ html_key = f"{prefix}.html"
268
+ expiry = int(os.environ.get("REPORT_URL_EXPIRY", "604800"))
269
+
270
+ try:
271
+ s3 = boto3.client("s3")
272
+
273
+ s3.put_object(
274
+ Bucket=bucket,
275
+ Key=json_key,
276
+ Body=json.dumps(report, indent=2, default=str).encode("utf-8"),
277
+ ContentType="application/json",
278
+ )
279
+ logger.info("json_report_saved", location=f"s3://{bucket}/{json_key}")
280
+
281
+ html_body = build_html_report(report).encode("utf-8")
282
+ s3.put_object(
283
+ Bucket=bucket,
284
+ Key=html_key,
285
+ Body=html_body,
286
+ ContentType="text/html; charset=utf-8",
287
+ )
288
+ logger.info("html_report_saved", location=f"s3://{bucket}/{html_key}")
289
+
290
+ url: str = s3.generate_presigned_url(
291
+ "get_object",
292
+ Params={"Bucket": bucket, "Key": html_key},
293
+ ExpiresIn=expiry,
294
+ )
295
+ logger.info("presigned_url_generated", expires_in_seconds=expiry)
296
+ return url
297
+ except ClientError as exc:
298
+ logger.error("s3_upload_failed", error=str(exc))
299
+ return None
@@ -0,0 +1,257 @@
1
+ """
2
+ Azure Function entrypoint for Argus (Timer trigger).
3
+
4
+ The function is triggered on a schedule by Azure Function's built-in timer trigger.
5
+ Deploy with the Bicep template in deploy/azure/function-app.bicep.
6
+
7
+ Environment variables (set in Azure Function App Configuration):
8
+ AZURE_SUBSCRIPTION_IDS Comma-separated subscription IDs to scan (required)
9
+ AZURE_LOG_ANALYTICS_WORKSPACE_ID Log Analytics workspace ID
10
+ (optional — for Activity Log KQL)
11
+ AI_PROVIDER "anthropic" | "azure_openai" (default: azure_openai)
12
+ ANTHROPIC_API_KEY Required when AI_PROVIDER=anthropic
13
+ AZURE_OPENAI_ENDPOINT Required when AI_PROVIDER=azure_openai
14
+ AZURE_OPENAI_DEPLOYMENT GPT-4o deployment name (default: gpt-4o)
15
+ IGNORE_REGIONS Comma-separated regions to exclude (default: empty)
16
+ SLACK_WEBHOOK_URL Slack incoming webhook URL
17
+ DRY_RUN "true" to skip Slack post (default: false)
18
+ REPORT_STORAGE_ACCOUNT Azure Storage account name for full reports (optional)
19
+ REPORT_STORAGE_CONTAINER Blob container name (default: argus-reports)
20
+ REPORT_URL_EXPIRY SAS URL expiry in seconds (default: 604800 = 7 days)
21
+ LOG_LEVEL DEBUG | INFO | WARNING | ERROR (default: INFO)
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import os
28
+ from datetime import datetime, timedelta, timezone
29
+ from typing import Any
30
+
31
+ import structlog
32
+
33
+ from adapters.azure.adapter import AzureAdapter
34
+ from core.agent.loop import AgentLoop
35
+ from core.log import configure_logging
36
+ from core.reports.comparison import compare_scans
37
+ from core.reports.delivery import (
38
+ notify_all,
39
+ save_reports_locally,
40
+ )
41
+ from core.reports.generator import build_report, build_slack_payload
42
+ from core.reports.html import build_html_report
43
+ from core.secrets import resolve_secrets
44
+ from core.validation import ConfigurationError, validate_environment
45
+
46
+ configure_logging()
47
+ logger = structlog.get_logger(__name__)
48
+
49
+
50
+ def main(mytimer: Any) -> None:
51
+ """
52
+ Azure Function timer trigger entry point.
53
+
54
+ `mytimer` is an azure.functions.TimerRequest object injected by the runtime.
55
+ The function name and schedule are defined in function.json (generated by
56
+ the Bicep template / func CLI).
57
+ """
58
+ if mytimer and mytimer.past_due:
59
+ logger.warning("Timer is past due — running scan now")
60
+
61
+ cloud = "azure"
62
+ try:
63
+ resolve_secrets()
64
+ validate_environment(cloud)
65
+ except ConfigurationError as exc:
66
+ logger.error("startup_validation_failed", error=str(exc))
67
+ return
68
+
69
+ ignore_regions = [
70
+ r.strip() for r in os.environ.get("IGNORE_REGIONS", "").split(",") if r.strip()
71
+ ]
72
+
73
+ subscription_ids_raw = os.environ.get("AZURE_SUBSCRIPTION_IDS", "").strip()
74
+
75
+ subscription_ids = [s.strip() for s in subscription_ids_raw.split(",") if s.strip()]
76
+
77
+ structlog.contextvars.bind_contextvars(
78
+ cloud=cloud, account_id=",".join(subscription_ids)
79
+ )
80
+ logger.info(
81
+ "scan_start", subscriptions=subscription_ids, ignore_regions=ignore_regions
82
+ )
83
+
84
+ ai_provider = _build_ai_provider()
85
+ adapter = AzureAdapter.from_env()
86
+
87
+ loop = AgentLoop(ai_provider=ai_provider, cloud_adapter=adapter)
88
+ findings, executive_summary = loop.run(
89
+ cloud=cloud,
90
+ ignore_regions=ignore_regions,
91
+ accounts=[{"id": sid, "name": sid} for sid in subscription_ids],
92
+ )
93
+
94
+ storage_account = os.environ.get("REPORT_STORAGE_ACCOUNT", "").strip()
95
+ previous_report = _load_previous_report(cloud, storage_account)
96
+ findings, scan_diff = compare_scans(findings, previous_report)
97
+
98
+ token_summary = loop.tracker.summary()
99
+ report = build_report(
100
+ findings,
101
+ cloud=cloud,
102
+ executive_summary=executive_summary,
103
+ accounts_scanned=subscription_ids,
104
+ agent_input_tokens=token_summary["total_input_tokens"],
105
+ agent_output_tokens=token_summary["total_output_tokens"],
106
+ scan_diff=scan_diff,
107
+ )
108
+ report_url: str | None = None
109
+ if storage_account:
110
+ report_url = _save_reports_to_blob(report, storage_account)
111
+ else:
112
+ save_reports_locally(report)
113
+
114
+ structlog.contextvars.bind_contextvars(scan_id=report["scan_id"])
115
+ payload = build_slack_payload(report, report_url=report_url)
116
+ notify_all(payload)
117
+
118
+ logger.info(
119
+ "scan_complete",
120
+ findings=report["findings_count"],
121
+ total_waste_usd=round(report["total_estimated_waste_usd"], 2),
122
+ )
123
+
124
+
125
+ def _build_ai_provider() -> Any:
126
+ provider_name = os.environ.get("AI_PROVIDER", "azure_openai").lower()
127
+ if provider_name == "anthropic":
128
+ from ai.anthropic import AnthropicProvider
129
+
130
+ return AnthropicProvider()
131
+ from ai.azure_openai import AzureOpenAIProvider
132
+
133
+ return AzureOpenAIProvider()
134
+
135
+
136
+ def _load_previous_report(cloud: str, storage_account: str) -> dict[str, Any] | None:
137
+ """Load the most recent previous report from Azure Blob or local storage."""
138
+ if storage_account:
139
+ try:
140
+ from azure.identity import (
141
+ DefaultAzureCredential, # type: ignore[import-untyped]
142
+ )
143
+ from azure.storage.blob import (
144
+ BlobServiceClient, # type: ignore[import-untyped]
145
+ )
146
+
147
+ container = os.environ.get("REPORT_STORAGE_CONTAINER", "argus-reports")
148
+ credential = DefaultAzureCredential()
149
+ account_url = f"https://{storage_account}.blob.core.windows.net"
150
+ client = BlobServiceClient(account_url=account_url, credential=credential)
151
+ container_client = client.get_container_client(container)
152
+ blobs = sorted(
153
+ (
154
+ b.name
155
+ for b in container_client.list_blobs(prefix=f"reports/{cloud}/")
156
+ if b.name.endswith(".json")
157
+ ),
158
+ reverse=True,
159
+ )
160
+ if blobs:
161
+ data = container_client.download_blob(blobs[0]).readall()
162
+ return json.loads(data)
163
+ except Exception as exc: # noqa: BLE001
164
+ logger.warning("previous_report_load_failed", error=str(exc))
165
+ else:
166
+ from pathlib import Path
167
+
168
+ base = Path(os.environ.get("LOCAL_REPORT_DIR", "local_reports")) / cloud
169
+ if base.exists():
170
+ json_files = sorted(base.rglob("*.json"), reverse=True)
171
+ if json_files:
172
+ return json.loads(json_files[0].read_text(encoding="utf-8"))
173
+ return None
174
+
175
+
176
+ def _save_reports_to_blob(report: dict[str, Any], storage_account: str) -> str | None:
177
+ """Upload JSON + HTML reports to Azure Blob. Returns a SAS URL for the HTML."""
178
+ try:
179
+ from azure.core.exceptions import ( # type: ignore[import-untyped]
180
+ AzureError,
181
+ ResourceExistsError,
182
+ )
183
+ from azure.identity import (
184
+ DefaultAzureCredential, # type: ignore[import-untyped]
185
+ )
186
+ from azure.storage.blob import ( # type: ignore[import-untyped]
187
+ BlobSasPermissions,
188
+ BlobServiceClient,
189
+ generate_blob_sas,
190
+ )
191
+ except ImportError:
192
+ logger.error(
193
+ "azure-storage-blob or azure-identity is not installed "
194
+ "— skipping Blob upload. "
195
+ "Run: pip install azure-storage-blob azure-identity"
196
+ )
197
+ return None
198
+
199
+ container = os.environ.get("REPORT_STORAGE_CONTAINER", "argus-reports")
200
+ expiry_seconds = int(os.environ.get("REPORT_URL_EXPIRY", "604800"))
201
+ now = datetime.now(tz=timezone.utc)
202
+ prefix = f"reports/{report['cloud']}/{now.strftime('%Y/%m/%d')}/{report['scan_id']}"
203
+ json_key = f"{prefix}.json"
204
+ html_key = f"{prefix}.html"
205
+
206
+ try:
207
+ credential = DefaultAzureCredential()
208
+ account_url = f"https://{storage_account}.blob.core.windows.net"
209
+ client = BlobServiceClient(account_url=account_url, credential=credential)
210
+ container_client = client.get_container_client(container)
211
+
212
+ try:
213
+ container_client.create_container()
214
+ except ResourceExistsError:
215
+ pass
216
+
217
+ container_client.upload_blob(
218
+ json_key,
219
+ json.dumps(report, indent=2, default=str).encode("utf-8"),
220
+ content_settings={"content_type": "application/json"},
221
+ overwrite=True,
222
+ )
223
+ logger.info(
224
+ "json_report_saved",
225
+ location=f"{storage_account}/{container}/{json_key}",
226
+ )
227
+
228
+ container_client.upload_blob(
229
+ html_key,
230
+ build_html_report(report).encode("utf-8"),
231
+ content_settings={"content_type": "text/html; charset=utf-8"},
232
+ overwrite=True,
233
+ )
234
+ logger.info(
235
+ "html_report_saved",
236
+ location=f"{storage_account}/{container}/{html_key}",
237
+ )
238
+
239
+ # SAS token scoped to just this blob
240
+ user_delegation_key = client.get_user_delegation_key(
241
+ key_start_time=now,
242
+ key_expiry_time=now + timedelta(seconds=expiry_seconds),
243
+ )
244
+ sas_token = generate_blob_sas(
245
+ account_name=storage_account,
246
+ container_name=container,
247
+ blob_name=html_key,
248
+ user_delegation_key=user_delegation_key,
249
+ permission=BlobSasPermissions(read=True),
250
+ expiry=now + timedelta(seconds=expiry_seconds),
251
+ )
252
+ url = f"{account_url}/{container}/{html_key}?{sas_token}"
253
+ logger.info("sas_url_generated", expires_in_seconds=expiry_seconds)
254
+ return url
255
+ except AzureError as exc:
256
+ logger.error("blob_upload_failed", error=str(exc))
257
+ return None