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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user