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()
+165
View File
@@ -0,0 +1,165 @@
#!/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()
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""
cost_report.py — Récupère les coûts de plusieurs tenants AWS et Azure.
Utilisation :
python cost_report.py # coûts du mois en cours (MTD)
python cost_report.py --start 2026-06-01 --end 2026-07-01
python cost_report.py --config mon_config.json
python cost_report.py --json # sortie JSON au lieu du tableau
Prérequis :
pip install -r requirements.txt
Config :
Copier config.example.json en config.json et remplir tes comptes.
Permissions nécessaires :
- AWS : le profil doit avoir le droit IAM "ce:GetCostAndUsage"
(policy managée "Billing" ou custom policy dessus).
- Azure : le service principal doit avoir le rôle "Cost Management Reader"
(ou "Reader") sur la subscription visée.
"""
import argparse
import json
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import date, timedelta
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from providers import get_aws_cost, get_azure_cost, convert_result
# ----------------------------------------------------------------------
# Orchestration
# ----------------------------------------------------------------------
def default_period():
"""Du 1er du mois en cours à aujourd'hui (Cost Explorer/Cost Management
veulent 'end' exclusif)."""
today = date.today()
start = today.replace(day=1)
end = today + timedelta(days=1)
return start.isoformat(), end.isoformat()
def run(config_path: str, start: str, end: str, target_currency: str = None):
with open(config_path) as f:
config = json.load(f)
results = []
def handle_aws(entry):
name = entry["name"]
try:
data = get_aws_cost(entry, start, end)
if target_currency:
data = convert_result(data, target_currency)
return {"provider": "AWS", "name": name, "status": "ok", **data}
except Exception as e:
return {"provider": "AWS", "name": name, "status": "error", "error": str(e)}
def handle_azure(entry):
name = entry["name"]
try:
data = get_azure_cost(
entry["tenant_id"], entry["client_id"], entry["client_secret"],
entry["subscription_id"], start, end,
)
if target_currency:
data = convert_result(data, target_currency)
return {"provider": "Azure", "name": name, "status": "ok", **data}
except Exception as e:
return {"provider": "Azure", "name": name, "status": "error", "error": str(e)}
tasks = []
with ThreadPoolExecutor(max_workers=10) as pool:
for entry in config.get("aws", []):
tasks.append(pool.submit(handle_aws, entry))
for entry in config.get("azure", []):
tasks.append(pool.submit(handle_azure, entry))
for t in as_completed(tasks):
results.append(t.result())
return results
def print_table(results, start, end):
print(f"\nCoûts du {start} au {end}\n")
header = f"{'Provider':8} {'Tenant':30} {'Statut':8} {'Total':>12} Devise"
print(header)
print("-" * len(header))
grand_total_by_currency = {}
for r in sorted(results, key=lambda x: (x["provider"], x["name"])):
if r["status"] == "ok":
line = f"{r['provider']:8} {r['name']:30} {'OK':8} {r['total']:>12.2f} {r['currency']}"
if r.get("original_currency") and r["original_currency"] != r["currency"]:
line += f" (orig. {r['original_total']:.2f} {r['original_currency']})"
if r.get("conversion_error"):
line += f" [conversion échouée: {r['conversion_error'][:40]}]"
print(line)
grand_total_by_currency[r["currency"]] = grand_total_by_currency.get(r["currency"], 0) + r["total"]
else:
print(f"{r['provider']:8} {r['name']:30} {'ERREUR':8} {r['error'][:60]}")
print("-" * len(header))
for currency, total in grand_total_by_currency.items():
print(f"{'TOTAL':8} {'':30} {'':8} {total:>12.2f} {currency}")
if len(grand_total_by_currency) > 1:
print(" (Attention : plusieurs devises encore présentes, vérifie les erreurs de conversion ci-dessus)")
def main():
parser = argparse.ArgumentParser(description="Rapport de coûts multi-tenants AWS/Azure")
parser.add_argument("--config", default="config.json", help="Chemin vers le fichier de config")
parser.add_argument("--start", help="Date de début (YYYY-MM-DD), défaut: 1er du mois en cours")
parser.add_argument("--end", help="Date de fin exclusive (YYYY-MM-DD), défaut: demain")
parser.add_argument("--json", action="store_true", help="Sortie en JSON brut")
parser.add_argument("--detail", action="store_true", help="Afficher la répartition par service")
parser.add_argument("--currency", default="EUR",
help="Devise cible pour la conversion (défaut: EUR). "
"Utiliser --no-convert pour désactiver.")
parser.add_argument("--no-convert", action="store_true",
help="Désactive la conversion, affiche les montants dans leur devise d'origine")
args = parser.parse_args()
default_start, default_end = default_period()
start = args.start or default_start
end = args.end or default_end
target_currency = None if args.no_convert else args.currency
results = run(args.config, start, end, target_currency)
if args.json:
print(json.dumps({"start": start, "end": end, "results": results}, indent=2, ensure_ascii=False))
return
print_table(results, start, end)
if args.detail:
for r in results:
if r["status"] == "ok" and r.get("by_service"):
print(f"\n--- Détail {r['provider']} / {r['name']} ---")
for service, amount in list(r["by_service"].items())[:15]:
print(f" {service:45} {amount:>10.2f} {r['currency']}")
if __name__ == "__main__":
main()
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""
db.py — Connexion SQLite + écriture des relevés de coûts.
Utilisé par collect.py (collecte journalière) et import_historical.py
(import ponctuel). Le schéma vit dans db/schema.sql.
"""
from __future__ import annotations
import os
import sqlite3
from pathlib import Path
DEFAULT_DB_PATH = "cost_dashboard.db"
SCHEMA_PATH = Path(__file__).parent.parent / "db" / "schema.sql"
def _resolve_db_path(db_path: str | None = None) -> str:
return db_path or os.environ.get("COST_DASHBOARD_DB") or DEFAULT_DB_PATH
def init_db(db_path: str | None = None) -> None:
"""Crée le fichier SQLite et applique le schéma s'il n'existe pas déjà."""
path = _resolve_db_path(db_path)
Path(path).parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(path)
try:
conn.executescript(SCHEMA_PATH.read_text())
conn.commit()
finally:
conn.close()
def get_connection(db_path: str | None = None) -> sqlite3.Connection:
"""Ouvre une connexion SQLite (foreign keys activées, row factory par nom)."""
path = _resolve_db_path(db_path)
conn = sqlite3.connect(path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
def upsert_collection(
conn: sqlite3.Connection,
provider: str,
tenant_name: str,
granularity: str,
period_start: str,
period_end: str,
collected_at: str,
status: str,
data: dict | None = None,
error_message: str | None = None,
) -> int:
"""Insère ou remplace un relevé (provider, tenant_name, granularity,
period_start) et sa répartition par service. `data` est le dict retourné
par providers.get_aws_cost/get_azure_cost (+ convert_result) : total,
currency, by_service, original_total, original_currency, exchange_rate.
"""
data = data or {}
conn.execute(
"""
INSERT INTO collections (
provider, tenant_name, granularity, period_start, period_end,
collected_at, status, error_message,
total_amount, currency, original_total, original_currency, exchange_rate
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(provider, tenant_name, granularity, period_start) DO UPDATE SET
period_end = excluded.period_end,
collected_at = excluded.collected_at,
status = excluded.status,
error_message = excluded.error_message,
total_amount = excluded.total_amount,
currency = excluded.currency,
original_total = excluded.original_total,
original_currency = excluded.original_currency,
exchange_rate = excluded.exchange_rate
""",
(
provider, tenant_name, granularity, period_start, period_end,
collected_at, status, error_message,
data.get("total"), data.get("currency"),
data.get("original_total"), data.get("original_currency"), data.get("exchange_rate"),
),
)
# last_insert_rowid() n'est pas fiable ici : sur la branche ON CONFLICT DO
# UPDATE (pas de nouvelle ligne créée), SQLite ne le met à jour que si
# aucun autre INSERT n'a eu lieu sur la connexion depuis — ce qui n'est
# pas garanti (ex: service_costs d'un tenant précédent). On relit l'id
# explicitement via la clé unique.
collection_id = conn.execute(
"""
SELECT id FROM collections
WHERE provider = ? AND tenant_name = ? AND granularity = ? AND period_start = ?
""",
(provider, tenant_name, granularity, period_start),
).fetchone()["id"]
conn.execute("DELETE FROM service_costs WHERE collection_id = ?", (collection_id,))
by_service = data.get("by_service") or {}
if by_service:
conn.executemany(
"INSERT INTO service_costs (collection_id, service_name, amount) VALUES (?, ?, ?)",
[(collection_id, service, amount) for service, amount in by_service.items()],
)
conn.commit()
return collection_id
def set_resource_collection_status(
conn: sqlite3.Connection,
provider: str,
tenant_name: str,
status: str,
collected_at: str,
message: str | None = None,
) -> None:
conn.execute(
"""
INSERT INTO resource_collection_status (provider, tenant_name, status, message, collected_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(provider, tenant_name) DO UPDATE SET
status = excluded.status,
message = excluded.message,
collected_at = excluded.collected_at
""",
(provider, tenant_name, status, message, collected_at),
)
conn.commit()
def upsert_resource_costs(
conn: sqlite3.Connection,
provider: str,
tenant_name: str,
period_start: str,
period_end: str,
services: dict,
) -> None:
"""Remplace le cliché de coûts par ressource pour ce tenant (voir
resource_costs dans le schéma : c'est une photo glissante, pas un
historique — on supprime l'ancien cliché avant d'écrire le nouveau).
`services` : {service_name: [{resource_id, resource_name, amount}, ...]},
la forme renvoyée par providers.get_*_resource_costs.
"""
conn.execute(
"DELETE FROM resource_costs WHERE provider = ? AND tenant_name = ?",
(provider, tenant_name),
)
rows = [
(provider, tenant_name, service, r["resource_id"], r["resource_name"], period_start, period_end, r["amount"])
for service, resources in services.items()
for r in resources
]
if rows:
conn.executemany(
"""
INSERT INTO resource_costs (
provider, tenant_name, service_name, resource_id, resource_name,
period_start, period_end, amount
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
rows,
)
conn.commit()
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""
import_historical.py — Importe en une fois d'anciennes données de coûts,
au format JSON produit par cost_report.py --json ou tenant_detail.py --json.
Les données importées sont stockées en granularity='monthly' (une ligne =
une période, généralement un mois), pour ne pas se mélanger avec la
collecte journalière du cron qui alimente granularity='daily'.
Formats acceptés (auto-détectés) :
1. Sortie multi-tenants de cost_report.py --json :
{"start": "...", "end": "...", "results": [
{"provider": "AWS", "name": "...", "status": "ok", "total": ..,
"currency": "EUR", "by_service": {...}, ...},
...
]}
2. Sortie mono-tenant de tenant_detail.py --json :
{"tenant": "...", "provider": "AWS", "start": "...", "end": "...",
"total": .., "currency": "EUR", "by_service": {...}}
Utilisation :
python import_historical.py --db ../cost_dashboard.db fichier1.json fichier2.json
python import_historical.py --db ../cost_dashboard.db historique/*.json
"""
import argparse
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from db import get_connection, init_db, upsert_collection
def import_file(conn, path: str) -> int:
with open(path) as f:
payload = json.load(f)
imported = 0
collected_at = datetime.now(timezone.utc).isoformat()
if "results" in payload:
# Format multi-tenants (cost_report.py --json)
start, end = payload["start"], payload["end"]
for r in payload["results"]:
provider = r["provider"].lower()
if r["status"] != "ok":
continue
upsert_collection(
conn, provider, r["name"], "monthly", start, end,
collected_at, "ok", data=r,
)
imported += 1
elif "tenant" in payload:
# Format mono-tenant (tenant_detail.py --json)
provider = payload["provider"].lower()
upsert_collection(
conn, provider, payload["tenant"], "monthly",
payload["start"], payload["end"], collected_at, "ok", data=payload,
)
imported += 1
else:
print(f" Format non reconnu, ignoré : {path}", file=sys.stderr)
return imported
def main():
parser = argparse.ArgumentParser(description="Import ponctuel de données de coûts historiques")
parser.add_argument("--db", default=None, help="Chemin vers la base SQLite")
parser.add_argument("files", nargs="+", help="Fichier(s) JSON à importer")
args = parser.parse_args()
if args.db:
init_db(args.db)
conn = get_connection(args.db)
else:
init_db()
conn = get_connection()
total_imported = 0
for path in args.files:
n = import_file(conn, path)
print(f"{path} : {n} relevé(s) importé(s)")
total_imported += n
conn.close()
print(f"\nTotal : {total_imported} relevé(s) importé(s).")
if __name__ == "__main__":
main()
+395
View File
@@ -0,0 +1,395 @@
"""
providers.py — Accès aux API de coûts AWS et Azure, + conversion de devise.
Module partagé entre le collecteur cron (collect.py), l'import historique
(import_historical.py) et les scripts CLI ponctuels. Toute la logique
d'authentification et d'agrégation vit ici, une seule fois.
"""
from __future__ import annotations
class ResourceLevelUnavailable(Exception):
"""Levée quand AWS/Azure refuse une demande de détail par ressource —
le plus souvent parce que "Resource IDs" n'est pas activé côté AWS
Cost Explorer, ou qu'une fenêtre de plus de 14 jours a été demandée à
AWS. Distincte d'une erreur générique pour que l'appelant puisse
l'afficher comme un état "indisponible", pas comme un échec."""
def _short_resource_name(resource_id: str) -> str:
"""Dernier segment d'un ARN AWS ou d'un resource ID Azure, pour
l'affichage (ex: 'arn:aws:s3:::my-bucket' -> 'my-bucket',
'/subscriptions/.../virtualMachines/vm-1' -> 'vm-1')."""
if "/" in resource_id:
return resource_id.rsplit("/", 1)[-1]
if ":" in resource_id:
return resource_id.rsplit(":", 1)[-1]
return resource_id
# ----------------------------------------------------------------------
# AWS
# ----------------------------------------------------------------------
def _aws_session(entry: dict):
"""Construit la session boto3 pour un tenant AWS.
Deux modes, au choix par tenant dans config.json :
- 'access_key_id' + 'secret_access_key' : clé IAM dédiée au tenant
(droit ce:GetCostAndUsage uniquement) — le seul mode qui fonctionne
en conteneur/Kubernetes, car il ne dépend d'aucun état sur l'hôte.
- 'profile' : profil ~/.aws/{credentials,config} existant — pratique en
local, mais suppose ~/.aws monté dans le conteneur, et ne fonctionne
pas du tout avec un profil SSO (le refresh token expire et sa
reconduction demande un navigateur, indisponible en conteneur).
"""
import boto3
name = entry.get("name", "?")
if entry.get("access_key_id") and entry.get("secret_access_key"):
return boto3.Session(
aws_access_key_id=entry["access_key_id"],
aws_secret_access_key=entry["secret_access_key"],
)
if entry.get("profile"):
return boto3.Session(profile_name=entry["profile"])
raise ValueError(
f"Tenant AWS '{name}' : config.json doit fournir soit 'profile', "
"soit 'access_key_id' + 'secret_access_key'."
)
def get_aws_cost(entry: dict, start: str, end: str, granularity: str = "MONTHLY") -> dict:
"""Coût total + répartition par service pour un tenant AWS (voir
_aws_session pour les modes d'authentification), sur la période
[start, end) (end exclusif, format YYYY-MM-DD).
granularity: 'DAILY' ou 'MONTHLY' (voir doc AWS Cost Explorer).
"""
session = _aws_session(entry)
ce = session.client("ce", region_name="us-east-1") # Cost Explorer = toujours us-east-1
resp = ce.get_cost_and_usage(
TimePeriod={"Start": start, "End": end},
Granularity=granularity,
Metrics=["UnblendedCost"],
GroupBy=[{"Type": "DIMENSION", "Key": "SERVICE"}],
)
total = 0.0
by_service: dict[str, float] = {}
currency = "USD"
for period in resp["ResultsByTime"]:
for group in period["Groups"]:
service = group["Keys"][0]
amount = float(group["Metrics"]["UnblendedCost"]["Amount"])
currency = group["Metrics"]["UnblendedCost"]["Unit"]
by_service[service] = by_service.get(service, 0.0) + amount
total += amount
return {
"total": round(total, 2),
"currency": currency,
"by_service": {k: round(v, 2) for k, v in sorted(by_service.items(), key=lambda x: -x[1])},
}
def get_aws_resource_costs(entry: dict, start: str, end: str) -> dict:
"""Coût par ressource (bucket S3, instance EC2, fonction Lambda...),
groupé par service, sur la période [start, end). Le détail par ressource
n'est PAS accessible via get_cost_and_usage (son GroupBy ne connaît pas
RESOURCE_ID) — il faut l'API dédiée get_cost_and_usage_with_resources,
qui impose les mêmes contraintes (14 jours max, "Resource IDs" activé
côté compte). Lève ResourceLevelUnavailable si AWS refuse, plutôt que de
laisser planter l'appelant, pour que ce soit affichable comme un état
plutôt qu'une erreur de collecte."""
from botocore.exceptions import ClientError
session = _aws_session(entry)
ce = session.client("ce", region_name="us-east-1")
try:
resp = ce.get_cost_and_usage_with_resources(
TimePeriod={"Start": start, "End": end},
Granularity="DAILY",
Metrics=["UnblendedCost"],
GroupBy=[
{"Type": "DIMENSION", "Key": "SERVICE"},
{"Type": "DIMENSION", "Key": "RESOURCE_ID"},
],
Filter={"Dimensions": {"Key": "RECORD_TYPE", "Values": ["Usage"]}},
)
except ClientError as e:
raise ResourceLevelUnavailable(str(e)) from e
services: dict[str, dict[str, float]] = {}
currency = "USD"
for period in resp["ResultsByTime"]:
for group in period["Groups"]:
service, resource_id = group["Keys"]
amount = float(group["Metrics"]["UnblendedCost"]["Amount"])
currency = group["Metrics"]["UnblendedCost"]["Unit"]
bucket = services.setdefault(service, {})
bucket[resource_id] = bucket.get(resource_id, 0.0) + amount
return {"currency": currency, "services": _finalize_resource_buckets(services)}
def _finalize_resource_buckets(services: dict[str, dict[str, float]]) -> dict[str, list[dict]]:
"""Transforme {service: {resource_id: montant}} en
{service: [{resource_id, resource_name, amount}, ...]} trié par coût
décroissant. 'NoResourceId' / id vide = coûts du service non rattachés
à une ressource précise (data transfer, support...)."""
result: dict[str, list[dict]] = {}
for service, resources in services.items():
rows = []
for rid, amount in resources.items():
is_unattributed = not rid or rid == "NoResourceId"
rows.append({
"resource_id": rid,
"resource_name": "Autres coûts (sans ressource associée)" if is_unattributed else _short_resource_name(rid),
"amount": round(amount, 2),
})
rows.sort(key=lambda r: -r["amount"])
result[service] = rows
return result
# ----------------------------------------------------------------------
# Azure
# ----------------------------------------------------------------------
# Cost Management ne renvoie pas le `Retry-After` HTTP standard sur ses 429 —
# constaté en pratique : il renvoie ses propres en-têtes `x-ms-ratelimit-
# microsoft.costmanagement-*-retry-after`, un par niveau de quota (entité,
# tenant, type de client). On vérifie les deux formes, et on prend le délai
# le plus grand si plusieurs sont présents (le plus restrictif fait foi).
_AZURE_RETRY_AFTER_HEADERS = (
"Retry-After",
"x-ms-ratelimit-microsoft.costmanagement-entity-retry-after",
"x-ms-ratelimit-microsoft.costmanagement-tenant-retry-after",
"x-ms-ratelimit-microsoft.costmanagement-clienttype-retry-after",
)
def _azure_retry_after_seconds(response) -> float | None:
if response is None:
return None
waits = []
for header in _AZURE_RETRY_AFTER_HEADERS:
value = response.headers.get(header)
if value:
try:
waits.append(float(value))
except ValueError:
pass
return max(waits) if waits else None
def _azure_query_with_retry(client, scope: str, query: dict, max_retries: int = 8):
"""client.query.usage avec retry + backoff sur les 429. L'API Cost
Management est plus sujette au rate-limiting que Cost Explorer côté AWS
— le quota est souvent partagé au niveau du tenant Azure AD entier, pas
par service principal, donc même un tout premier appel peut être
limité — et azure-core ne retente pas toujours ces réponses tout seul
(constaté : un seul essai visible dans les logs avant l'erreur).
max_retries=8 avec un plafond de repli à 60s (~5 min de patience max
cumulée) si Azure ne fournit aucun en-tête retry-after exploitable :
volontairement généreux, pensé pour un backfill qui peut se permettre
d'attendre plutôt que d'abandonner en cours de route."""
from azure.core.exceptions import HttpResponseError
import time
for attempt in range(max_retries):
try:
return client.query.usage(scope, query)
except HttpResponseError as e:
if e.status_code != 429 or attempt == max_retries - 1:
raise
wait = _azure_retry_after_seconds(e.response)
if wait is None:
wait = min(2 ** attempt, 60)
time.sleep(wait)
def get_azure_cost(tenant_id: str, client_id: str, client_secret: str,
subscription_id: str, start: str, end: str) -> dict:
"""Coût total + répartition par service pour une subscription Azure,
sur la période [start, end)."""
from azure.identity import ClientSecretCredential
from azure.mgmt.costmanagement import CostManagementClient
credential = ClientSecretCredential(tenant_id, client_id, client_secret)
client = CostManagementClient(credential)
scope = f"/subscriptions/{subscription_id}"
query = {
"type": "ActualCost",
"timeframe": "Custom",
"timePeriod": {"from": f"{start}T00:00:00+00:00", "to": f"{end}T00:00:00+00:00"},
"dataset": {
"granularity": "None",
"aggregation": {"totalCost": {"name": "PreTaxCost", "function": "Sum"}},
"grouping": [{"type": "Dimension", "name": "ServiceName"}],
},
}
result = _azure_query_with_retry(client, scope, query)
columns = [c.name for c in result.columns]
cost_idx = columns.index("PreTaxCost")
currency_idx = columns.index("Currency") if "Currency" in columns else None
service_idx = columns.index("ServiceName") if "ServiceName" in columns else None
total = 0.0
by_service: dict[str, float] = {}
currency = "EUR"
for row in result.rows:
amount = float(row[cost_idx])
total += amount
if service_idx is not None:
service = row[service_idx]
by_service[service] = by_service.get(service, 0.0) + amount
if currency_idx is not None:
currency = row[currency_idx]
return {
"total": round(total, 2),
"currency": currency,
"by_service": {k: round(v, 2) for k, v in sorted(by_service.items(), key=lambda x: -x[1])},
}
def get_azure_resource_costs(tenant_id: str, client_id: str, client_secret: str,
subscription_id: str, start: str, end: str) -> dict:
"""Coût par ressource (VM, storage account...), groupé par service, sur
la période [start, end). Azure n'impose pas de limite de fenêtre pour ce
niveau de détail (contrairement à AWS) — on garde quand même 14 jours
en pratique pour rester cohérent entre les deux providers."""
from azure.identity import ClientSecretCredential
from azure.mgmt.costmanagement import CostManagementClient
from azure.core.exceptions import HttpResponseError
credential = ClientSecretCredential(tenant_id, client_id, client_secret)
client = CostManagementClient(credential)
scope = f"/subscriptions/{subscription_id}"
query = {
"type": "ActualCost",
"timeframe": "Custom",
"timePeriod": {"from": f"{start}T00:00:00+00:00", "to": f"{end}T00:00:00+00:00"},
"dataset": {
"granularity": "None",
"aggregation": {"totalCost": {"name": "PreTaxCost", "function": "Sum"}},
"grouping": [
{"type": "Dimension", "name": "ServiceName"},
{"type": "Dimension", "name": "ResourceId"},
],
},
}
try:
result = _azure_query_with_retry(client, scope, query)
except HttpResponseError as e:
raise ResourceLevelUnavailable(str(e)) from e
columns = [c.name for c in result.columns]
cost_idx = columns.index("PreTaxCost")
currency_idx = columns.index("Currency") if "Currency" in columns else None
service_idx = columns.index("ServiceName") if "ServiceName" in columns else None
resource_idx = columns.index("ResourceId") if "ResourceId" in columns else None
services: dict[str, dict[str, float]] = {}
currency = "EUR"
for row in result.rows:
amount = float(row[cost_idx])
if currency_idx is not None:
currency = row[currency_idx]
service = row[service_idx] if service_idx is not None else "(service inconnu)"
resource_id = row[resource_idx] if resource_idx is not None else ""
bucket = services.setdefault(service, {})
bucket[resource_id] = bucket.get(resource_id, 0.0) + amount
return {"currency": currency, "services": _finalize_resource_buckets(services)}
# ----------------------------------------------------------------------
# Conversion de devise
# ----------------------------------------------------------------------
_rate_cache: dict[tuple[str, str], float] = {}
def get_exchange_rate(from_currency: str, to_currency: str) -> float:
"""Taux de change du jour (source: BCE, via l'API gratuite frankfurter.app).
Mis en cache pour la durée du process."""
if from_currency == to_currency:
return 1.0
key = (from_currency, to_currency)
if key in _rate_cache:
return _rate_cache[key]
import requests
resp = requests.get(
"https://api.frankfurter.app/latest",
params={"from": from_currency, "to": to_currency},
timeout=10,
)
resp.raise_for_status()
rate = resp.json()["rates"][to_currency]
_rate_cache[key] = rate
return rate
def convert_result(result: dict, target_currency: str) -> dict:
"""Convertit un résultat (total + by_service) vers la devise cible.
Ajoute 'original_total' / 'original_currency' / 'exchange_rate' pour
traçabilité. Le montant est figé : il ne sera plus reconverti ensuite."""
original_currency = result["currency"]
if original_currency == target_currency:
result["original_total"] = result["total"]
result["original_currency"] = original_currency
result["exchange_rate"] = 1.0
return result
try:
rate = get_exchange_rate(original_currency, target_currency)
except Exception as e:
result["conversion_error"] = str(e)
return result
result["original_total"] = result["total"]
result["original_currency"] = original_currency
result["exchange_rate"] = rate
result["total"] = round(result["total"] * rate, 2)
result["currency"] = target_currency
if result.get("by_service"):
result["by_service"] = {k: round(v * rate, 2) for k, v in result["by_service"].items()}
return result
def convert_resource_costs(result: dict, target_currency: str) -> dict:
"""Équivalent de convert_result pour le résultat de
get_aws_resource_costs / get_azure_resource_costs (par ressource,
groupé par service)."""
original_currency = result["currency"]
if original_currency == target_currency:
return result
try:
rate = get_exchange_rate(original_currency, target_currency)
except Exception as e:
result["conversion_error"] = str(e)
return result
result["currency"] = target_currency
result["services"] = {
service: [{**r, "amount": round(r["amount"] * rate, 2)} for r in resources]
for service, resources in result["services"].items()
}
return result
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""
tenant_detail.py — Détail des coûts par famille de service (S3, EC2, ECS,
Fargate, etc.) pour UN tenant spécifique, converti en EUR par défaut.
Ce script réutilise providers.py (accès AWS/Azure) et default_period() de
cost_report.py : il doit rester dans le même dossier que ces deux modules.
Utilisation :
python tenant_detail.py --tenant analytics-platform
python tenant_detail.py --tenant client-c-prod --start 2026-06-01 --end 2026-07-01
python tenant_detail.py --tenant analytics-platform --top 15
python tenant_detail.py --tenant analytics-platform --json
python tenant_detail.py --tenant analytics-platform --no-convert # devise d'origine
"""
import argparse
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from providers import get_aws_cost, get_azure_cost, convert_result
from cost_report import default_period
def find_tenant(config: dict, tenant_name: str):
"""Cherche le tenant par son 'name' dans les sections aws/azure de la config.
Retourne (provider, entry) ou (None, None) si introuvable."""
for entry in config.get("aws", []):
if entry["name"] == tenant_name:
return "aws", entry
for entry in config.get("azure", []):
if entry["name"] == tenant_name:
return "azure", entry
return None, None
def main():
parser = argparse.ArgumentParser(
description="Détail des coûts par famille de service pour un tenant donné"
)
parser.add_argument("--tenant", required=True,
help="Nom du tenant tel que défini dans config.json")
parser.add_argument("--config", default="config.json", help="Chemin vers le fichier de config")
parser.add_argument("--start", help="Date de début (YYYY-MM-DD), défaut: 1er du mois en cours")
parser.add_argument("--end", help="Date de fin exclusive (YYYY-MM-DD), défaut: demain")
parser.add_argument("--currency", default="EUR", help="Devise cible (défaut: EUR)")
parser.add_argument("--no-convert", action="store_true",
help="Désactive la conversion, garde la devise d'origine")
parser.add_argument("--top", type=int, default=None,
help="N'afficher que les N services les plus coûteux")
parser.add_argument("--json", action="store_true", help="Sortie en JSON brut")
args = parser.parse_args()
try:
with open(args.config) as f:
config = json.load(f)
except FileNotFoundError:
print(f"Fichier de config introuvable : {args.config}", file=sys.stderr)
sys.exit(1)
provider, entry = find_tenant(config, args.tenant)
if entry is None:
available = [e["name"] for e in config.get("aws", [])] + \
[e["name"] for e in config.get("azure", [])]
print(f"Tenant '{args.tenant}' introuvable dans {args.config}", file=sys.stderr)
print(f"Tenants disponibles : {', '.join(available) or '(aucun)'}", file=sys.stderr)
sys.exit(1)
default_start, default_end = default_period()
start = args.start or default_start
end = args.end or default_end
try:
if provider == "aws":
data = get_aws_cost(entry, start, end)
else:
data = get_azure_cost(
entry["tenant_id"], entry["client_id"], entry["client_secret"],
entry["subscription_id"], start, end,
)
except Exception as e:
print(f"Erreur lors de la récupération des coûts pour '{args.tenant}' : {e}", file=sys.stderr)
sys.exit(1)
target_currency = None if args.no_convert else args.currency
if target_currency:
data = convert_result(data, target_currency)
if args.json:
print(json.dumps(
{"tenant": args.tenant, "provider": provider.upper(), "start": start, "end": end, **data},
indent=2, ensure_ascii=False,
))
return
print(f"\nDétail des coûts — {args.tenant} ({provider.upper()})")
print(f"Période : {start}{end}")
if data.get("original_currency") and data["original_currency"] != data["currency"]:
print(f"Total : {data['total']:.2f} {data['currency']} "
f"(orig. {data['original_total']:.2f} {data['original_currency']})")
else:
print(f"Total : {data['total']:.2f} {data['currency']}")
if data.get("conversion_error"):
print(f"[Conversion échouée, montant affiché dans sa devise d'origine : {data['conversion_error']}]")
services = list(data.get("by_service", {}).items())
if args.top:
services = services[:args.top]
print()
if not services:
print("Aucun coût détecté sur la période pour ce tenant.")
return
total = data["total"] or 1 # évite division par zéro
header = f"{'Famille de service':45} {'Montant':>12} {'%':>6}"
print(header)
print("-" * len(header))
for service, amount in services:
pct = (amount / total) * 100
print(f"{service:45} {amount:>12.2f} {pct:>5.1f}%")
print("-" * len(header))
print(f"{'TOTAL':45} {data['total']:>12.2f} {'100.0':>5}% ({data['currency']})")
if __name__ == "__main__":
main()