first (big) draft
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user