"""Nova PowerBI Export (REQ-190, P3). Emits CSV/JSON views to metrics/powerbi/ from the SQLite cold store. Fact + dimension tables + 8 empty placeholder views for deferred metrics (with documented schemas ready to fill when their blocking decisions lift). D-120: Nova-native (CSV/JSON files, no live connector) D-129: PowerBI ingests via the folder connector D-128: metrics/ at repo root """ import csv import datetime import json import os import sqlite3 import sys _METRICS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "metrics") _STORE_PATH = os.path.join(_METRICS_DIR, "nova_metrics.db") _EXPORT_DIR = os.path.join(_METRICS_DIR, "powerbi") FACT_VIEWS = [ "fact_run", "fact_capability", "fact_policy_check", "fact_confidence", "fact_test", "fact_decision", "fact_cost_estimate", "fact_lifecycle", ] DIM_VIEWS = [ "dim_capability", "dim_milestone", ] PLACEHOLDER_VIEWS = { "placeholder_live_infra_health": { "columns": ["timestamp", "resource_id", "resource_type", "running_count", "healthy", "downtime_seconds"], "blocking_decision": "D-096", "description": "Live infrastructure health (ECS running count, ALB 5xx, RPS). Blocked: live AWS torn down.", }, "placeholder_live_outbox_rate": { "columns": ["timestamp", "contract_id", "write_latency_ms", "append_count"], "blocking_decision": "D-096", "description": "Live outbox write rate / ledger append latency. Blocked: DynamoDB outbox table absent.", }, "placeholder_tamper_evident_checkpoints": { "columns": ["timestamp", "checkpoint_id", "jws_signed", "object_lock_enabled"], "blocking_decision": "D-083", "description": "Tamper-evident ledger checkpoints / JWS signature rate. Blocked: S3 Object Lock + JWS deferred.", }, "placeholder_onboarding_funnel": { "columns": ["timestamp", "consumer_repo", "requested_environment", "status", "granted_at"], "blocking_decision": "D-113/D-114/D-119", "description": "Onboarding funnel: requested → granted conversion. Blocked: no auto-grant event.", }, "placeholder_drift_detection": { "columns": ["timestamp", "workspace_id", "drift_count", "auto_reverted", "detection_cycle"], "blocking_decision": "D-096 + no scheduler", "description": "Drift detection (scheduled terraform plan -detailed-exitcode). Blocked: live AWS + scheduler.", }, "placeholder_live_cur_reconciliation": { "columns": ["timestamp", "resource_address", "actual_usd", "baseline_usd", "saved_usd"], "blocking_decision": "D-096", "description": "Live cost CUR reconciliation. Blocked: live AWS billing. Infracost pre-apply estimates are in fact_cost_estimate.", }, "placeholder_sla_downtime": { "columns": ["timestamp", "service", "uptime_pct", "downtime_minutes", "slo_target"], "blocking_decision": "D-096", "description": "SLA / unplanned downtime. Blocked: needs live service uptime monitoring.", }, "placeholder_predictive_reactive": { "columns": ["timestamp", "action_id", "label", "trigger", "count"], "blocking_decision": "future emitter", "description": "Predictive vs Reactive ratio. Blocked: requires ML anomaly-forecasting service.", }, } def _iso8601_now(): return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") def _export_table_csv(conn, table_name, export_dir): """Export a SQLite table to a CSV file.""" rows = conn.execute(f"SELECT * FROM {table_name}").fetchall() if not rows: return 0 columns = [desc[0] for desc in conn.execute(f"SELECT * FROM {table_name} LIMIT 0").description] csv_path = os.path.join(export_dir, f"{table_name}.csv") with open(csv_path, "w", newline="", encoding="utf-8") as f: writer = csv.writer(f) writer.writerow(columns) writer.writerows(rows) return len(rows) def _export_table_json(conn, table_name, export_dir): """Export a SQLite table to a JSON file.""" rows = conn.execute(f"SELECT * FROM {table_name}").fetchall() if not rows: return 0 columns = [desc[0] for desc in conn.execute(f"SELECT * FROM {table_name} LIMIT 0").description] records = [dict(zip(columns, row)) for row in rows] json_path = os.path.join(export_dir, f"{table_name}.json") with open(json_path, "w", encoding="utf-8") as f: json.dump(records, f, indent=2, default=str) return len(rows) def _export_placeholder_csv(view_name, schema, export_dir): """Export a placeholder CSV with headers only (no data rows).""" csv_path = os.path.join(export_dir, f"{view_name}.csv") with open(csv_path, "w", newline="", encoding="utf-8") as f: writer = csv.writer(f) writer.writerow(schema["columns"]) return 0 def _export_placeholder_json(view_name, schema, export_dir): """Export a placeholder JSON with schema metadata (no data rows).""" json_path = os.path.join(export_dir, f"{view_name}.json") with open(json_path, "w", encoding="utf-8") as f: json.dump({"schema": schema, "data": []}, f, indent=2) return 0 def export_all(store_path=None, export_dir=None, fmt="both"): """Export all fact/dim tables + placeholder views to CSV and/or JSON. Args: store_path: path to the SQLite cold store export_dir: directory for exported files fmt: "csv", "json", or "both" Returns: Summary dict with export counts. """ if store_path is None: store_path = _STORE_PATH if export_dir is None: export_dir = _EXPORT_DIR os.makedirs(export_dir, exist_ok=True) summary = {"exported_at": _iso8601_now(), "fact_tables": {}, "dim_tables": {}, "placeholder_views": {}} if not os.path.isfile(store_path): summary["error"] = f"SQLite store not found: {store_path}" for view_name, schema in PLACEHOLDER_VIEWS.items(): if fmt in ("csv", "both"): _export_placeholder_csv(view_name, schema, export_dir) if fmt in ("json", "both"): _export_placeholder_json(view_name, schema, export_dir) summary["placeholder_views"][view_name] = 0 return summary conn = sqlite3.connect(store_path) for table in FACT_VIEWS: count = 0 try: if fmt in ("csv", "both"): count = _export_table_csv(conn, table, export_dir) if fmt in ("json", "both"): count = _export_table_json(conn, table, export_dir) except sqlite3.OperationalError: count = 0 summary["fact_tables"][table] = count for table in DIM_VIEWS: count = 0 try: if fmt in ("csv", "both"): count = _export_table_csv(conn, table, export_dir) if fmt in ("json", "both"): count = _export_table_json(conn, table, export_dir) except sqlite3.OperationalError: count = 0 summary["dim_tables"][table] = count conn.close() for view_name, schema in PLACEHOLDER_VIEWS.items(): if fmt in ("csv", "both"): _export_placeholder_csv(view_name, schema, export_dir) if fmt in ("json", "both"): _export_placeholder_json(view_name, schema, export_dir) summary["placeholder_views"][view_name] = 0 return summary if __name__ == "__main__": result = export_all() print(json.dumps(result, indent=2))