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,168 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from datetime import datetime, timezone
5
+ from typing import Any
6
+
7
+ from core.models.finding import ResourceFinding
8
+
9
+ # Maximum findings shown as individual rows in the Slack digest
10
+ SLACK_DIGEST_LIMIT = 5
11
+
12
+
13
+ def build_report(
14
+ findings: list[ResourceFinding],
15
+ cloud: str,
16
+ executive_summary: str,
17
+ accounts_scanned: list[str] | None = None,
18
+ agent_input_tokens: int = 0,
19
+ agent_output_tokens: int = 0,
20
+ scan_diff: dict[str, Any] | None = None,
21
+ ) -> dict[str, Any]:
22
+ """
23
+ Convert a list of ResourceFinding objects into the canonical JSON report.
24
+ Findings are sorted by estimated_monthly_cost descending before serialising.
25
+ """
26
+ sorted_findings = sorted(
27
+ findings, key=lambda f: f.estimated_monthly_cost, reverse=True
28
+ )
29
+ total_waste = sum(f.estimated_monthly_cost for f in sorted_findings)
30
+
31
+ return {
32
+ "schema_version": "1.0",
33
+ "scan_id": str(uuid.uuid4()),
34
+ "generated_at": datetime.now(tz=timezone.utc).isoformat(),
35
+ "cloud": cloud,
36
+ "accounts_scanned": accounts_scanned or [],
37
+ "total_estimated_waste_usd": round(total_waste, 2),
38
+ "findings_count": len(sorted_findings),
39
+ "findings": [f.to_dict() for f in sorted_findings],
40
+ "executive_summary": executive_summary,
41
+ "agent_input_tokens": agent_input_tokens,
42
+ "agent_output_tokens": agent_output_tokens,
43
+ "estimated_agent_cost_usd": _estimate_cost(
44
+ agent_input_tokens, agent_output_tokens
45
+ ),
46
+ "scan_diff": scan_diff,
47
+ }
48
+
49
+
50
+ def _estimate_cost(input_tokens: int, output_tokens: int) -> float:
51
+ input_cost_per_m = 3.0
52
+ output_cost_per_m = 15.0
53
+ cost = (input_tokens / 1_000_000 * input_cost_per_m) + (
54
+ output_tokens / 1_000_000 * output_cost_per_m
55
+ )
56
+ return round(cost, 4)
57
+
58
+
59
+ def build_slack_payload(
60
+ report: dict[str, Any],
61
+ report_url: str | None = None,
62
+ ) -> dict[str, Any]:
63
+ """
64
+ Build a compact Slack Block Kit digest.
65
+
66
+ Shows stats + AI summary + top findings as a one-line-per-finding table.
67
+ Full AI reasoning lives in the HTML report linked via report_url.
68
+ """
69
+ cloud = report["cloud"].upper()
70
+ total = report["total_estimated_waste_usd"]
71
+ count = report["findings_count"]
72
+ generated_at = report["generated_at"][:10] # YYYY-MM-DD
73
+ accounts = len(report.get("accounts_scanned", []))
74
+
75
+ _PRIORITY_EMOJI = {
76
+ "HIGH": ":red_circle:",
77
+ "MEDIUM": ":large_yellow_circle:",
78
+ "LOW": ":large_green_circle:",
79
+ }
80
+
81
+ blocks: list[dict[str, Any]] = [
82
+ {
83
+ "type": "header",
84
+ "text": {
85
+ "type": "plain_text",
86
+ "text": f"Argus — {cloud} Waste Report ({generated_at})",
87
+ },
88
+ },
89
+ {
90
+ "type": "section",
91
+ "fields": [
92
+ {
93
+ "type": "mrkdwn",
94
+ "text": f":money_with_wings: *${total:,.2f}/month* estimated waste",
95
+ },
96
+ {
97
+ "type": "mrkdwn",
98
+ "text": (
99
+ f":bar_chart: *{count}* idle "
100
+ f"resource{'s' if count != 1 else ''} across "
101
+ f"*{accounts}* account{'s' if accounts != 1 else ''}"
102
+ ),
103
+ },
104
+ ],
105
+ },
106
+ {
107
+ "type": "section",
108
+ "text": {
109
+ "type": "mrkdwn",
110
+ "text": f"_{report['executive_summary']}_",
111
+ },
112
+ },
113
+ {"type": "divider"},
114
+ ]
115
+
116
+ top = report["findings"][:SLACK_DIGEST_LIMIT]
117
+ if top:
118
+ lines = ["*Top findings*"]
119
+ for finding in top:
120
+ cost = finding["estimated_monthly_cost"]
121
+ priority = (finding.get("priority") or "low").upper()
122
+ emoji = _PRIORITY_EMOJI.get(priority, ":white_circle:")
123
+ label = finding.get("name") or finding["resource_id"]
124
+ rtype = finding["resource_type"]
125
+ lines.append(f"{emoji} `{label}` · {rtype} · *${cost:,.2f}/mo*")
126
+
127
+ remaining = count - SLACK_DIGEST_LIMIT
128
+ if remaining > 0:
129
+ lines.append(
130
+ f":white_circle: _+{remaining} more "
131
+ f"finding{'s' if remaining != 1 else ''} in the full report_"
132
+ )
133
+
134
+ blocks.append(
135
+ {"type": "section", "text": {"type": "mrkdwn", "text": "\n".join(lines)}}
136
+ )
137
+ blocks.append({"type": "divider"})
138
+
139
+ actions: list[dict[str, Any]] = []
140
+ if report_url:
141
+ actions.append(
142
+ {
143
+ "type": "button",
144
+ "text": {
145
+ "type": "plain_text",
146
+ "text": ":page_facing_up: Full report (HTML)",
147
+ },
148
+ "url": report_url,
149
+ "style": "primary",
150
+ }
151
+ )
152
+ actions.append(
153
+ {
154
+ "type": "button",
155
+ "text": {"type": "plain_text", "text": "vamshisiddarth/argus"},
156
+ "url": "https://github.com/vamshisiddarth/argus",
157
+ }
158
+ )
159
+ blocks.append({"type": "actions", "elements": actions})
160
+
161
+ blocks.append(
162
+ {
163
+ "type": "context",
164
+ "elements": [{"type": "mrkdwn", "text": f"Scan ID: `{report['scan_id']}`"}],
165
+ }
166
+ )
167
+
168
+ return {"blocks": blocks}
core/reports/html.py ADDED
@@ -0,0 +1,286 @@
1
+ """
2
+ Self-contained HTML report generator for Argus.
3
+
4
+ Produces a single HTML file with:
5
+ - Summary stats header
6
+ - AI executive summary
7
+ - Filterable/sortable findings table
8
+ - Expandable rows with AI reasoning and recommendation
9
+
10
+ The output is intentionally self-contained (no external CDN) so it works
11
+ offline and is safe to serve from a pre-signed S3 URL.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import html
17
+ from datetime import datetime, timezone
18
+ from typing import Any
19
+
20
+
21
+ def build_html_report(report: dict[str, Any]) -> str:
22
+ cloud = report["cloud"].upper()
23
+ total = report["total_estimated_waste_usd"]
24
+ count = report["findings_count"]
25
+ generated_at = report["generated_at"][:10]
26
+ scan_id = report["scan_id"]
27
+ accounts = ", ".join(report.get("accounts_scanned", [])) or "—"
28
+ summary = html.escape(report.get("executive_summary", ""))
29
+ findings = report["findings"]
30
+
31
+ rows_html = _build_rows(findings)
32
+
33
+ return f"""<!DOCTYPE html>
34
+ <html lang="en">
35
+ <head>
36
+ <meta charset="utf-8">
37
+ <meta name="viewport" content="width=device-width, initial-scale=1">
38
+ <title>Argus — {cloud} Waste Report ({generated_at})</title>
39
+ <style>
40
+ *,*::before,*::after{{box-sizing:border-box;margin:0;padding:0}}
41
+ body{{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;font-size:14px;line-height:1.5;color:#111;background:#f5f5f4}}
42
+ a{{color:#185fa5;text-decoration:none}}
43
+ a:hover{{text-decoration:underline}}
44
+ .wrap{{max-width:1100px;margin:0 auto;padding:24px 16px}}
45
+ .header{{background:#fff;border:1px solid #e5e5e5;border-radius:10px;padding:20px 24px;margin-bottom:16px}}
46
+ .header-top{{display:flex;align-items:center;gap:10px;margin-bottom:12px}}
47
+ .logo{{width:22px;height:22px}}
48
+ .title{{font-size:17px;font-weight:600}}
49
+ .meta{{font-size:12px;color:#888;margin-left:auto}}
50
+ .stats{{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:14px}}
51
+ .stat{{background:#f5f5f4;border-radius:8px;padding:10px 16px;min-width:120px}}
52
+ .stat-val{{font-size:22px;font-weight:600}}
53
+ .stat-val.red{{color:#a32d2d}}
54
+ .stat-lbl{{font-size:11px;color:#666;margin-top:2px}}
55
+ .summary{{font-size:13px;color:#444;line-height:1.7;border-left:3px solid #e5e5e5;padding-left:12px}}
56
+ .filters{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px;align-items:center}}
57
+ .filters select,.filters input{{background:#fff;border:1px solid #d4d4d4;border-radius:6px;padding:6px 10px;font-size:13px;color:#111;outline:none}}
58
+ .filters select:focus,.filters input:focus{{border-color:#378add}}
59
+ .count-label{{margin-left:auto;font-size:12px;color:#888}}
60
+ .card{{background:#fff;border:1px solid #e5e5e5;border-radius:10px;overflow:hidden}}
61
+ table{{width:100%;border-collapse:collapse}}
62
+ thead th{{font-size:11px;font-weight:600;color:#888;text-transform:uppercase;letter-spacing:.04em;padding:9px 12px;border-bottom:1px solid #e5e5e5;background:#fafaf9;text-align:left;white-space:nowrap}}
63
+ thead th.sort{{cursor:pointer;user-select:none}}
64
+ thead th.sort:hover{{color:#111}}
65
+ tbody tr{{transition:background .1s}}
66
+ tbody tr:hover{{background:#fafaf9}}
67
+ tbody td{{padding:10px 12px;border-bottom:1px solid #f0f0f0;vertical-align:middle}}
68
+ tbody tr:last-child td{{border-bottom:none}}
69
+ .expand-btn{{background:none;border:none;cursor:pointer;color:#888;font-size:16px;line-height:1;padding:2px 4px;border-radius:4px}}
70
+ .expand-btn:hover{{background:#f0f0f0;color:#111}}
71
+ .pill{{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600}}
72
+ .pill-high{{background:#fcebeb;color:#a32d2d}}
73
+ .pill-medium{{background:#faeeda;color:#854f0b}}
74
+ .pill-low{{background:#eaf3de;color:#3b6d11}}
75
+ .mono{{font-family:'SFMono-Regular',Consolas,'Liberation Mono',Menlo,monospace;font-size:12px}}
76
+ .muted{{color:#888}}
77
+ .cost-high{{font-weight:600;color:#a32d2d}}
78
+ .cost-medium{{font-weight:600;color:#854f0b}}
79
+ .cost-low{{font-weight:600;color:#3b6d11}}
80
+ .detail-row td{{padding:0}}
81
+ .detail-inner{{display:none;background:#fafaf9;border-top:1px solid #f0f0f0;padding:12px 16px;font-size:12px;line-height:1.7;color:#444}}
82
+ .detail-inner.open{{display:block}}
83
+ .detail-label{{font-weight:600;color:#111;margin-right:4px}}
84
+ .footer{{display:flex;align-items:center;justify-content:space-between;padding:10px 16px;border-top:1px solid #f0f0f0;font-size:11px;color:#aaa}}
85
+ @media(max-width:640px){{.stats{{flex-direction:column}}.meta{{display:none}}}}
86
+ </style>
87
+ </head>
88
+ <body>
89
+ <div class="wrap">
90
+ <div class="header">
91
+ <div class="header-top">
92
+ <svg class="logo" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
93
+ <path d="M12 4L4 8l8 4 8-4-8-4z" fill="#E24B4A"/>
94
+ <path d="M4 12l8 4 8-4M4 16l8 4 8-4" stroke="#E24B4A" stroke-width="1.5" stroke-linecap="round"/>
95
+ </svg>
96
+ <span class="title">Argus — {cloud} Waste Report</span>
97
+ <span class="meta">{generated_at} &nbsp;·&nbsp; Scan {scan_id[:8]} &nbsp;·&nbsp; Accounts: {html.escape(accounts)}</span>
98
+ </div>
99
+ <div class="stats">
100
+ <div class="stat"><div class="stat-val red">${total:,.2f}</div><div class="stat-lbl">estimated waste / month</div></div>
101
+ <div class="stat"><div class="stat-val">{count}</div><div class="stat-lbl">findings</div></div>
102
+ <div class="stat"><div class="stat-val">{len(report.get("accounts_scanned", []))}</div><div class="stat-lbl">accounts scanned</div></div>
103
+ </div>
104
+ <p class="summary">{summary}</p>
105
+ </div>
106
+
107
+ <div class="filters">
108
+ <select id="f-priority" onchange="applyFilters()">
109
+ <option value="">All priorities</option>
110
+ <option>high</option><option>medium</option><option>low</option>
111
+ </select>
112
+ <select id="f-type" onchange="applyFilters()">
113
+ <option value="">All types</option>
114
+ {_build_type_options(findings)}
115
+ </select>
116
+ <input id="f-search" placeholder="Search resource ID, name, or region…" oninput="applyFilters()" style="min-width:220px">
117
+ <span class="count-label" id="count-label">Showing {count} of {count} findings</span>
118
+ </div>
119
+
120
+ <div class="card">
121
+ <table id="findings-table">
122
+ <thead>
123
+ <tr>
124
+ <th style="width:32px"></th>
125
+ <th class="sort" onclick="sortBy('priority')">Priority</th>
126
+ <th class="sort" onclick="sortBy('name')">Resource</th>
127
+ <th>Type</th>
128
+ <th>Region</th>
129
+ <th class="sort" onclick="sortBy('cost')">Cost / mo</th>
130
+ <th>Last activity</th>
131
+ </tr>
132
+ </thead>
133
+ <tbody id="tbody">
134
+ {rows_html}
135
+ </tbody>
136
+ </table>
137
+ <div class="footer">
138
+ <span>Generated by <a href="https://github.com/vamshisiddarth/argus">Argus</a> &nbsp;·&nbsp; Self-contained HTML, works offline</span>
139
+ <a href="#" onclick="downloadJson(event)">&#8595; Download JSON</a>
140
+ </div>
141
+ </div>
142
+ </div>
143
+
144
+ <script>
145
+ var RAW_JSON = {_json_data(report)};
146
+
147
+ function toggle(btn) {{
148
+ var row = btn.closest('tr');
149
+ var detail = row.nextElementSibling;
150
+ var inner = detail.querySelector('.detail-inner');
151
+ var open = inner.classList.contains('open');
152
+ inner.classList.toggle('open', !open);
153
+ btn.textContent = open ? '›' : '⌄';
154
+ }}
155
+
156
+ var sortState = {{col: 'cost', asc: false}};
157
+
158
+ function sortBy(col) {{
159
+ if (sortState.col === col) sortState.asc = !sortState.asc;
160
+ else {{ sortState.col = col; sortState.asc = col !== 'cost'; }}
161
+ applyFilters();
162
+ }}
163
+
164
+ var PRIORITY_ORDER = {{high: 0, medium: 1, low: 2}};
165
+
166
+ function applyFilters() {{
167
+ var p = document.getElementById('f-priority').value.toLowerCase();
168
+ var t = document.getElementById('f-type').value.toLowerCase();
169
+ var s = document.getElementById('f-search').value.toLowerCase();
170
+ var tbody = document.getElementById('tbody');
171
+ var rows = Array.from(tbody.querySelectorAll('tr[data-priority]'));
172
+
173
+ rows.forEach(function(row) {{
174
+ var dp = row.dataset.priority || '';
175
+ var dt = (row.dataset.rtype || '').toLowerCase();
176
+ var txt = row.textContent.toLowerCase();
177
+ var match = (!p || dp === p) && (!t || dt.includes(t)) && (!s || txt.includes(s));
178
+ row.style.display = match ? '' : 'none';
179
+ var detail = row.nextElementSibling;
180
+ if (detail && detail.classList.contains('detail-row')) {{
181
+ detail.style.display = match ? '' : 'none';
182
+ if (!match) detail.querySelector('.detail-inner').classList.remove('open');
183
+ }}
184
+ }});
185
+
186
+ var visible = rows.filter(function(r) {{ return r.style.display !== 'none'; }});
187
+
188
+ visible.sort(function(a, b) {{
189
+ var col = sortState.col;
190
+ var av, bv;
191
+ if (col === 'cost') {{
192
+ av = parseFloat(a.dataset.cost || 0);
193
+ bv = parseFloat(b.dataset.cost || 0);
194
+ }} else if (col === 'priority') {{
195
+ av = PRIORITY_ORDER[a.dataset.priority] || 99;
196
+ bv = PRIORITY_ORDER[b.dataset.priority] || 99;
197
+ }} else {{
198
+ av = (a.dataset.name || '').toLowerCase();
199
+ bv = (b.dataset.name || '').toLowerCase();
200
+ }}
201
+ if (av < bv) return sortState.asc ? -1 : 1;
202
+ if (av > bv) return sortState.asc ? 1 : -1;
203
+ return 0;
204
+ }});
205
+
206
+ visible.forEach(function(row) {{
207
+ var detail = row.nextElementSibling;
208
+ tbody.appendChild(row);
209
+ if (detail && detail.classList.contains('detail-row')) tbody.appendChild(detail);
210
+ }});
211
+
212
+ document.getElementById('count-label').textContent =
213
+ 'Showing ' + visible.length + ' of ' + rows.length + ' findings';
214
+ }}
215
+
216
+ function downloadJson(e) {{
217
+ e.preventDefault();
218
+ var blob = new Blob([JSON.stringify(RAW_JSON, null, 2)], {{type: 'application/json'}});
219
+ var a = document.createElement('a');
220
+ a.href = URL.createObjectURL(blob);
221
+ a.download = 'argus-report-' + RAW_JSON.scan_id.slice(0, 8) + '.json';
222
+ a.click();
223
+ }}
224
+ </script>
225
+ </body>
226
+ </html>"""
227
+
228
+
229
+ def _build_rows(findings: list[dict[str, Any]]) -> str:
230
+ parts: list[str] = []
231
+ for f in findings:
232
+ priority = (f.get("priority") or "low").lower()
233
+ resource_id = html.escape(f.get("resource_id") or "")
234
+ name = html.escape(f.get("name") or "")
235
+ rtype = html.escape(f.get("resource_type") or "")
236
+ region = html.escape(f.get("region") or "")
237
+ cost = f.get("estimated_monthly_cost") or 0.0
238
+ waste_reason = html.escape(f.get("waste_reason") or "")
239
+ recommendation = html.escape(f.get("recommendation") or "")
240
+ last_activity = f.get("last_activity")
241
+ if last_activity:
242
+ try:
243
+ dt = datetime.fromisoformat(str(last_activity).replace("Z", "+00:00"))
244
+ delta = datetime.now(tz=timezone.utc) - dt
245
+ days = delta.days
246
+ last_activity_str = f"{days}d ago" if days >= 0 else "—"
247
+ except (ValueError, TypeError):
248
+ last_activity_str = html.escape(str(last_activity))
249
+ else:
250
+ last_activity_str = "—"
251
+
252
+ display_name = name or resource_id
253
+ cost_class = f"cost-{priority}"
254
+ pill_class = f"pill-{priority}"
255
+
256
+ parts.append(
257
+ f"""<tr data-priority="{priority}" data-rtype="{rtype}" data-cost="{cost}" data-name="{display_name}">
258
+ <td><button class="expand-btn" onclick="toggle(this)" aria-label="expand">›</button></td>
259
+ <td><span class="pill {pill_class}">{priority.upper()}</span></td>
260
+ <td><span class="mono">{display_name}</span>{"<br><span class='mono muted'>" + resource_id + "</span>" if name else ""}</td>
261
+ <td class="muted">{rtype}</td>
262
+ <td class="muted">{region}</td>
263
+ <td class="{cost_class}">${cost:,.2f}</td>
264
+ <td class="muted">{last_activity_str}</td>
265
+ </tr>
266
+ <tr class="detail-row">
267
+ <td colspan="7"><div class="detail-inner">
268
+ <span class="detail-label">Why idle:</span>{waste_reason}<br>
269
+ <span class="detail-label">Recommendation:</span>{recommendation}
270
+ </div></td>
271
+ </tr>"""
272
+ )
273
+ return "\n".join(parts)
274
+
275
+
276
+ def _build_type_options(findings: list[dict[str, Any]]) -> str:
277
+ types = sorted(
278
+ {f.get("resource_type") or "" for f in findings if f.get("resource_type")}
279
+ )
280
+ return "\n".join(f"<option>{html.escape(t)}</option>" for t in types)
281
+
282
+
283
+ def _json_data(report: dict[str, Any]) -> str:
284
+ import json
285
+
286
+ return json.dumps(report, default=str)
@@ -0,0 +1,162 @@
1
+ """
2
+ Multi-cloud report aggregation and unified resource taxonomy.
3
+
4
+ Merges individual per-cloud reports into a single combined report with
5
+ normalized resource type names for cross-cloud comparison.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import uuid
11
+ from datetime import datetime, timezone
12
+ from typing import Any
13
+
14
+ RESOURCE_TAXONOMY: dict[str, str] = {
15
+ # Compute
16
+ "AWS::EC2::Instance": "Compute Instance",
17
+ "AWS::EC2::NatGateway": "NAT Gateway",
18
+ "AWS::Lambda::Function": "Serverless Function",
19
+ "GCE": "Compute Instance",
20
+ "CloudFunction": "Serverless Function",
21
+ "VirtualMachine": "Compute Instance",
22
+ "AzureFunction": "Serverless Function",
23
+ # Database
24
+ "AWS::RDS::DBInstance": "Relational Database",
25
+ "AWS::RDS::DBCluster": "Database Cluster",
26
+ "CloudSQL": "Relational Database",
27
+ "AlloyDB": "Database Cluster",
28
+ "AzureSQL": "Relational Database",
29
+ "CosmosDB": "NoSQL Database",
30
+ # Cache
31
+ "AWS::ElastiCache::CacheCluster": "Cache Cluster",
32
+ "AWS::ElastiCache::ReplicationGroup": "Cache Cluster",
33
+ "Memorystore": "Cache Cluster",
34
+ "AzureCache": "Cache Cluster",
35
+ # Data warehouse
36
+ "AWS::Redshift::Cluster": "Data Warehouse",
37
+ "BigQuery": "Data Warehouse",
38
+ "Synapse": "Data Warehouse",
39
+ # Search
40
+ "AWS::Elasticsearch::Domain": "Search Service",
41
+ # Storage
42
+ "AWS::EC2::Volume": "Block Storage",
43
+ "PersistentDisk": "Block Storage",
44
+ "ManagedDisk": "Block Storage",
45
+ # Load balancer
46
+ "AWS::ElasticLoadBalancingV2::LoadBalancer": "Load Balancer",
47
+ "LoadBalancer": "Load Balancer",
48
+ "AzureLB": "Load Balancer",
49
+ # Replication
50
+ "AWS::DMS::ReplicationInstance": "Replication Instance",
51
+ }
52
+
53
+
54
+ def normalize_resource_type(resource_type: str) -> str:
55
+ return RESOURCE_TAXONOMY.get(resource_type, resource_type)
56
+
57
+
58
+ def merge_reports(reports: list[dict[str, Any]]) -> dict[str, Any]:
59
+ """
60
+ Merge multiple per-cloud reports into one combined multi-cloud report.
61
+
62
+ Each input report is a standard Argus report dict (from build_report()).
63
+ The merged report has:
64
+ - All findings from all clouds, sorted by cost descending
65
+ - Each finding gets a `normalized_type` field
66
+ - Combined totals and executive summary
67
+ - Per-cloud breakdown in `cloud_breakdown`
68
+ """
69
+ if not reports:
70
+ return _empty_merged_report()
71
+
72
+ if len(reports) == 1:
73
+ return _enrich_single(reports[0])
74
+
75
+ all_findings: list[dict[str, Any]] = []
76
+ clouds: list[str] = []
77
+ accounts: list[str] = []
78
+ total_input_tokens = 0
79
+ total_output_tokens = 0
80
+ summaries: list[str] = []
81
+ cloud_breakdown: list[dict[str, Any]] = []
82
+
83
+ for report in reports:
84
+ cloud = report["cloud"]
85
+ clouds.append(cloud)
86
+ accounts.extend(report.get("accounts_scanned", []))
87
+ total_input_tokens += report.get("agent_input_tokens", 0)
88
+ total_output_tokens += report.get("agent_output_tokens", 0)
89
+
90
+ if report.get("executive_summary"):
91
+ summaries.append(f"[{cloud.upper()}] {report['executive_summary']}")
92
+
93
+ cloud_breakdown.append(
94
+ {
95
+ "cloud": cloud,
96
+ "findings_count": report["findings_count"],
97
+ "total_estimated_waste_usd": report["total_estimated_waste_usd"],
98
+ "scan_id": report["scan_id"],
99
+ }
100
+ )
101
+
102
+ for finding in report.get("findings", []):
103
+ enriched = {**finding}
104
+ enriched["normalized_type"] = normalize_resource_type(
105
+ finding["resource_type"]
106
+ )
107
+ all_findings.append(enriched)
108
+
109
+ all_findings.sort(key=lambda f: f["estimated_monthly_cost"], reverse=True)
110
+ total_waste = sum(f["estimated_monthly_cost"] for f in all_findings)
111
+
112
+ return {
113
+ "schema_version": "1.0",
114
+ "scan_id": str(uuid.uuid4()),
115
+ "generated_at": datetime.now(tz=timezone.utc).isoformat(),
116
+ "cloud": "multi",
117
+ "clouds": sorted(set(clouds)),
118
+ "accounts_scanned": accounts,
119
+ "total_estimated_waste_usd": round(total_waste, 2),
120
+ "findings_count": len(all_findings),
121
+ "findings": all_findings,
122
+ "executive_summary": " ".join(summaries),
123
+ "agent_input_tokens": total_input_tokens,
124
+ "agent_output_tokens": total_output_tokens,
125
+ "cloud_breakdown": cloud_breakdown,
126
+ }
127
+
128
+
129
+ def _empty_merged_report() -> dict[str, Any]:
130
+ return {
131
+ "schema_version": "1.0",
132
+ "scan_id": str(uuid.uuid4()),
133
+ "generated_at": datetime.now(tz=timezone.utc).isoformat(),
134
+ "cloud": "multi",
135
+ "clouds": [],
136
+ "accounts_scanned": [],
137
+ "total_estimated_waste_usd": 0.0,
138
+ "findings_count": 0,
139
+ "findings": [],
140
+ "executive_summary": "",
141
+ "agent_input_tokens": 0,
142
+ "agent_output_tokens": 0,
143
+ "cloud_breakdown": [],
144
+ }
145
+
146
+
147
+ def _enrich_single(report: dict[str, Any]) -> dict[str, Any]:
148
+ enriched = {**report}
149
+ enriched["clouds"] = [report["cloud"]]
150
+ enriched["cloud_breakdown"] = [
151
+ {
152
+ "cloud": report["cloud"],
153
+ "findings_count": report["findings_count"],
154
+ "total_estimated_waste_usd": report["total_estimated_waste_usd"],
155
+ "scan_id": report["scan_id"],
156
+ }
157
+ ]
158
+ for finding in enriched.get("findings", []):
159
+ finding["normalized_type"] = normalize_resource_type(
160
+ finding["resource_type"]
161
+ )
162
+ return enriched