166 lines
6.2 KiB
Python
166 lines
6.2 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
collect.py — Point d'entrée du cron. Collecte le coût de la veille (J-1)
|
|
pour chaque tenant défini dans config.json, et l'écrit dans SQLite.
|
|
|
|
Pourquoi J-1 et pas "aujourd'hui" : les données de facturation AWS/Azure du
|
|
jour même sont généralement incomplètes (délai de consolidation de
|
|
quelques heures à ~24-48h côté AWS Cost Explorer notamment). Collecter J-1
|
|
donne un chiffre stable.
|
|
|
|
Utilisation (cron quotidien, par exemple à 6h du matin) :
|
|
python collect.py --config /path/to/config.json --db /path/to/cost_dashboard.db
|
|
|
|
Un échec sur un tenant n'empêche pas la collecte des autres : chaque
|
|
tenant est traité indépendamment et les erreurs sont journalisées en base
|
|
(status='error') pour que le dashboard puisse l'afficher plutôt que de
|
|
silencieusement afficher un trou ou un zéro.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import logging
|
|
import sys
|
|
from datetime import date, datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
|
|
from db import (
|
|
get_connection, init_db, upsert_collection,
|
|
upsert_resource_costs, set_resource_collection_status,
|
|
)
|
|
from providers import (
|
|
get_aws_cost, get_azure_cost, convert_result,
|
|
get_aws_resource_costs, get_azure_resource_costs, convert_resource_costs,
|
|
ResourceLevelUnavailable,
|
|
)
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
)
|
|
log = logging.getLogger("collect")
|
|
|
|
TARGET_CURRENCY = "EUR"
|
|
|
|
# Fenêtre du détail par ressource (bucket S3, instance EC2...) : bornée à 14
|
|
# jours côté AWS Cost Explorer (limite dure de l'API) ; gardée identique côté
|
|
# Azure pour rester cohérent entre providers. Voir providers.py.
|
|
RESOURCE_WINDOW_DAYS = 14
|
|
|
|
|
|
def collect_tenant(conn, provider: str, entry: dict, period_start: str, period_end: str) -> bool:
|
|
"""Collecte le relevé quotidien (par service) d'un tenant. Retourne True
|
|
si la collecte a réussi — sert à décider si ça vaut la peine de tenter
|
|
le détail par ressource juste après (inutile si l'auth du tenant est
|
|
déjà cassée)."""
|
|
name = entry["name"]
|
|
collected_at = datetime.now(timezone.utc).isoformat()
|
|
|
|
try:
|
|
if provider == "aws":
|
|
data = get_aws_cost(entry, period_start, period_end, granularity="DAILY")
|
|
else:
|
|
data = get_azure_cost(
|
|
entry["tenant_id"], entry["client_id"], entry["client_secret"],
|
|
entry["subscription_id"], period_start, period_end,
|
|
)
|
|
data = convert_result(data, TARGET_CURRENCY)
|
|
|
|
upsert_collection(
|
|
conn, provider, name, "daily", period_start, period_end,
|
|
collected_at, "ok", data=data,
|
|
)
|
|
log.info("OK %-6s %-30s %.2f %s", provider, name, data["total"], data["currency"])
|
|
return True
|
|
|
|
except Exception as e:
|
|
upsert_collection(
|
|
conn, provider, name, "daily", period_start, period_end,
|
|
collected_at, "error", error_message=str(e),
|
|
)
|
|
log.error("ECHEC %-6s %-30s %s", provider, name, e)
|
|
return False
|
|
|
|
|
|
def collect_tenant_resources(conn, provider: str, entry: dict, period_start: str, period_end: str) -> None:
|
|
"""Collecte le détail par ressource (RESOURCE_WINDOW_DAYS derniers
|
|
jours) d'un tenant, pour le panneau de détail du dashboard. Séparé de
|
|
collect_tenant : ça peut échouer pour une raison indépendante (ex:
|
|
"Resource IDs" non activé côté AWS) sans que la collecte quotidienne
|
|
normale soit affectée."""
|
|
name = entry["name"]
|
|
collected_at = datetime.now(timezone.utc).isoformat()
|
|
|
|
try:
|
|
if provider == "aws":
|
|
data = get_aws_resource_costs(entry, period_start, period_end)
|
|
else:
|
|
data = get_azure_resource_costs(
|
|
entry["tenant_id"], entry["client_id"], entry["client_secret"],
|
|
entry["subscription_id"], period_start, period_end,
|
|
)
|
|
data = convert_resource_costs(data, TARGET_CURRENCY)
|
|
|
|
upsert_resource_costs(conn, provider, name, period_start, period_end, data["services"])
|
|
set_resource_collection_status(conn, provider, name, "ok", collected_at)
|
|
n_resources = sum(len(v) for v in data["services"].values())
|
|
log.info("OK %-6s %-30s détail ressources (%d ressources)", provider, name, n_resources)
|
|
|
|
except ResourceLevelUnavailable as e:
|
|
set_resource_collection_status(conn, provider, name, "unavailable", collected_at, message=str(e))
|
|
log.warning("INDISPO %-6s %-30s détail ressources indisponible : %s", provider, name, e)
|
|
|
|
except Exception as e:
|
|
set_resource_collection_status(conn, provider, name, "error", collected_at, message=str(e))
|
|
log.error("ECHEC %-6s %-30s détail ressources : %s", provider, name, e)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Collecte journalière des coûts (cron)")
|
|
parser.add_argument("--config", default="config.json")
|
|
parser.add_argument("--db", default=None, help="Chemin vers la base SQLite")
|
|
parser.add_argument("--date", help="Jour à collecter (YYYY-MM-DD), défaut: hier")
|
|
args = parser.parse_args()
|
|
|
|
if args.date:
|
|
target_day = date.fromisoformat(args.date)
|
|
else:
|
|
target_day = date.today() - timedelta(days=1)
|
|
|
|
period_start = target_day.isoformat()
|
|
period_end = (target_day + timedelta(days=1)).isoformat()
|
|
|
|
db_path = args.db or None
|
|
if db_path:
|
|
init_db(db_path)
|
|
conn = get_connection(db_path)
|
|
else:
|
|
init_db()
|
|
conn = get_connection()
|
|
|
|
with open(args.config) as f:
|
|
config = json.load(f)
|
|
|
|
log.info("Collecte pour le %s", period_start)
|
|
|
|
resource_period_end = target_day + timedelta(days=1)
|
|
resource_period_start = resource_period_end - timedelta(days=RESOURCE_WINDOW_DAYS)
|
|
|
|
for provider, entries in (("aws", config.get("aws", [])), ("azure", config.get("azure", []))):
|
|
for entry in entries:
|
|
ok = collect_tenant(conn, provider, entry, period_start, period_end)
|
|
if ok:
|
|
collect_tenant_resources(
|
|
conn, provider, entry,
|
|
resource_period_start.isoformat(), resource_period_end.isoformat(),
|
|
)
|
|
|
|
conn.close()
|
|
log.info("Terminé.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|