first (big) draft

This commit is contained in:
2026-08-07 17:19:49 +02:00
parent 2974a3a4cd
commit 9e78dfa4f2
24 changed files with 3975 additions and 1 deletions
+198
View File
@@ -0,0 +1,198 @@
#!/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()