f8616b806e
P1 (Wave 1, feat) — REQ-187, REQ-188, REQ-205 (emitter), REQ-206 (emitter) New components: - core/metrics/event_envelope.py — CloudEvents 1.0 envelope + platform.* conventions - core/metrics/run_manifest.py — per-run manifest writer (nova.run.started/completed/failed) - core/metrics/decision_ledger.py — SQLite append-only hash-chain (ai.decision.made + attestation.recorded) - core/metrics/infracost_adapter.py — Infracost post-processor (degraded mode when CLI absent, A6) - schemas/metrics_event.schema.json — CloudEvents envelope schema - schemas/metrics_run_manifest.schema.json — per-run manifest schema - metrics/README.md — backup/restore doc (REQ-201) - tests/test_metrics_emitters.py — 16 tests (all pass) Modified components: - core/confidence_signal.py — emits nova.confidence.computed + nova.ai.decision.made (D-122) - core/hitl_gates.py — emits nova.attestation.recorded on qa/prod/dr gates (D-132) - adapters/terraform/policy/checkov_adapter.py — emits nova.policy.evaluated - pyproject.toml — addopts gains --junitxml + --json-report + --cov (REQ-206) - .gitignore — metrics runtime artifacts ignored D-120: Nova-native (JSONL + SQLite, no Kafka/OTel) D-121: Decision Ledger = outbox_writer extension → SQLite hash-chain D-122: AI decision = confidence_signal + HITL gate (not LLM) D-128: metrics/ at repo root D-132: Attestation instrumentation ---ci--- project: acdl phase: 1 milestone: v1.17 status: execute ---/ci---
73 lines
2.8 KiB
Python
73 lines
2.8 KiB
Python
"""Nova Infracost Post-Processor (REQ-187, D-120).
|
|
|
|
Runs Infracost on `terraform show -json plan.tfplan` (offline, reads plan
|
|
JSON, no live AWS). Emits nova.cost.estimated{delta_usd} events. Degrades
|
|
gracefully (omits the event, logs a warning) when Infracost CLI is absent
|
|
(assumption A6).
|
|
|
|
run_platform.sh invokes it after the plan stage.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
|
from core.metrics.event_envelope import emit
|
|
|
|
|
|
def _is_infracost_available():
|
|
"""Check if the Infracost CLI is on PATH."""
|
|
return shutil.which("infracost") is not None
|
|
|
|
|
|
def estimate(plan_json_path, run_id, contract_id, environment):
|
|
"""Run Infracost on a terraform plan JSON. Returns the cost estimate dict.
|
|
|
|
Args:
|
|
plan_json_path: path to `terraform show -json plan.tfplan` output
|
|
run_id: the run identifier
|
|
contract_id: the contract UUID
|
|
environment: dev|qa|prod|dr
|
|
|
|
Returns:
|
|
{"delta_usd": float, "total_monthly_usd": float, "available": bool}
|
|
or {"available": False} if Infracost is not installed.
|
|
"""
|
|
if not _is_infracost_available():
|
|
sys.stderr.write("[infracost] CLI not found — cost.estimated event omitted (A6 degraded mode)\n")
|
|
return {"available": False, "delta_usd": 0.0, "total_monthly_usd": 0.0}
|
|
|
|
if not os.path.isfile(plan_json_path):
|
|
sys.stderr.write(f"[infracost] plan JSON not found: {plan_json_path}\n")
|
|
return {"available": False, "delta_usd": 0.0, "total_monthly_usd": 0.0}
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
["infracost", "breakdown", "--path", plan_json_path, "--format", "json"],
|
|
capture_output=True, text=True, timeout=30,
|
|
)
|
|
if result.returncode != 0:
|
|
sys.stderr.write(f"[infracost] CLI failed: {result.stderr[:200]}\n")
|
|
return {"available": False, "delta_usd": 0.0, "total_monthly_usd": 0.0}
|
|
|
|
breakdown = json.loads(result.stdout)
|
|
delta = float(breakdown.get("diffTotalMonthlyCost", 0.0))
|
|
total = float(breakdown.get("totalMonthlyCost", 0.0))
|
|
estimate_data = {"available": True, "delta_usd": delta, "total_monthly_usd": total}
|
|
|
|
emit("nova.cost.estimated", run_id, environment, estimate_data, contract_id=contract_id)
|
|
return estimate_data
|
|
except Exception as exc:
|
|
sys.stderr.write(f"[infracost] error: {exc}\n")
|
|
return {"available": False, "delta_usd": 0.0, "total_monthly_usd": 0.0}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) < 5:
|
|
print("usage: infracost_adapter.py <plan_json_path> <run_id> <contract_id> <environment>", file=sys.stderr)
|
|
sys.exit(2)
|
|
est = estimate(sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4])
|
|
print(json.dumps(est, indent=2)) |