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/models/finding.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
Cloud = Literal["aws", "gcp", "azure"]
|
|
8
|
+
Priority = Literal["high", "medium", "low"]
|
|
9
|
+
FindingStatus = Literal["new", "recurring", "resolved"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ResourceFinding:
|
|
14
|
+
"""
|
|
15
|
+
Universal representation of a single idle/wasteful cloud resource.
|
|
16
|
+
Produced by the agent loop, consumed by the report generator.
|
|
17
|
+
No cloud SDK imports — pure Python.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
resource_id: str
|
|
21
|
+
resource_type: str # e.g. "AWS::EC2::Instance", "AWS::RDS::DBInstance"
|
|
22
|
+
cloud: Cloud # "aws" | "gcp" | "azure"
|
|
23
|
+
region: str
|
|
24
|
+
estimated_monthly_cost: float # USD
|
|
25
|
+
waste_reason: str # AI-written: why this resource is idle/wasteful
|
|
26
|
+
recommendation: str # AI-written: specific action to take
|
|
27
|
+
priority: Priority # AI-assigned based on cost + confidence
|
|
28
|
+
metrics_summary: dict[str, Any] # key signals used to reach this conclusion
|
|
29
|
+
tags: dict[str, str]
|
|
30
|
+
scan_time: datetime
|
|
31
|
+
name: str | None = None
|
|
32
|
+
last_activity: datetime | None = None
|
|
33
|
+
status: FindingStatus = "new"
|
|
34
|
+
|
|
35
|
+
def to_dict(self) -> dict[str, Any]:
|
|
36
|
+
return {
|
|
37
|
+
"resource_id": self.resource_id,
|
|
38
|
+
"resource_type": self.resource_type,
|
|
39
|
+
"cloud": self.cloud,
|
|
40
|
+
"region": self.region,
|
|
41
|
+
"name": self.name,
|
|
42
|
+
"estimated_monthly_cost": self.estimated_monthly_cost,
|
|
43
|
+
"waste_reason": self.waste_reason,
|
|
44
|
+
"recommendation": self.recommendation,
|
|
45
|
+
"priority": self.priority,
|
|
46
|
+
"metrics_summary": self.metrics_summary,
|
|
47
|
+
"tags": self.tags,
|
|
48
|
+
"last_activity": (
|
|
49
|
+
self.last_activity.isoformat() if self.last_activity else None
|
|
50
|
+
),
|
|
51
|
+
"scan_time": self.scan_time.isoformat(),
|
|
52
|
+
"status": self.status,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def from_dict(cls, data: dict[str, Any], scan_time: datetime) -> ResourceFinding:
|
|
57
|
+
last_activity = None
|
|
58
|
+
if data.get("last_activity"):
|
|
59
|
+
last_activity = datetime.fromisoformat(data["last_activity"])
|
|
60
|
+
|
|
61
|
+
return cls(
|
|
62
|
+
resource_id=data["resource_id"],
|
|
63
|
+
resource_type=data["resource_type"],
|
|
64
|
+
cloud=data["cloud"],
|
|
65
|
+
region=data["region"],
|
|
66
|
+
name=data.get("name"),
|
|
67
|
+
estimated_monthly_cost=float(data["estimated_monthly_cost"]),
|
|
68
|
+
waste_reason=data["waste_reason"],
|
|
69
|
+
recommendation=data["recommendation"],
|
|
70
|
+
priority=data["priority"],
|
|
71
|
+
metrics_summary=data.get("metrics_summary", {}),
|
|
72
|
+
tags=data.get("tags", {}),
|
|
73
|
+
last_activity=last_activity,
|
|
74
|
+
scan_time=scan_time,
|
|
75
|
+
status=data.get("status", "new"),
|
|
76
|
+
)
|
core/py.typed
ADDED
|
File without changes
|
core/reports/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from core.models.finding import ResourceFinding
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def compare_scans(
|
|
9
|
+
current_findings: list[ResourceFinding],
|
|
10
|
+
previous_report: dict[str, Any] | None,
|
|
11
|
+
) -> tuple[list[ResourceFinding], dict[str, Any]]:
|
|
12
|
+
"""
|
|
13
|
+
Compare current findings against a previous report and label each finding's status.
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
(labelled_findings, diff_summary)
|
|
17
|
+
labelled_findings: current findings with status set to "new" or "recurring"
|
|
18
|
+
diff_summary: dict with new/recurring/resolved counts
|
|
19
|
+
and resolved_resource_ids
|
|
20
|
+
"""
|
|
21
|
+
if not previous_report:
|
|
22
|
+
for f in current_findings:
|
|
23
|
+
f.status = "new"
|
|
24
|
+
return current_findings, {
|
|
25
|
+
"previous_scan_id": None,
|
|
26
|
+
"new_findings": len(current_findings),
|
|
27
|
+
"recurring_findings": 0,
|
|
28
|
+
"resolved_findings": 0,
|
|
29
|
+
"resolved_resource_ids": [],
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
prev_resource_ids = {f["resource_id"] for f in previous_report.get("findings", [])}
|
|
33
|
+
current_resource_ids = {f.resource_id for f in current_findings}
|
|
34
|
+
|
|
35
|
+
for f in current_findings:
|
|
36
|
+
f.status = "recurring" if f.resource_id in prev_resource_ids else "new"
|
|
37
|
+
|
|
38
|
+
resolved_ids = sorted(prev_resource_ids - current_resource_ids)
|
|
39
|
+
|
|
40
|
+
new_count = sum(1 for f in current_findings if f.status == "new")
|
|
41
|
+
recurring_count = sum(1 for f in current_findings if f.status == "recurring")
|
|
42
|
+
|
|
43
|
+
return current_findings, {
|
|
44
|
+
"previous_scan_id": previous_report.get("scan_id"),
|
|
45
|
+
"new_findings": new_count,
|
|
46
|
+
"recurring_findings": recurring_count,
|
|
47
|
+
"resolved_findings": len(resolved_ids),
|
|
48
|
+
"resolved_resource_ids": resolved_ids,
|
|
49
|
+
}
|
core/reports/delivery.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import urllib.request
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import structlog
|
|
12
|
+
|
|
13
|
+
logger = structlog.get_logger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# Exceptions
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NotificationDeliveryError(Exception):
|
|
22
|
+
"""Raised when a notification provider fails to deliver."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SlackDeliveryError(NotificationDeliveryError):
|
|
26
|
+
"""Raised when Slack rejects the webhook payload."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TeamsDeliveryError(NotificationDeliveryError):
|
|
30
|
+
"""Raised when Teams rejects the webhook payload."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class WebhookDeliveryError(NotificationDeliveryError):
|
|
34
|
+
"""Raised when a generic webhook call fails."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# NotificationProvider ABC
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class NotificationProvider(ABC):
|
|
43
|
+
@abstractmethod
|
|
44
|
+
def notify(self, payload: dict[str, Any]) -> None: ...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# Slack
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SlackNotificationProvider(NotificationProvider):
|
|
53
|
+
def __init__(self, webhook_url: str | None = None) -> None:
|
|
54
|
+
from core.config import get_settings
|
|
55
|
+
|
|
56
|
+
self._url = (
|
|
57
|
+
webhook_url
|
|
58
|
+
if webhook_url is not None
|
|
59
|
+
else get_settings().report.slack_webhook_url
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def notify(self, payload: dict[str, Any]) -> None:
|
|
63
|
+
if not self._url:
|
|
64
|
+
raise EnvironmentError(
|
|
65
|
+
"SLACK_WEBHOOK_URL is not set. "
|
|
66
|
+
"Export it or pass webhook_url= explicitly."
|
|
67
|
+
)
|
|
68
|
+
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
69
|
+
req = urllib.request.Request(
|
|
70
|
+
self._url,
|
|
71
|
+
data=body,
|
|
72
|
+
headers={"Content-Type": "application/json"},
|
|
73
|
+
method="POST",
|
|
74
|
+
)
|
|
75
|
+
try:
|
|
76
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
77
|
+
response_text = resp.read().decode("utf-8")
|
|
78
|
+
except urllib.error.HTTPError as exc:
|
|
79
|
+
raise SlackDeliveryError(
|
|
80
|
+
f"Slack webhook returned HTTP {exc.code}: {exc.reason}"
|
|
81
|
+
) from exc
|
|
82
|
+
except urllib.error.URLError as exc:
|
|
83
|
+
raise SlackDeliveryError(
|
|
84
|
+
f"Failed to reach Slack webhook: {exc.reason}"
|
|
85
|
+
) from exc
|
|
86
|
+
|
|
87
|
+
if response_text.strip() != "ok":
|
|
88
|
+
raise SlackDeliveryError(f"Unexpected Slack response: {response_text!r}")
|
|
89
|
+
|
|
90
|
+
logger.info("slack_report_delivered")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
# Microsoft Teams (Office 365 Incoming Webhook)
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class TeamsNotificationProvider(NotificationProvider):
|
|
99
|
+
def __init__(self, webhook_url: str | None = None) -> None:
|
|
100
|
+
from core.config import get_settings
|
|
101
|
+
|
|
102
|
+
self._url = (
|
|
103
|
+
webhook_url
|
|
104
|
+
if webhook_url is not None
|
|
105
|
+
else get_settings().report.teams_webhook_url
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def notify(self, payload: dict[str, Any]) -> None:
|
|
109
|
+
if not self._url:
|
|
110
|
+
raise EnvironmentError(
|
|
111
|
+
"TEAMS_WEBHOOK_URL is not set. "
|
|
112
|
+
"Export it or pass webhook_url= explicitly."
|
|
113
|
+
)
|
|
114
|
+
teams_payload = self._to_teams_card(payload)
|
|
115
|
+
body = json.dumps(teams_payload, ensure_ascii=False).encode("utf-8")
|
|
116
|
+
req = urllib.request.Request(
|
|
117
|
+
self._url,
|
|
118
|
+
data=body,
|
|
119
|
+
headers={"Content-Type": "application/json"},
|
|
120
|
+
method="POST",
|
|
121
|
+
)
|
|
122
|
+
try:
|
|
123
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
124
|
+
resp.read()
|
|
125
|
+
except urllib.error.HTTPError as exc:
|
|
126
|
+
raise TeamsDeliveryError(
|
|
127
|
+
f"Teams webhook returned HTTP {exc.code}: {exc.reason}"
|
|
128
|
+
) from exc
|
|
129
|
+
except urllib.error.URLError as exc:
|
|
130
|
+
raise TeamsDeliveryError(
|
|
131
|
+
f"Failed to reach Teams webhook: {exc.reason}"
|
|
132
|
+
) from exc
|
|
133
|
+
|
|
134
|
+
logger.info("teams_report_delivered")
|
|
135
|
+
|
|
136
|
+
def _to_teams_card(self, slack_payload: dict[str, Any]) -> dict[str, Any]:
|
|
137
|
+
text_parts: list[str] = []
|
|
138
|
+
for block in slack_payload.get("blocks", []):
|
|
139
|
+
if block.get("type") == "section":
|
|
140
|
+
text_obj = block.get("text", {})
|
|
141
|
+
text_parts.append(text_obj.get("text", ""))
|
|
142
|
+
elif block.get("type") == "header":
|
|
143
|
+
text_obj = block.get("text", {})
|
|
144
|
+
text_parts.append(f"**{text_obj.get('text', '')}**")
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
"@type": "MessageCard",
|
|
148
|
+
"@context": "http://schema.org/extensions",
|
|
149
|
+
"summary": "Argus Cost Report",
|
|
150
|
+
"themeColor": "0076D7",
|
|
151
|
+
"title": "Argus Cost Optimization Report",
|
|
152
|
+
"text": "\n\n".join(text_parts),
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
# Generic Webhook (HTTP POST with raw JSON)
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class WebhookNotificationProvider(NotificationProvider):
|
|
162
|
+
def __init__(self, webhook_url: str | None = None) -> None:
|
|
163
|
+
from core.config import get_settings
|
|
164
|
+
|
|
165
|
+
self._url = (
|
|
166
|
+
webhook_url
|
|
167
|
+
if webhook_url is not None
|
|
168
|
+
else get_settings().report.webhook_url
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def notify(self, payload: dict[str, Any]) -> None:
|
|
172
|
+
if not self._url:
|
|
173
|
+
raise EnvironmentError(
|
|
174
|
+
"WEBHOOK_URL is not set. " "Export it or pass webhook_url= explicitly."
|
|
175
|
+
)
|
|
176
|
+
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
177
|
+
req = urllib.request.Request(
|
|
178
|
+
self._url,
|
|
179
|
+
data=body,
|
|
180
|
+
headers={"Content-Type": "application/json"},
|
|
181
|
+
method="POST",
|
|
182
|
+
)
|
|
183
|
+
try:
|
|
184
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
185
|
+
resp.read()
|
|
186
|
+
except urllib.error.HTTPError as exc:
|
|
187
|
+
raise WebhookDeliveryError(
|
|
188
|
+
f"Webhook returned HTTP {exc.code}: {exc.reason}"
|
|
189
|
+
) from exc
|
|
190
|
+
except urllib.error.URLError as exc:
|
|
191
|
+
raise WebhookDeliveryError(
|
|
192
|
+
f"Failed to reach webhook: {exc.reason}"
|
|
193
|
+
) from exc
|
|
194
|
+
|
|
195
|
+
logger.info("webhook_report_delivered")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
# Provider registry + dispatcher
|
|
200
|
+
# ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
_PROVIDER_MAP: dict[str, type[NotificationProvider]] = {
|
|
203
|
+
"slack": SlackNotificationProvider,
|
|
204
|
+
"teams": TeamsNotificationProvider,
|
|
205
|
+
"webhook": WebhookNotificationProvider,
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def build_notification_providers() -> list[NotificationProvider]:
|
|
210
|
+
raw = os.environ.get("NOTIFICATION_PROVIDER", "slack")
|
|
211
|
+
names = [n.strip().lower() for n in raw.split(",") if n.strip()]
|
|
212
|
+
providers: list[NotificationProvider] = []
|
|
213
|
+
for name in names:
|
|
214
|
+
cls = _PROVIDER_MAP.get(name)
|
|
215
|
+
if cls is None:
|
|
216
|
+
logger.warning("unknown_notification_provider", provider=name)
|
|
217
|
+
continue
|
|
218
|
+
providers.append(cls())
|
|
219
|
+
return providers
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def notify_all(payload: dict[str, Any], dry_run: bool | None = None) -> None:
|
|
223
|
+
from core.config import get_settings
|
|
224
|
+
|
|
225
|
+
resolved_dry_run = (
|
|
226
|
+
dry_run if dry_run is not None else get_settings().report.dry_run
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
if resolved_dry_run:
|
|
230
|
+
logger.info(
|
|
231
|
+
"dry_run_notification_skipped",
|
|
232
|
+
payload_preview=json.dumps(payload, indent=2)[:500],
|
|
233
|
+
)
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
providers = build_notification_providers()
|
|
237
|
+
for provider in providers:
|
|
238
|
+
try:
|
|
239
|
+
provider.notify(payload)
|
|
240
|
+
except (NotificationDeliveryError, OSError) as exc:
|
|
241
|
+
logger.error(
|
|
242
|
+
"notification_delivery_failed",
|
|
243
|
+
provider=type(provider).__name__,
|
|
244
|
+
error=str(exc),
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# ---------------------------------------------------------------------------
|
|
249
|
+
# Legacy function — delegates to notify_all for backward compatibility
|
|
250
|
+
# ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def post_to_slack(
|
|
254
|
+
payload: dict[str, Any],
|
|
255
|
+
webhook_url: str | None = None,
|
|
256
|
+
dry_run: bool | None = None,
|
|
257
|
+
) -> None:
|
|
258
|
+
from core.config import get_settings
|
|
259
|
+
|
|
260
|
+
resolved_dry_run = (
|
|
261
|
+
dry_run if dry_run is not None else get_settings().report.dry_run
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
if resolved_dry_run:
|
|
265
|
+
logger.info(
|
|
266
|
+
"[DRY RUN] Slack payload (not sent):\n%s", json.dumps(payload, indent=2)
|
|
267
|
+
)
|
|
268
|
+
return
|
|
269
|
+
|
|
270
|
+
provider = SlackNotificationProvider(webhook_url=webhook_url)
|
|
271
|
+
provider.notify(payload)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# ---------------------------------------------------------------------------
|
|
275
|
+
# Local report saving (unchanged)
|
|
276
|
+
# ---------------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def save_reports_locally(
|
|
280
|
+
report: dict[str, Any],
|
|
281
|
+
base_dir: str | None = None,
|
|
282
|
+
) -> str:
|
|
283
|
+
from core.config import get_settings
|
|
284
|
+
from core.reports.export import export_pdf, export_pptx, get_report_formats
|
|
285
|
+
|
|
286
|
+
resolved_dir = Path(base_dir or get_settings().report.local_report_dir)
|
|
287
|
+
now = datetime.now(tz=timezone.utc)
|
|
288
|
+
prefix = (
|
|
289
|
+
resolved_dir / report["cloud"] / now.strftime("%Y/%m/%d") / report["scan_id"]
|
|
290
|
+
)
|
|
291
|
+
prefix.parent.mkdir(parents=True, exist_ok=True)
|
|
292
|
+
|
|
293
|
+
formats = get_report_formats()
|
|
294
|
+
result_path = str(prefix)
|
|
295
|
+
|
|
296
|
+
if "json" in formats:
|
|
297
|
+
json_path = prefix.with_suffix(".json")
|
|
298
|
+
json_path.write_text(
|
|
299
|
+
json.dumps(report, indent=2, default=str), encoding="utf-8"
|
|
300
|
+
)
|
|
301
|
+
logger.info("json_report_saved", path=str(json_path))
|
|
302
|
+
|
|
303
|
+
if "html" in formats:
|
|
304
|
+
from core.reports.html import build_html_report
|
|
305
|
+
|
|
306
|
+
html_path = prefix.with_suffix(".html")
|
|
307
|
+
html_path.write_text(build_html_report(report), encoding="utf-8")
|
|
308
|
+
logger.info("html_report_saved", path=str(html_path))
|
|
309
|
+
result_path = str(html_path.resolve())
|
|
310
|
+
|
|
311
|
+
if "pdf" in formats:
|
|
312
|
+
try:
|
|
313
|
+
export_pdf(report, prefix)
|
|
314
|
+
except ImportError as exc:
|
|
315
|
+
logger.warning("pdf_export_skipped", reason=str(exc))
|
|
316
|
+
|
|
317
|
+
if "pptx" in formats:
|
|
318
|
+
try:
|
|
319
|
+
export_pptx(report, prefix)
|
|
320
|
+
except ImportError as exc:
|
|
321
|
+
logger.warning("pptx_export_skipped", reason=str(exc))
|
|
322
|
+
|
|
323
|
+
return result_path
|
core/reports/export.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Multi-format report export: PDF and PPTX.
|
|
3
|
+
|
|
4
|
+
Both are optional — the libraries are only imported when the format is requested.
|
|
5
|
+
Install with: pip install weasyprint python-pptx
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import structlog
|
|
14
|
+
|
|
15
|
+
logger = structlog.get_logger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_report_formats() -> set[str]:
|
|
19
|
+
from core.config import get_settings
|
|
20
|
+
|
|
21
|
+
return get_settings().report.formats
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def export_pdf(report: dict[str, Any], output_path: Path) -> Path:
|
|
25
|
+
try:
|
|
26
|
+
from weasyprint import HTML # type: ignore[import-untyped]
|
|
27
|
+
except ImportError as exc:
|
|
28
|
+
raise ImportError(
|
|
29
|
+
"weasyprint is required for PDF export. "
|
|
30
|
+
"Install with: pip install weasyprint"
|
|
31
|
+
) from exc
|
|
32
|
+
|
|
33
|
+
from core.reports.html import build_html_report
|
|
34
|
+
|
|
35
|
+
html_content = build_html_report(report)
|
|
36
|
+
pdf_path = output_path.with_suffix(".pdf")
|
|
37
|
+
HTML(string=html_content).write_pdf(str(pdf_path))
|
|
38
|
+
logger.info("pdf_report_saved", path=str(pdf_path))
|
|
39
|
+
return pdf_path
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def export_pptx(report: dict[str, Any], output_path: Path) -> Path:
|
|
43
|
+
try:
|
|
44
|
+
from pptx import Presentation # type: ignore[import-untyped]
|
|
45
|
+
from pptx.util import Inches # type: ignore[import-untyped]
|
|
46
|
+
except ImportError as exc:
|
|
47
|
+
raise ImportError(
|
|
48
|
+
"python-pptx is required for PPTX export. "
|
|
49
|
+
"Install with: pip install python-pptx"
|
|
50
|
+
) from exc
|
|
51
|
+
|
|
52
|
+
prs = Presentation()
|
|
53
|
+
prs.slide_width = Inches(13.333)
|
|
54
|
+
prs.slide_height = Inches(7.5)
|
|
55
|
+
|
|
56
|
+
# Title slide
|
|
57
|
+
slide = prs.slides.add_slide(prs.slide_layouts[0])
|
|
58
|
+
slide.shapes.title.text = f"Argus — {report['cloud'].upper()} Waste Report"
|
|
59
|
+
slide.placeholders[1].text = (
|
|
60
|
+
f"Generated: {report['generated_at'][:10]}\n"
|
|
61
|
+
f"Total estimated waste: ${report['total_estimated_waste_usd']:,.2f}/month\n"
|
|
62
|
+
f"Findings: {report['findings_count']}"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Executive summary slide
|
|
66
|
+
slide = prs.slides.add_slide(prs.slide_layouts[1])
|
|
67
|
+
slide.shapes.title.text = "Executive Summary"
|
|
68
|
+
slide.placeholders[1].text = report.get("executive_summary", "")
|
|
69
|
+
|
|
70
|
+
# Top findings slide(s) — max 8 per slide
|
|
71
|
+
findings = report.get("findings", [])
|
|
72
|
+
chunk_size = 8
|
|
73
|
+
for i in range(0, len(findings), chunk_size):
|
|
74
|
+
chunk = findings[i : i + chunk_size]
|
|
75
|
+
slide = prs.slides.add_slide(prs.slide_layouts[1])
|
|
76
|
+
slide.shapes.title.text = (
|
|
77
|
+
f"Top Findings ({i + 1}–{i + len(chunk)} of {len(findings)})"
|
|
78
|
+
)
|
|
79
|
+
lines = []
|
|
80
|
+
for f in chunk:
|
|
81
|
+
name = f.get("name") or f["resource_id"]
|
|
82
|
+
cost = f["estimated_monthly_cost"]
|
|
83
|
+
priority = (f.get("priority") or "low").upper()
|
|
84
|
+
lines.append(
|
|
85
|
+
f"[{priority}] {name} — {f['resource_type']} — " f"${cost:,.2f}/mo"
|
|
86
|
+
)
|
|
87
|
+
lines.append(f" → {f.get('recommendation', '')}")
|
|
88
|
+
slide.placeholders[1].text = "\n".join(lines)
|
|
89
|
+
|
|
90
|
+
# Scan metadata slide
|
|
91
|
+
slide = prs.slides.add_slide(prs.slide_layouts[1])
|
|
92
|
+
slide.shapes.title.text = "Scan Details"
|
|
93
|
+
meta_lines = [
|
|
94
|
+
f"Scan ID: {report['scan_id']}",
|
|
95
|
+
f"Cloud: {report['cloud']}",
|
|
96
|
+
f"Accounts: {', '.join(report.get('accounts_scanned', []))}",
|
|
97
|
+
]
|
|
98
|
+
if report.get("agent_input_tokens"):
|
|
99
|
+
meta_lines.append(
|
|
100
|
+
f"AI tokens: {report['agent_input_tokens']:,} in / "
|
|
101
|
+
f"{report['agent_output_tokens']:,} out"
|
|
102
|
+
)
|
|
103
|
+
meta_lines.append(
|
|
104
|
+
f"Estimated AI cost: ${report.get('estimated_agent_cost_usd', 0):.4f}"
|
|
105
|
+
)
|
|
106
|
+
slide.placeholders[1].text = "\n".join(meta_lines)
|
|
107
|
+
|
|
108
|
+
pptx_path = output_path.with_suffix(".pptx")
|
|
109
|
+
prs.save(str(pptx_path))
|
|
110
|
+
logger.info("pptx_report_saved", path=str(pptx_path))
|
|
111
|
+
return pptx_path
|