feat(scripts): cost_report.py — monthly NocoDB/S3/Ollama rollup (REQ-INB-54)
---ci--- project: inboxos phase: 1 milestone: v1.4 status: execute wave: 1 task: T6 ---/ci---
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
"""Offline tests for scripts/cost_report.py — REQ-INB-54.
|
||||
|
||||
Covers 2 cases:
|
||||
1. NocoDB unreachable emits a row with nocodb.status='unreachable' and continues
|
||||
2. Full happy path emits N rows where N = days in requested month
|
||||
"""
|
||||
import calendar
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "scripts"))
|
||||
import cost_report
|
||||
|
||||
|
||||
class TestCostReport(unittest.TestCase):
|
||||
def test_nocodb_unreachable_continues(self):
|
||||
with mock.patch.object(cost_report, "nocodb_row_counts", return_value={
|
||||
"status": "unreachable", "reason": "test"
|
||||
}):
|
||||
with mock.patch.object(cost_report, "s3_archive_size_gb", return_value=-1.0):
|
||||
with mock.patch.object(cost_report, "ollama_token_count", return_value=0):
|
||||
rows = cost_report.report("2026-02")
|
||||
self.assertGreater(len(rows), 0)
|
||||
self.assertEqual(rows[0]["nocodb"]["status"], "unreachable")
|
||||
|
||||
def test_happy_path_emits_rows_per_day(self):
|
||||
with mock.patch.object(cost_report, "nocodb_row_counts", return_value={
|
||||
"status": "ok", "finance": 10, "crm": 5, "intel": 3, "promotions": 7
|
||||
}):
|
||||
with mock.patch.object(cost_report, "s3_archive_size_gb", return_value=1.5):
|
||||
with mock.patch.object(cost_report, "ollama_token_count", return_value=1000):
|
||||
rows = cost_report.report("2026-02")
|
||||
_, days = calendar.monthrange(2026, 2)
|
||||
self.assertEqual(len(rows), days)
|
||||
self.assertEqual(rows[0]["s3_archive_gb"], 1.5)
|
||||
self.assertEqual(rows[0]["ollama_tokens"], 1000)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Executable
+121
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Cost report: monthly NocoDB / S3 / Ollama rollup.
|
||||
|
||||
v1.4 REQ-INB-54.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import calendar
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
NOCODB_BASE_URL = os.environ.get("NOCODB_BASE_URL", "http://localhost:8080")
|
||||
NOCODB_API_TOKEN = os.environ.get("NOCODB_API_TOKEN", "")
|
||||
SEAWEEDFS_S3_BUCKET = os.environ.get("SEAWEEDFS_S3_BUCKET", "inboxos")
|
||||
|
||||
|
||||
def _nocodb_headers() -> dict[str, str]:
|
||||
return {"xc-token": NOCODB_API_TOKEN, "Content-Type": "application/json"}
|
||||
|
||||
|
||||
def nocodb_row_counts() -> dict[str, int]:
|
||||
if not NOCODB_API_TOKEN:
|
||||
return {"status": "unreachable", "reason": "NOCODB_API_TOKEN unset"}
|
||||
counts: dict[str, int] = {"status": "ok"}
|
||||
for module in ("finance", "crm", "intel", "promotions"):
|
||||
table_id = os.environ.get(f"NOCODB_TABLE_{module.upper()}", "")
|
||||
if not table_id:
|
||||
counts[module] = 0
|
||||
continue
|
||||
try:
|
||||
r = requests.get(
|
||||
f"{NOCODB_BASE_URL}/api/v2/tables/{table_id}/records",
|
||||
headers=_nocodb_headers(),
|
||||
params={"limit": 1, "where": "(Id,gt,0)"},
|
||||
timeout=10,
|
||||
)
|
||||
r.raise_for_status()
|
||||
counts[module] = r.json().get("pageInfo", {}).get("totalRows", 0)
|
||||
except requests.RequestException as exc:
|
||||
counts["status"] = "unreachable"
|
||||
counts[f"{module}_error"] = str(exc)
|
||||
counts[module] = 0
|
||||
return counts
|
||||
|
||||
|
||||
def s3_archive_size_gb() -> float:
|
||||
try:
|
||||
import boto3
|
||||
s3 = boto3.client("s3")
|
||||
resp = s3.list_objects_v2(Bucket=SEAWEEDFS_S3_BUCKET)
|
||||
total = sum(obj["Size"] for obj in resp.get("Contents", []))
|
||||
return round(total / (1024 ** 3), 3)
|
||||
except Exception as exc:
|
||||
return -1.0
|
||||
|
||||
|
||||
def ollama_token_count(month: str) -> int:
|
||||
metrics_path = os.path.join("pipelines", "_metrics", f"tokens-{month}.jsonl")
|
||||
if not os.path.exists(metrics_path):
|
||||
return 0
|
||||
total = 0
|
||||
with open(metrics_path) as f:
|
||||
for line in f:
|
||||
try:
|
||||
total += json.loads(line).get("total_tokens", 0)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return total
|
||||
|
||||
|
||||
def report(month: str) -> list[dict]:
|
||||
year, mo = map(int, month.split("-"))
|
||||
_, days_in_month = calendar.monthrange(year, mo)
|
||||
nocodb = nocodb_row_counts()
|
||||
s3_gb = s3_archive_size_gb()
|
||||
tokens = ollama_token_count(month)
|
||||
rows = []
|
||||
for day in range(1, days_in_month + 1):
|
||||
rows.append({
|
||||
"date": f"{month}-{day:02d}",
|
||||
"nocodb": nocodb,
|
||||
"s3_archive_gb": s3_gb,
|
||||
"ollama_tokens": tokens,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser(description="Monthly NocoDB/S3/Ollama cost report")
|
||||
p.add_argument("--month", help="YYYY-MM (default: current month)")
|
||||
p.add_argument("--format", choices=["json", "csv"], default="json")
|
||||
args = p.parse_args()
|
||||
|
||||
if not args.month:
|
||||
args.month = datetime.now(timezone.utc).strftime("%Y-%m")
|
||||
|
||||
rows = report(args.month)
|
||||
|
||||
if args.format == "json":
|
||||
print(json.dumps(rows, indent=2))
|
||||
else:
|
||||
print("date,nocodb_status,finance,crm,intel,promotions,s3_archive_gb,ollama_tokens")
|
||||
for r in rows:
|
||||
n = r["nocodb"]
|
||||
print(
|
||||
f"{r['date']},{n.get('status', '?')},"
|
||||
f"{n.get('finance', 0)},{n.get('crm', 0)},"
|
||||
f"{n.get('intel', 0)},{n.get('promotions', 0)},"
|
||||
f"{r['s3_archive_gb']},{r['ollama_tokens']}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user