costguard-cli 2.0.16__tar.gz → 2.2.0__tar.gz
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.
- {costguard_cli-2.0.16 → costguard_cli-2.2.0}/PKG-INFO +2 -1
- {costguard_cli-2.0.16 → costguard_cli-2.2.0}/costguard_cli/__init__.py +1 -1
- {costguard_cli-2.0.16 → costguard_cli-2.2.0}/costguard_cli/formatters/html_report.py +39 -5
- {costguard_cli-2.0.16 → costguard_cli-2.2.0}/costguard_cli/formatters/markdown.py +109 -12
- {costguard_cli-2.0.16 → costguard_cli-2.2.0}/costguard_cli/validate.py +115 -11
- {costguard_cli-2.0.16 → costguard_cli-2.2.0}/costguard_cli.egg-info/PKG-INFO +2 -1
- {costguard_cli-2.0.16 → costguard_cli-2.2.0}/costguard_cli.egg-info/SOURCES.txt +3 -1
- {costguard_cli-2.0.16 → costguard_cli-2.2.0}/costguard_cli.egg-info/requires.txt +1 -0
- {costguard_cli-2.0.16 → costguard_cli-2.2.0}/pyproject.toml +2 -1
- costguard_cli-2.2.0/tests/test_formatters_cost_diff.py +157 -0
- costguard_cli-2.2.0/tests/test_resource_table_collapse.py +54 -0
- {costguard_cli-2.0.16 → costguard_cli-2.2.0}/LICENSE +0 -0
- {costguard_cli-2.0.16 → costguard_cli-2.2.0}/README.md +0 -0
- {costguard_cli-2.0.16 → costguard_cli-2.2.0}/costguard_cli/__main__.py +0 -0
- {costguard_cli-2.0.16 → costguard_cli-2.2.0}/costguard_cli/formatters/__init__.py +0 -0
- {costguard_cli-2.0.16 → costguard_cli-2.2.0}/costguard_cli/formatters/json_report.py +0 -0
- {costguard_cli-2.0.16 → costguard_cli-2.2.0}/costguard_cli/formatters/terminal.py +0 -0
- {costguard_cli-2.0.16 → costguard_cli-2.2.0}/costguard_cli/platforms.py +0 -0
- {costguard_cli-2.0.16 → costguard_cli-2.2.0}/costguard_cli/py.typed +0 -0
- {costguard_cli-2.0.16 → costguard_cli-2.2.0}/costguard_cli.egg-info/dependency_links.txt +0 -0
- {costguard_cli-2.0.16 → costguard_cli-2.2.0}/costguard_cli.egg-info/entry_points.txt +0 -0
- {costguard_cli-2.0.16 → costguard_cli-2.2.0}/costguard_cli.egg-info/top_level.txt +0 -0
- {costguard_cli-2.0.16 → costguard_cli-2.2.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: costguard-cli
|
|
3
|
-
Version: 2.0
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: CostGuard CI/CD validation CLI — shift-left cost governance for cloud infrastructure
|
|
5
5
|
Author-email: SKYXOPS <engineering@skyxops.com>
|
|
6
6
|
License-Expression: LicenseRef-Proprietary
|
|
@@ -24,6 +24,7 @@ Description-Content-Type: text/markdown
|
|
|
24
24
|
License-File: LICENSE
|
|
25
25
|
Requires-Dist: requests>=2.28.0
|
|
26
26
|
Requires-Dist: jinja2>=3.1.0
|
|
27
|
+
Requires-Dist: pyyaml>=6.0
|
|
27
28
|
Provides-Extra: dev
|
|
28
29
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
29
30
|
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
@@ -1271,26 +1271,60 @@ class HtmlFormatter:
|
|
|
1271
1271
|
|
|
1272
1272
|
decision = (endpoint_data.get("decision") or "ERROR").upper()
|
|
1273
1273
|
|
|
1274
|
-
# Cost values — support both flat summary and legacy
|
|
1274
|
+
# Cost values — support both flat summary and legacy. AI-CG-11:
|
|
1275
|
+
# when past/planned/delta are present, the headline becomes the
|
|
1276
|
+
# signed delta so DELETE / DOWNSIZE PRs render the savings instead
|
|
1277
|
+
# of the misleading "$0.00" the legacy field reports.
|
|
1275
1278
|
summary = results.get("summary") or {}
|
|
1276
|
-
total_monthly = summary.get("total_monthly_cost") or results.get("total_monthly_usd")
|
|
1277
1279
|
currency = summary.get("currency") or results.get("currency", "USD")
|
|
1278
|
-
total_cost_str = f"${total_monthly:,.2f}" if total_monthly is not None else "N/A"
|
|
1279
1280
|
resources_raw = results.get("resources") or []
|
|
1280
1281
|
resource_count = summary.get("total_resources") or len(resources_raw)
|
|
1281
1282
|
|
|
1282
|
-
|
|
1283
|
+
past_total = results.get("past_monthly_cost")
|
|
1284
|
+
planned_total = results.get("planned_monthly_cost")
|
|
1285
|
+
delta_total = results.get("delta_monthly_cost")
|
|
1286
|
+
legacy_total = summary.get("total_monthly_cost") or results.get("total_monthly_usd")
|
|
1287
|
+
# Keep `total_monthly` defined for legacy code paths below (violations
|
|
1288
|
+
# block etc. expects this variable to exist).
|
|
1289
|
+
total_monthly = legacy_total
|
|
1290
|
+
|
|
1291
|
+
if delta_total is not None and (past_total is not None or planned_total is not None):
|
|
1292
|
+
sign = "+" if delta_total >= 0 else "-"
|
|
1293
|
+
kind = "increase" if delta_total > 0 else ("savings" if delta_total < 0 else "no change")
|
|
1294
|
+
total_cost_str = f"{sign}${abs(delta_total):,.2f} ({kind})"
|
|
1295
|
+
else:
|
|
1296
|
+
total_cost_str = f"${legacy_total:,.2f}" if legacy_total is not None else "N/A"
|
|
1297
|
+
|
|
1298
|
+
# Prepare resource rows — for DELETE the customer-visible cost
|
|
1299
|
+
# column reflects the SAVINGS (signed negative), not the legacy
|
|
1300
|
+
# future-state $0.
|
|
1301
|
+
def _fmt_signed(v):
|
|
1302
|
+
return f"{'+' if v >= 0 else '-'}${abs(v):,.2f}"
|
|
1303
|
+
|
|
1283
1304
|
resources = []
|
|
1284
1305
|
for r in resources_raw:
|
|
1285
1306
|
mc = r.get("monthly_cost")
|
|
1286
1307
|
hc = r.get("hourly_cost")
|
|
1308
|
+
ext = r.get("extensions") or {}
|
|
1309
|
+
action = ext.get("action")
|
|
1310
|
+
delta = ext.get("delta_monthly_cost")
|
|
1311
|
+
|
|
1312
|
+
if delta is not None and action in ("delete", "update", "create"):
|
|
1313
|
+
# New-shape: render signed delta as the headline cost
|
|
1314
|
+
monthly_str = _fmt_signed(delta)
|
|
1315
|
+
else:
|
|
1316
|
+
monthly_str = f"${mc:,.2f}" if mc is not None else "N/A"
|
|
1317
|
+
|
|
1287
1318
|
resources.append({
|
|
1288
1319
|
"resource_name": r.get("resource_name") or r.get("resource_id", "unknown"),
|
|
1289
1320
|
"resource_type": r.get("resource_type", "unknown"),
|
|
1290
1321
|
"provider": (r.get("provider") or "-").upper(),
|
|
1291
1322
|
"region": r.get("region", "-"),
|
|
1292
1323
|
"hourly_str": f"${hc:,.4f}" if hc is not None else "-",
|
|
1293
|
-
"monthly_str":
|
|
1324
|
+
"monthly_str": monthly_str,
|
|
1325
|
+
"action": (action or "").capitalize(),
|
|
1326
|
+
"past_str": f"${ext.get('past_monthly_cost', 0):,.2f}" if ext.get('past_monthly_cost') is not None else "—",
|
|
1327
|
+
"planned_str": f"${ext.get('planned_monthly_cost', 0):,.2f}" if ext.get('planned_monthly_cost') is not None else "—",
|
|
1294
1328
|
})
|
|
1295
1329
|
|
|
1296
1330
|
# Violations
|
|
@@ -17,6 +17,11 @@ class MarkdownFormatter:
|
|
|
17
17
|
"low": "🔵",
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
# Wrap per-resource table in <details> when row count exceeds this.
|
|
21
|
+
# Big plans (50-100+ resources) otherwise bury budget/violations/AI
|
|
22
|
+
# narrative below an unreadable wall of rows.
|
|
23
|
+
RESOURCE_COLLAPSE_THRESHOLD = 10
|
|
24
|
+
|
|
20
25
|
def format(self, response: dict, tag: str | None = None, **kwargs) -> str:
|
|
21
26
|
"""Format the full CostGuard API response as Markdown."""
|
|
22
27
|
sections: list[str] = []
|
|
@@ -84,12 +89,39 @@ class MarkdownFormatter:
|
|
|
84
89
|
)
|
|
85
90
|
|
|
86
91
|
def _cost_summary(self, results: dict) -> str:
|
|
92
|
+
"""Render plan-level summary.
|
|
93
|
+
|
|
94
|
+
AI-CG-11: when the response carries past/planned/delta (server added
|
|
95
|
+
these in feature/ai-cg-11-cost-diff), surface the signed delta as
|
|
96
|
+
the headline so DELETE / DOWNSIZE PRs show their savings instead of
|
|
97
|
+
the misleading "Estimated Monthly Cost: $0" the legacy field reports.
|
|
98
|
+
"""
|
|
87
99
|
summary = results.get("summary") or {}
|
|
88
|
-
total = summary.get("total_monthly_cost") or results.get("total_monthly_usd")
|
|
89
100
|
currency = summary.get("currency") or results.get("currency", "USD")
|
|
90
101
|
resource_count = summary.get("total_resources") or len(results.get("resources") or [])
|
|
91
102
|
|
|
92
|
-
|
|
103
|
+
# New cost-diff fields take precedence when present.
|
|
104
|
+
past = results.get("past_monthly_cost")
|
|
105
|
+
planned = results.get("planned_monthly_cost")
|
|
106
|
+
delta = results.get("delta_monthly_cost")
|
|
107
|
+
legacy_total = summary.get("total_monthly_cost") or results.get("total_monthly_usd")
|
|
108
|
+
|
|
109
|
+
if delta is not None and (past is not None or planned is not None):
|
|
110
|
+
sign = "+" if delta >= 0 else "-"
|
|
111
|
+
kind = "increase" if delta > 0 else ("savings" if delta < 0 else "no change")
|
|
112
|
+
past_str = f"${past:,.2f}" if past is not None else "$0.00"
|
|
113
|
+
planned_str = f"${planned:,.2f}" if planned is not None else "$0.00"
|
|
114
|
+
return (
|
|
115
|
+
f"### Cost Summary\n\n"
|
|
116
|
+
f"| Metric | Value |\n"
|
|
117
|
+
f"|--------|-------|\n"
|
|
118
|
+
f"| **Monthly cost change** | **{sign}${abs(delta):,.2f} {currency} ({kind})** |\n"
|
|
119
|
+
f"| Past monthly cost | {past_str} |\n"
|
|
120
|
+
f"| Planned monthly cost | {planned_str} |\n"
|
|
121
|
+
f"| Resources Analyzed | {resource_count} |"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
total_str = f"${legacy_total:,.2f}" if legacy_total is not None else "N/A"
|
|
93
125
|
return (
|
|
94
126
|
f"### Cost Summary\n\n"
|
|
95
127
|
f"| Metric | Value |\n"
|
|
@@ -99,24 +131,89 @@ class MarkdownFormatter:
|
|
|
99
131
|
)
|
|
100
132
|
|
|
101
133
|
def _resource_table(self, resources: list[dict]) -> str:
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
134
|
+
"""Render per-resource table.
|
|
135
|
+
|
|
136
|
+
AI-CG-11: adaptive columns based on the actions present in the plan:
|
|
137
|
+
pure CREATE → Resource · Type · Region · Cost (3-col cost area)
|
|
138
|
+
pure DELETE → Resource · Type · Region · Savings (signed negative)
|
|
139
|
+
UPDATE / MIX → Resource · Type · Region · Action · Past · Planned · Δ
|
|
140
|
+
|
|
141
|
+
DELETE rows previously rendered "$0.00" because monthly_cost is the
|
|
142
|
+
future-state cost. Now they render the signed delta (the saving).
|
|
143
|
+
"""
|
|
144
|
+
actions = {
|
|
145
|
+
(r.get("extensions") or {}).get("action")
|
|
146
|
+
for r in resources
|
|
147
|
+
}
|
|
148
|
+
actions.discard(None)
|
|
149
|
+
# Default to all-columns shape when actions are mixed or absent
|
|
150
|
+
pure_create = actions == {"create"}
|
|
151
|
+
pure_delete = actions == {"delete"}
|
|
152
|
+
|
|
153
|
+
if pure_create:
|
|
154
|
+
header = "| Resource | Type | Region | Cost |"
|
|
155
|
+
divider = "|----------|------|--------|-----:|"
|
|
156
|
+
elif pure_delete:
|
|
157
|
+
header = "| Resource | Type | Region | Savings |"
|
|
158
|
+
divider = "|----------|------|--------|--------:|"
|
|
159
|
+
else:
|
|
160
|
+
header = "| Resource | Type | Region | Action | Past | Planned | Δ |"
|
|
161
|
+
divider = "|----------|------|--------|--------|-----:|--------:|---:|"
|
|
162
|
+
|
|
163
|
+
rows = [header, divider]
|
|
107
164
|
|
|
108
165
|
for r in resources:
|
|
109
166
|
name = r.get("resource_name") or r.get("resource_id", "unknown")
|
|
110
167
|
rtype = r.get("resource_type", "unknown")
|
|
111
168
|
region = r.get("region", "-")
|
|
112
|
-
|
|
113
|
-
|
|
169
|
+
ext = r.get("extensions") or {}
|
|
170
|
+
action = ext.get("action")
|
|
171
|
+
past = ext.get("past_monthly_cost")
|
|
172
|
+
planned = ext.get("planned_monthly_cost")
|
|
173
|
+
delta = ext.get("delta_monthly_cost")
|
|
174
|
+
mc = r.get("monthly_cost")
|
|
114
175
|
success = r.get("success", True)
|
|
115
176
|
status = "" if success else " (failed)"
|
|
116
177
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
178
|
+
def fmt(v):
|
|
179
|
+
return f"${v:,.2f}" if v is not None else "—"
|
|
180
|
+
def fmt_signed(v):
|
|
181
|
+
if v is None:
|
|
182
|
+
return "—"
|
|
183
|
+
return f"{'+' if v >= 0 else '-'}${abs(v):,.2f}"
|
|
184
|
+
|
|
185
|
+
if pure_create:
|
|
186
|
+
cost_str = fmt(planned if planned is not None else mc)
|
|
187
|
+
rows.append(f"| `{name}` | {rtype} | {region} | {cost_str}{status} |")
|
|
188
|
+
elif pure_delete:
|
|
189
|
+
savings_str = fmt_signed(delta) if delta is not None else fmt_signed(-(past or 0))
|
|
190
|
+
rows.append(f"| `{name}` | {rtype} | {region} | {savings_str}{status} |")
|
|
191
|
+
else:
|
|
192
|
+
action_str = (action or "-").capitalize() if action else "-"
|
|
193
|
+
past_str = "—" if action == "create" else fmt(past)
|
|
194
|
+
planned_str = "—" if action == "delete" else fmt(planned)
|
|
195
|
+
delta_str = fmt_signed(delta) if delta is not None else fmt(mc)
|
|
196
|
+
rows.append(
|
|
197
|
+
f"| `{name}` | {rtype} | {region} | {action_str} | "
|
|
198
|
+
f"{past_str} | {planned_str} | {delta_str}{status} |"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
table = "\n".join(rows)
|
|
202
|
+
count = len(resources)
|
|
203
|
+
|
|
204
|
+
# Long resource lists make PR/MR comments huge and dominate the review
|
|
205
|
+
# surface. Wrap the table in <details> when it crosses the threshold so
|
|
206
|
+
# reviewers see the summary by default and can expand on demand.
|
|
207
|
+
# GitLab and GitHub both render <details>/<summary> as native folds.
|
|
208
|
+
if count > self.RESOURCE_COLLAPSE_THRESHOLD:
|
|
209
|
+
return (
|
|
210
|
+
"### Per-Resource Costs\n\n"
|
|
211
|
+
f"<details>\n"
|
|
212
|
+
f"<summary>Show {count} resources</summary>\n\n"
|
|
213
|
+
f"{table}\n\n"
|
|
214
|
+
f"</details>"
|
|
215
|
+
)
|
|
216
|
+
return f"### Per-Resource Costs\n\n{table}"
|
|
120
217
|
|
|
121
218
|
def _budget_section(self, budget: dict) -> str:
|
|
122
219
|
budget_name = budget.get("budget_name", "Unknown")
|
|
@@ -185,8 +185,37 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
185
185
|
# ---------------------------------------------------------------------------
|
|
186
186
|
# Plan file reading
|
|
187
187
|
# ---------------------------------------------------------------------------
|
|
188
|
+
def _parse_plan_text(raw: str, source: str) -> dict:
|
|
189
|
+
"""Parse plan text as JSON, falling back to YAML for CloudFormation
|
|
190
|
+
templates which are commonly authored in YAML. `source` is used only
|
|
191
|
+
for error messages ('stdin' or a file path)."""
|
|
192
|
+
try:
|
|
193
|
+
return json.loads(raw)
|
|
194
|
+
except json.JSONDecodeError as json_exc:
|
|
195
|
+
try:
|
|
196
|
+
import yaml # PyYAML is a runtime dep — see pyproject.toml
|
|
197
|
+
except ImportError as imp_exc:
|
|
198
|
+
raise ValueError(
|
|
199
|
+
f"Invalid JSON in {source} ({json_exc}); install PyYAML to also "
|
|
200
|
+
"accept CloudFormation YAML templates."
|
|
201
|
+
) from imp_exc
|
|
202
|
+
try:
|
|
203
|
+
loaded = yaml.safe_load(raw)
|
|
204
|
+
except yaml.YAMLError as yaml_exc:
|
|
205
|
+
raise ValueError(
|
|
206
|
+
f"{source} is neither valid JSON ({json_exc}) nor valid YAML ({yaml_exc})."
|
|
207
|
+
) from yaml_exc
|
|
208
|
+
if not isinstance(loaded, dict):
|
|
209
|
+
raise ValueError(
|
|
210
|
+
f"{source} parsed as YAML to {type(loaded).__name__}, expected a dict "
|
|
211
|
+
"(top-level mapping like a CloudFormation template)."
|
|
212
|
+
)
|
|
213
|
+
return loaded
|
|
214
|
+
|
|
215
|
+
|
|
188
216
|
def read_plan_file(plan_path: str) -> dict:
|
|
189
|
-
"""Read and parse the IaC plan JSON
|
|
217
|
+
"""Read and parse the IaC plan JSON (or CloudFormation YAML) file, or
|
|
218
|
+
stdin when plan_path is '-'."""
|
|
190
219
|
if plan_path == "-":
|
|
191
220
|
try:
|
|
192
221
|
raw = sys.stdin.read()
|
|
@@ -194,10 +223,7 @@ def read_plan_file(plan_path: str) -> dict:
|
|
|
194
223
|
raise ValueError("Stdin read interrupted")
|
|
195
224
|
if not raw.strip():
|
|
196
225
|
raise ValueError("No data received on stdin (empty pipe)")
|
|
197
|
-
|
|
198
|
-
return json.loads(raw)
|
|
199
|
-
except json.JSONDecodeError as exc:
|
|
200
|
-
raise ValueError(f"Invalid JSON on stdin: {exc}") from exc
|
|
226
|
+
return _parse_plan_text(raw, source="stdin")
|
|
201
227
|
|
|
202
228
|
path = Path(plan_path)
|
|
203
229
|
if not path.exists():
|
|
@@ -205,11 +231,9 @@ def read_plan_file(plan_path: str) -> dict:
|
|
|
205
231
|
if not path.is_file():
|
|
206
232
|
raise ValueError(f"Plan path is not a file: {plan_path}")
|
|
207
233
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
except json.JSONDecodeError as exc:
|
|
212
|
-
raise ValueError(f"Invalid JSON in plan file: {exc}") from exc
|
|
234
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
235
|
+
raw = fh.read()
|
|
236
|
+
return _parse_plan_text(raw, source=str(path))
|
|
213
237
|
|
|
214
238
|
|
|
215
239
|
# ---------------------------------------------------------------------------
|
|
@@ -235,6 +259,69 @@ def detect_iac_type(plan_content: dict) -> str:
|
|
|
235
259
|
return "terraform"
|
|
236
260
|
|
|
237
261
|
|
|
262
|
+
def _changeset_missing_properties(plan_content: dict) -> bool:
|
|
263
|
+
"""Detect CloudFormation describe-change-set output that lacks resource
|
|
264
|
+
Properties — AWS strips Properties unless `describe-change-set` was
|
|
265
|
+
invoked with `--include-property-values`. CostGuard's CFN parser cannot
|
|
266
|
+
price these and would return 'Invalid CloudFormation format' or zero
|
|
267
|
+
monthly cost. Catching it locally saves an API round-trip and gives a
|
|
268
|
+
clearer error message.
|
|
269
|
+
|
|
270
|
+
Returns True only when:
|
|
271
|
+
- the plan looks like a changeset (has Changes[])
|
|
272
|
+
- at least one Add/Modify change is present
|
|
273
|
+
- NONE of those changes carry property values (AfterContext.Properties
|
|
274
|
+
or BeforeContext.Properties or Details[].Target.Name)
|
|
275
|
+
"""
|
|
276
|
+
changes = plan_content.get("Changes")
|
|
277
|
+
if not isinstance(changes, list) or not changes:
|
|
278
|
+
return False # Not a changeset shape, let the API/parser handle it.
|
|
279
|
+
|
|
280
|
+
saw_priceable_change = False
|
|
281
|
+
for change in changes:
|
|
282
|
+
if not isinstance(change, dict):
|
|
283
|
+
continue
|
|
284
|
+
rc = change.get("ResourceChange") or {}
|
|
285
|
+
action = (rc.get("Action") or "").lower()
|
|
286
|
+
if action in ("remove", "delete"):
|
|
287
|
+
continue # Deletes have no cost impact, no Properties expected.
|
|
288
|
+
saw_priceable_change = True
|
|
289
|
+
|
|
290
|
+
# Properties may be present in any of these shapes.
|
|
291
|
+
for ctx_key in ("AfterContext", "BeforeContext"):
|
|
292
|
+
ctx = rc.get(ctx_key)
|
|
293
|
+
if isinstance(ctx, str):
|
|
294
|
+
# AWS sometimes serializes as a JSON-encoded string.
|
|
295
|
+
try:
|
|
296
|
+
ctx = json.loads(ctx)
|
|
297
|
+
except (json.JSONDecodeError, TypeError):
|
|
298
|
+
ctx = None
|
|
299
|
+
if isinstance(ctx, dict) and ctx.get("Properties"):
|
|
300
|
+
return False
|
|
301
|
+
details = rc.get("Details") or []
|
|
302
|
+
if any(
|
|
303
|
+
isinstance(d, dict) and (d.get("Target") or {}).get("Name")
|
|
304
|
+
for d in details
|
|
305
|
+
):
|
|
306
|
+
return False
|
|
307
|
+
|
|
308
|
+
return saw_priceable_change
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _normalize_api_url(api_url: str) -> str:
|
|
312
|
+
"""Accept either a base URL ('https://host') or a full endpoint URL
|
|
313
|
+
('https://host/v1/costguard/analyze') so users can store whichever they
|
|
314
|
+
like in COSTGUARD_API_URL without thinking about which one this CLI
|
|
315
|
+
expects. Strips the well-known analyze suffix if present so the caller
|
|
316
|
+
can safely re-append it.
|
|
317
|
+
"""
|
|
318
|
+
base = api_url.rstrip("/")
|
|
319
|
+
suffix = "/v1/costguard/analyze"
|
|
320
|
+
if base.endswith(suffix):
|
|
321
|
+
base = base[: -len(suffix)]
|
|
322
|
+
return base
|
|
323
|
+
|
|
324
|
+
|
|
238
325
|
def call_costguard_api(
|
|
239
326
|
api_url: str,
|
|
240
327
|
api_key: str,
|
|
@@ -249,7 +336,7 @@ def call_costguard_api(
|
|
|
249
336
|
errors (429, 502, 503, 504, timeouts, connection resets).
|
|
250
337
|
Client errors (400, 401, 403, 404) fail immediately.
|
|
251
338
|
"""
|
|
252
|
-
url = f"{api_url
|
|
339
|
+
url = f"{_normalize_api_url(api_url)}/v1/costguard/analyze"
|
|
253
340
|
|
|
254
341
|
headers = {
|
|
255
342
|
"x-api-key": api_key,
|
|
@@ -424,6 +511,23 @@ def main() -> None:
|
|
|
424
511
|
# Determine IaC type
|
|
425
512
|
iac_type = args.iac_type or detect_iac_type(plan_content)
|
|
426
513
|
|
|
514
|
+
# Pre-flight: CloudFormation changesets must include resource
|
|
515
|
+
# property values; otherwise the API has nothing to price against
|
|
516
|
+
# and would return "Invalid CloudFormation format". Catching this
|
|
517
|
+
# locally avoids a wasted API call and points the user at the AWS
|
|
518
|
+
# CLI flag they actually need.
|
|
519
|
+
if iac_type == "cloudformation" and _changeset_missing_properties(plan_content):
|
|
520
|
+
print(
|
|
521
|
+
"[CostGuard] ERROR: CloudFormation changeset contains no resource "
|
|
522
|
+
"Properties.\n"
|
|
523
|
+
"[CostGuard] Hint: regenerate the changeset with --include-property-values:\n"
|
|
524
|
+
"[CostGuard] aws cloudformation describe-change-set \\\n"
|
|
525
|
+
"[CostGuard] --stack-name <stack> --change-set-name <name> \\\n"
|
|
526
|
+
"[CostGuard] --include-property-values > changeset.json",
|
|
527
|
+
file=sys.stderr,
|
|
528
|
+
)
|
|
529
|
+
sys.exit(EXIT_ERROR)
|
|
530
|
+
|
|
427
531
|
# Warn if no budget code and no skip — API will reject
|
|
428
532
|
if not budget_code and not args.skip_budget:
|
|
429
533
|
print(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: costguard-cli
|
|
3
|
-
Version: 2.0
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: CostGuard CI/CD validation CLI — shift-left cost governance for cloud infrastructure
|
|
5
5
|
Author-email: SKYXOPS <engineering@skyxops.com>
|
|
6
6
|
License-Expression: LicenseRef-Proprietary
|
|
@@ -24,6 +24,7 @@ Description-Content-Type: text/markdown
|
|
|
24
24
|
License-File: LICENSE
|
|
25
25
|
Requires-Dist: requests>=2.28.0
|
|
26
26
|
Requires-Dist: jinja2>=3.1.0
|
|
27
|
+
Requires-Dist: pyyaml>=6.0
|
|
27
28
|
Provides-Extra: dev
|
|
28
29
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
29
30
|
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
@@ -16,4 +16,6 @@ costguard_cli/formatters/__init__.py
|
|
|
16
16
|
costguard_cli/formatters/html_report.py
|
|
17
17
|
costguard_cli/formatters/json_report.py
|
|
18
18
|
costguard_cli/formatters/markdown.py
|
|
19
|
-
costguard_cli/formatters/terminal.py
|
|
19
|
+
costguard_cli/formatters/terminal.py
|
|
20
|
+
tests/test_formatters_cost_diff.py
|
|
21
|
+
tests/test_resource_table_collapse.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "costguard-cli"
|
|
7
|
-
version = "2.0
|
|
7
|
+
version = "2.2.0"
|
|
8
8
|
description = "CostGuard CI/CD validation CLI — shift-left cost governance for cloud infrastructure"
|
|
9
9
|
requires-python = ">=3.9"
|
|
10
10
|
license = "LicenseRef-Proprietary"
|
|
@@ -36,6 +36,7 @@ classifiers = [
|
|
|
36
36
|
dependencies = [
|
|
37
37
|
"requests>=2.28.0",
|
|
38
38
|
"jinja2>=3.1.0",
|
|
39
|
+
"pyyaml>=6.0",
|
|
39
40
|
]
|
|
40
41
|
|
|
41
42
|
[project.scripts]
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# =============================================================================
|
|
2
|
+
# Copyright (c) 2026 SKYXOPS Corp. All rights reserved.
|
|
3
|
+
# =============================================================================
|
|
4
|
+
"""AI-CG-11 follow-up: markdown + HTML formatters render signed cost-diff
|
|
5
|
+
when the costguard API response carries past/planned/delta fields.
|
|
6
|
+
|
|
7
|
+
Regression-guard for the original bug: DELETE resources render as "$0.00"
|
|
8
|
+
in the legacy `monthly_cost` field, hiding the savings from the reviewer.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from costguard_cli.formatters.markdown import MarkdownFormatter
|
|
14
|
+
from costguard_cli.formatters.html_report import HtmlFormatter
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def markdown():
|
|
19
|
+
return MarkdownFormatter()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.fixture
|
|
23
|
+
def html():
|
|
24
|
+
return HtmlFormatter()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _mixed_response():
|
|
28
|
+
"""Minimal response shape with all 4 actions exercised. Mirrors the
|
|
29
|
+
real costguard API: top-level `endpoint_data` (decision/violations/budget)
|
|
30
|
+
+ top-level `results` (cost numbers + resources)."""
|
|
31
|
+
return {
|
|
32
|
+
"request_id": "01J5K8R7-TEST",
|
|
33
|
+
"endpoint_data": {"decision": "ALLOW"},
|
|
34
|
+
"results": {
|
|
35
|
+
"narrative": "Test narrative.",
|
|
36
|
+
"currency": "USD",
|
|
37
|
+
"past_monthly_cost": 270.60,
|
|
38
|
+
"planned_monthly_cost": 247.90,
|
|
39
|
+
"delta_monthly_cost": -22.70,
|
|
40
|
+
"total_monthly_usd": 247.90,
|
|
41
|
+
"resources": [
|
|
42
|
+
{
|
|
43
|
+
"resource_name": "aws_instance.d1",
|
|
44
|
+
"resource_type": "aws_instance",
|
|
45
|
+
"provider": "aws",
|
|
46
|
+
"region": "us-east-1",
|
|
47
|
+
"monthly_cost": 0.0,
|
|
48
|
+
"extensions": {
|
|
49
|
+
"action": "delete",
|
|
50
|
+
"past_monthly_cost": 140.00,
|
|
51
|
+
"planned_monthly_cost": 0.00,
|
|
52
|
+
"delta_monthly_cost": -140.00,
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"resource_name": "aws_instance.u1",
|
|
57
|
+
"resource_type": "aws_instance",
|
|
58
|
+
"provider": "aws",
|
|
59
|
+
"region": "us-east-1",
|
|
60
|
+
"monthly_cost": 3.80,
|
|
61
|
+
"extensions": {
|
|
62
|
+
"action": "update",
|
|
63
|
+
"past_monthly_cost": 30.30,
|
|
64
|
+
"planned_monthly_cost": 3.80,
|
|
65
|
+
"delta_monthly_cost": -26.50,
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"resource_name": "aws_instance.c1",
|
|
70
|
+
"resource_type": "aws_instance",
|
|
71
|
+
"provider": "aws",
|
|
72
|
+
"region": "us-east-1",
|
|
73
|
+
"monthly_cost": 70.00,
|
|
74
|
+
"extensions": {
|
|
75
|
+
"action": "create",
|
|
76
|
+
"past_monthly_cost": 0.00,
|
|
77
|
+
"planned_monthly_cost": 70.00,
|
|
78
|
+
"delta_monthly_cost": +70.00,
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _pure_delete_response():
|
|
87
|
+
return {
|
|
88
|
+
"request_id": "01J5K8R7-DEL",
|
|
89
|
+
"endpoint_data": {"decision": "ALLOW"},
|
|
90
|
+
"results": {
|
|
91
|
+
"narrative": "Decommission.",
|
|
92
|
+
"currency": "USD",
|
|
93
|
+
"past_monthly_cost": 430.50,
|
|
94
|
+
"planned_monthly_cost": 0.00,
|
|
95
|
+
"delta_monthly_cost": -430.50,
|
|
96
|
+
"total_monthly_usd": 0.00,
|
|
97
|
+
"resources": [
|
|
98
|
+
{
|
|
99
|
+
"resource_name": "aws_db_instance.legacy",
|
|
100
|
+
"resource_type": "aws_db_instance",
|
|
101
|
+
"provider": "aws", "region": "us-east-1",
|
|
102
|
+
"monthly_cost": 0.0,
|
|
103
|
+
"extensions": {
|
|
104
|
+
"action": "delete",
|
|
105
|
+
"past_monthly_cost": 290.20,
|
|
106
|
+
"planned_monthly_cost": 0.00,
|
|
107
|
+
"delta_monthly_cost": -290.20,
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ----- Markdown -----
|
|
116
|
+
|
|
117
|
+
def test_markdown_mixed_shows_signed_delta_headline(markdown):
|
|
118
|
+
out = markdown.format(_mixed_response())
|
|
119
|
+
assert "Monthly cost change" in out
|
|
120
|
+
assert "-$22.70" in out
|
|
121
|
+
assert "(savings)" in out
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_markdown_delete_row_shows_negative_savings_not_zero(markdown):
|
|
125
|
+
out = markdown.format(_mixed_response())
|
|
126
|
+
# The DELETE row's Δ column must show -$140.00, NOT $0.00
|
|
127
|
+
assert "-$140.00" in out
|
|
128
|
+
# Sanity: the DELETE row's Δ column contains the signed savings
|
|
129
|
+
# (note: aws_instance.d1 also appears in the hourly-cost collapsible,
|
|
130
|
+
# so just verify the resource is rendered at all).
|
|
131
|
+
assert "aws_instance.d1" in out
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_markdown_pure_delete_uses_savings_column(markdown):
|
|
135
|
+
out = markdown.format(_pure_delete_response())
|
|
136
|
+
assert "Savings" in out
|
|
137
|
+
assert "-$290.20" in out
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ----- HTML -----
|
|
141
|
+
|
|
142
|
+
def test_html_mixed_shows_signed_delta_headline(html):
|
|
143
|
+
out = html.format(_mixed_response())
|
|
144
|
+
assert "-$22.70" in out
|
|
145
|
+
assert "(savings)" in out
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_html_delete_row_shows_signed_savings(html):
|
|
149
|
+
out = html.format(_mixed_response())
|
|
150
|
+
assert "-$140.00" in out
|
|
151
|
+
assert "aws_instance.d1" in out
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_html_pure_delete_headline_shows_signed_delta(html):
|
|
155
|
+
out = html.format(_pure_delete_response())
|
|
156
|
+
assert "-$430.50" in out
|
|
157
|
+
assert "aws_db_instance.legacy" in out
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""AI-XMOD-202: collapse per-resource cost table when > RESOURCE_COLLAPSE_THRESHOLD."""
|
|
2
|
+
|
|
3
|
+
from costguard_cli.formatters.markdown import MarkdownFormatter
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _response(n_resources: int) -> dict:
|
|
7
|
+
return {
|
|
8
|
+
"endpoint_data": {"decision": "ALLOW", "budget": {}, "violations": []},
|
|
9
|
+
"results": {
|
|
10
|
+
"total_monthly_usd": n_resources * 10,
|
|
11
|
+
"resources": [
|
|
12
|
+
{
|
|
13
|
+
"resource_name": f"aws_instance.r{i}",
|
|
14
|
+
"resource_type": "aws_instance",
|
|
15
|
+
"region": "us-east-1",
|
|
16
|
+
"monthly_cost": 10.0,
|
|
17
|
+
"extensions": {"action": "create", "planned_monthly_cost": 10.0},
|
|
18
|
+
}
|
|
19
|
+
for i in range(n_resources)
|
|
20
|
+
],
|
|
21
|
+
},
|
|
22
|
+
"request_id": "test",
|
|
23
|
+
"timestamp": "2026-05-24T00:00:00",
|
|
24
|
+
"processing_time_ms": 1,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_short_table_renders_flat():
|
|
29
|
+
out = MarkdownFormatter().format(_response(5))
|
|
30
|
+
assert "### Per-Resource Costs" in out
|
|
31
|
+
assert "<summary>Show 5 resources" not in out
|
|
32
|
+
assert "aws_instance.r0" in out
|
|
33
|
+
assert "aws_instance.r4" in out
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_long_table_wrapped_in_details():
|
|
37
|
+
out = MarkdownFormatter().format(_response(15))
|
|
38
|
+
assert "### Per-Resource Costs" in out
|
|
39
|
+
assert "<details>" in out
|
|
40
|
+
assert "<summary>Show 15 resources</summary>" in out
|
|
41
|
+
assert "</details>" in out
|
|
42
|
+
assert "aws_instance.r0" in out
|
|
43
|
+
assert "aws_instance.r14" in out
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_threshold_boundary_exact_match_not_collapsed():
|
|
47
|
+
# At exactly the threshold, stay flat (strictly greater than collapses).
|
|
48
|
+
out = MarkdownFormatter().format(_response(MarkdownFormatter.RESOURCE_COLLAPSE_THRESHOLD))
|
|
49
|
+
assert "<summary>Show" not in out
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_threshold_boundary_one_over_collapses():
|
|
53
|
+
out = MarkdownFormatter().format(_response(MarkdownFormatter.RESOURCE_COLLAPSE_THRESHOLD + 1))
|
|
54
|
+
assert "<summary>Show" in out
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|