#!/usr/bin/env python3 """ backfill_historical.py — Rapatrie l'historique de coûts disponible directement depuis AWS Cost Explorer / Azure Cost Management vers la base, mois par mois, sans passer par un export JSON intermédiaire (contrairement à import_historical.py, qui importe des fichiers déjà exportés). À lancer une fois à la mise en place du dashboard (ou pour rattraper l'historique d'un tenant nouvellement ajouté). En plus de l'historique mensuel, rapatrie aussi les `--daily-days` derniers jours en granularité journalière ('daily') — sans ça, les graphes 7j/30j de la page tenant restent vides jusqu'à ce que le cron ait tourné assez de jours de suite pour les remplir. Réutilise telles quelles collect_tenant et collect_tenant_resources (collect.py) : mêmes fonctions que celles que le cron quotidien appelle déjà, juste rejouées sur les jours passés au lieu d'attendre qu'ils s'accumulent. Limites : - AWS Cost Explorer ne conserve que 12 mois d'historique glissants — au-delà, l'API ne renvoie simplement rien (pas une erreur). --months au-delà de 12 ne rapatriera donc rien de plus côté AWS. - Azure dépend du type de contrat (souvent plus long que 12 mois) — la limite AWS ne s'applique pas. - Chaque mois ou jour interrogé est un appel Cost Explorer / Cost Management facturé (~0,01 $ par requête AWS) : pour N tenants, M mois et D jours, prévoir environ N x (M + D) requêtes. Pour 28 tenants AWS sur 12 mois + 30 jours, ça fait ~336 + ~840 requêtes (~12 $ au total), payé une fois — et ça prend plusieurs minutes (les appels sont séquentiels, un par un). - Les mois/jours sans coût (compte pas encore créé, par exemple) sont ignorés plutôt que d'écrire une ligne à 0. Stocké avec granularity='monthly' (historique) et 'daily' (30 derniers jours) — la collecte quotidienne du cron écrase ensuite les jours récents au fil de l'eau, sans créer de doublons (upsert sur la même clé). Utilisation : python backfill_historical.py --config ../config.json --db ../cost_dashboard.db python backfill_historical.py --config ../config.json --db ../cost_dashboard.db --months 6 --daily-days 14 python backfill_historical.py --config ../config.json --db ../cost_dashboard.db --daily-days 0 # historique seul python backfill_historical.py --config ../config.json --db ../cost_dashboard.db --tenant analytics-platform """ import argparse import json import logging import sys import time 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 from providers import get_aws_cost, get_azure_cost, convert_result from collect import collect_tenant, collect_tenant_resources, RESOURCE_WINDOW_DAYS logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", ) log = logging.getLogger("backfill") TARGET_CURRENCY = "EUR" # Pause volontaire entre deux appels Cost Management, uniquement côté Azure : # son quota est plus bas et souvent partagé au niveau du tenant Azure AD # entier (contrairement à Cost Explorer côté AWS, déjà retenté automatiquement # par botocore). Mieux vaut ralentir nous-mêmes que de déclencher des 429 en # rafale sur des dizaines d'appels consécutifs — le retry dans providers.py # absorbe les 429 occasionnels, mais pas un rythme d'appels trop soutenu. AZURE_CALL_DELAY_SECONDS = 2.0 def month_ranges(n_months: int) -> list[tuple[date, date]]: """Les n derniers mois (le plus ancien en premier), en (start, end) avec end exclusif — mois courant (en cours) inclus.""" today = date.today() y, m = today.year, today.month months = [] for _ in range(n_months): start = date(y, m, 1) end_y, end_m = (y + 1, 1) if m == 12 else (y, m + 1) end = date(end_y, end_m, 1) months.append((start, end)) m -= 1 if m == 0: m, y = 12, y - 1 return list(reversed(months)) def backfill_tenant(conn, provider: str, entry: dict, months: list[tuple[date, date]]) -> None: name = entry["name"] for start, end in months: collected_at = datetime.now(timezone.utc).isoformat() month_label = start.isoformat()[:7] try: if provider == "aws": data = get_aws_cost(entry, start.isoformat(), end.isoformat(), granularity="MONTHLY") else: data = get_azure_cost( entry["tenant_id"], entry["client_id"], entry["client_secret"], entry["subscription_id"], start.isoformat(), end.isoformat(), ) if data["total"] == 0 and not data.get("by_service"): log.info("VIDE %-6s %-30s %s (aucune donnée)", provider, name, month_label) continue data = convert_result(data, TARGET_CURRENCY) upsert_collection( conn, provider, name, "monthly", start.isoformat(), end.isoformat(), collected_at, "ok", data=data, ) log.info("OK %-6s %-30s %s %.2f %s", provider, name, month_label, data["total"], data["currency"]) except Exception as e: log.error("ECHEC %-6s %-30s %s : %s", provider, name, month_label, e) finally: if provider == "azure": time.sleep(AZURE_CALL_DELAY_SECONDS) def day_ranges(n_days: int) -> list[date]: """Les n derniers jours (le plus ancien en premier), jusqu'à hier inclus — même convention que collect.py : le jour même est rarement finalisé côté AWS/Azure, donc jamais backfillé.""" yesterday = date.today() - timedelta(days=1) return [yesterday - timedelta(days=i) for i in range(n_days - 1, -1, -1)] def backfill_tenant_daily(conn, provider: str, entry: dict, days: list[date]) -> None: """Rejoue collect_tenant (collect.py) sur chaque jour de `days`, puis collect_tenant_resources une fois si au moins un jour a réussi — exactement ce que ferait le cron quotidien, jour après jour, mais en une passe.""" if not days: return any_ok = False for day in days: ok = collect_tenant(conn, provider, entry, day.isoformat(), (day + timedelta(days=1)).isoformat()) any_ok = any_ok or ok if provider == "azure": time.sleep(AZURE_CALL_DELAY_SECONDS) if any_ok: resource_end = days[-1] + timedelta(days=1) resource_start = resource_end - timedelta(days=RESOURCE_WINDOW_DAYS) collect_tenant_resources(conn, provider, entry, resource_start.isoformat(), resource_end.isoformat()) def main(): parser = argparse.ArgumentParser(description="Backfill de l'historique de coûts depuis AWS/Azure") parser.add_argument("--config", default="config.json") parser.add_argument("--db", default=None, help="Chemin vers la base SQLite") parser.add_argument("--months", type=int, default=12, help="Nombre de mois à rapatrier, mois courant inclus (défaut: 12, plafond utile côté AWS ; " "0 pour ne faire que le backfill journalier)") parser.add_argument("--daily-days", type=int, default=30, help="Nombre de jours à rapatrier en granularité journalière, pour que les graphes " "7j/30j soient déjà peuplés (défaut: 30 ; 0 pour ne faire que l'historique mensuel)") parser.add_argument("--tenant", help="Ne rapatrier qu'un seul tenant (son 'name' dans config.json)") args = parser.parse_args() 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) months = month_ranges(args.months) if args.months > 0 else [] days = day_ranges(args.daily_days) if args.daily_days > 0 else [] if months: log.info("Backfill de %d mois (%s -> %s)", len(months), months[0][0].isoformat(), months[-1][1].isoformat()) if days: log.info("Backfill journalier de %d jours (%s -> %s)", len(days), days[0].isoformat(), days[-1].isoformat()) for provider, entries in (("aws", config.get("aws", [])), ("azure", config.get("azure", []))): for entry in entries: if args.tenant and entry["name"] != args.tenant: continue backfill_tenant(conn, provider, entry, months) backfill_tenant_daily(conn, provider, entry, days) conn.close() log.info("Terminé.") if __name__ == "__main__": main()