290 lines
13 KiB
Markdown
290 lines
13 KiB
Markdown
# simple-cost-dashboard
|
|
|
|
Suivi centralisé des coûts AWS et Azure pour une multitude de tenants,
|
|
avec conversion automatique en EUR et historique conservé en base SQLite.
|
|
Dashboard web (page globale + détail par tenant) alimenté par une collecte
|
|
quotidienne (cron).
|
|
|
|
---
|
|
|
|
## 1. Installation locale (sans Docker)
|
|
|
|
```bash
|
|
cd simple-cost-dashboard
|
|
python3 -m venv venv
|
|
source venv/bin/activate
|
|
pip install -r requirements.txt
|
|
```
|
|
|
|
## 2. Configuration des tenants
|
|
|
|
Crée un fichier `config.json` à la racine du projet (à côté de ce README) :
|
|
|
|
```json
|
|
{
|
|
"aws": [
|
|
{ "name": "analytics-platform", "profile": "analytics-platform" },
|
|
{
|
|
"name": "client-a-prod",
|
|
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
|
|
"secret_access_key": "VOTRE_SECRET_ICI"
|
|
}
|
|
],
|
|
"azure": [
|
|
{
|
|
"name": "client-c-prod",
|
|
"tenant_id": "00000000-0000-0000-0000-000000000000",
|
|
"client_id": "11111111-1111-1111-1111-111111111111",
|
|
"client_secret": "VOTRE_SECRET_ICI",
|
|
"subscription_id": "22222222-2222-2222-2222-222222222222"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
- **AWS** — deux modes au choix par tenant, dans l'ordre de priorité
|
|
`access_key_id`/`secret_access_key` puis `profile` :
|
|
- `access_key_id` + `secret_access_key` : clé IAM dédiée au tenant,
|
|
limitée au droit `ce:GetCostAndUsage`. **Seul mode qui fonctionne en
|
|
conteneur/Kubernetes** — il ne dépend d'aucun fichier ni état sur
|
|
l'hôte, contrairement à `profile`.
|
|
- `profile` : un profil déjà configuré dans `~/.aws/credentials` ou
|
|
`~/.aws/config`, pratique en local. Nécessite de monter `~/.aws` dans
|
|
le conteneur (`-v ~/.aws:/home/appuser/.aws:ro`) — et **ne fonctionne
|
|
pas avec un profil SSO** en conteneur : le token mis en cache finit
|
|
par expirer, et son renouvellement demande un navigateur, indisponible
|
|
côté conteneur/CronJob. Réservé au dev local avec des clés statiques
|
|
ou un profil SSO valide au moment du run.
|
|
- **Azure** : un service principal par tenant (`az ad sp create-for-rbac`),
|
|
avec le rôle **Cost Management Reader** sur la subscription visée.
|
|
- Le champ `"name"` est l'identifiant utilisé partout (URLs, dashboard).
|
|
Deux tenants peuvent avoir le même `name` sur des CSP différents (ex:
|
|
`analytics-platform` en AWS **et** en Azure) — ils restent distingués
|
|
par le couple `(provider, name)`, y compris dans les URLs
|
|
(`/tenant/aws/analytics-platform` vs `/tenant/azure/analytics-platform`).
|
|
|
|
⚠️ `config.json` contient des secrets (`client_secret` Azure, `secret_access_key`
|
|
AWS). Ne le commite pas — il est déjà exclu via `.gitignore`.
|
|
|
|
## 3. Base de données
|
|
|
|
La base SQLite (`cost_dashboard.db` par défaut, chemin surchargeable via
|
|
`COST_DASHBOARD_DB`) et son schéma sont créés automatiquement au premier
|
|
lancement de `collect.py`, `import_historical.py` ou de l'API. Rien à
|
|
faire manuellement. Le fichier `db/schema.sql` décrit la structure.
|
|
|
|
## 4. Importer l'historique (une seule fois)
|
|
|
|
**Depuis AWS/Azure directement**, pour peupler la base avec tout l'historique
|
|
disponible sans étape intermédiaire :
|
|
|
|
```bash
|
|
cd collector
|
|
python backfill_historical.py --config ../config.json --db ../cost_dashboard.db
|
|
```
|
|
|
|
Rapatrie 12 mois par défaut (`--months` pour ajuster), un par un, pour
|
|
chaque tenant de `config.json`. Limites à connaître :
|
|
- **AWS Cost Explorer ne conserve que 12 mois glissants** — au-delà, l'API
|
|
ne renvoie rien (pas une erreur), donc `--months` plus grand n'aide pas
|
|
côté AWS. Azure dépend du contrat (souvent plus long).
|
|
- Chaque mois interrogé est un appel Cost Explorer/Cost Management facturé
|
|
(~0,01 $ la requête AWS) — pour 28 tenants sur 12 mois, prévoir ~336
|
|
requêtes (quelques dollars, payés une fois).
|
|
- `--tenant <nom>` pour ne rapatrier qu'un seul tenant (ex: nouvellement
|
|
ajouté à `config.json`).
|
|
|
|
En plus de l'historique mensuel, ce même script rapatrie aussi les
|
|
**30 derniers jours en granularité journalière** (`--daily-days`, 0 pour
|
|
désactiver) — sinon les graphes 7j/30j de la page tenant restent vides
|
|
jusqu'à ce que le cron ait tourné 7 à 30 jours de suite pour les remplir.
|
|
Rejoue exactement `collect_tenant`/`collect_tenant_resources` de
|
|
`collect.py` (mêmes fonctions que le cron), jour par jour : même ordre de
|
|
grandeur en appels facturés que le backfill mensuel (~840 requêtes pour
|
|
28 tenants sur 30 jours), et ça prend plusieurs minutes (appels
|
|
séquentiels). Le cron quotidien écrase ensuite ces jours au fil de l'eau
|
|
sans créer de doublons (upsert sur la même clé).
|
|
|
|
⚠️ **Côté Azure**, l'API Cost Management est plus vite sujette au
|
|
rate-limiting (429) que Cost Explorer côté AWS — surtout en rafale sur les
|
|
dizaines d'appels d'un backfill. `providers.py` retente automatiquement les
|
|
429 (jusqu'à 8 fois), en respectant le délai suggéré par Azure — pas le
|
|
`Retry-After` HTTP standard, mais ses propres en-têtes
|
|
`x-ms-ratelimit-microsoft.costmanagement-{entity,tenant,clienttype}-retry-after`,
|
|
le plus restrictif faisant foi quand plusieurs sont présents. `backfill_historical.py`
|
|
ajoute en plus une pause de 2s entre deux appels Azure pour limiter le
|
|
risque d'en déclencher. Si un tenant Azure échoue quand même après ça,
|
|
relancer le backfill juste sur lui (`--tenant <nom>`) suffit généralement.
|
|
|
|
**Depuis des exports JSON existants** (si tu as déjà des données de coûts
|
|
passées, exportées via `cost_report.py --json` ou `tenant_detail.py --json`,
|
|
par exemple générées avant la mise en place de ce dashboard) :
|
|
|
|
```bash
|
|
python import_historical.py --db ../cost_dashboard.db ../historique/*.json
|
|
```
|
|
|
|
Les deux stockent avec `granularity='monthly'`, pour ne pas se mélanger avec
|
|
la collecte journalière (qui prime dessus dans les agrégations dès qu'elle a
|
|
des données pour un mois donné). Les deux sont ré-exécutables sans créer de
|
|
doublons.
|
|
|
|
## 5. Collecte quotidienne (cron)
|
|
|
|
`collect.py` récupère le coût de **J-1** (les données du jour même sont
|
|
rarement finalisées côté AWS/Azure) pour chaque tenant, le convertit en
|
|
EUR (taux figé au moment de la collecte), et l'écrit en base avec
|
|
`granularity='daily'`.
|
|
|
|
**Test manuel :**
|
|
```bash
|
|
cd collector
|
|
python collect.py --config ../config.json --db ../cost_dashboard.db
|
|
```
|
|
|
|
**Rattraper une journée précise :**
|
|
```bash
|
|
python collect.py --config ../config.json --db ../cost_dashboard.db --date 2026-07-28
|
|
```
|
|
|
|
Un échec sur un tenant (token expiré, permissions...) n'empêche pas la
|
|
collecte des autres — journalisé en base (`status='error'`) et affiché
|
|
dans le dashboard plutôt que de laisser un trou silencieux.
|
|
|
|
Juste après, pour chaque tenant collecté avec succès, `collect.py` récupère
|
|
aussi le **détail par ressource** (bucket S3, instance EC2, VM Azure...) sur
|
|
une fenêtre glissante de **14 jours** — c'est le maximum que fournit AWS Cost
|
|
Explorer pour ce niveau de détail (limite dure de l'API, pas de notre code),
|
|
gardé identique côté Azure par cohérence. Alimente le panneau qui s'ouvre au
|
|
clic sur un service dans le dashboard (§6). Côté AWS, ça suppose d'avoir
|
|
activé **"Resource IDs"** dans les préférences Cost Explorer du compte — si
|
|
ce n'est pas fait, la collecte du détail échoue proprement pour ce tenant
|
|
(`resource_collection_status.status = 'unavailable'`) sans bloquer le reste,
|
|
et le dashboard l'affiche avec un message explicite plutôt qu'un vide.
|
|
|
|
**En dehors de Docker/Kubernetes**, installation en crontab classique :
|
|
```
|
|
0 6 * * * cd /opt/simple-cost-dashboard/collector && /opt/simple-cost-dashboard/venv/bin/python collect.py --config ../config.json --db ../cost_dashboard.db >> ../cron.log 2>&1
|
|
```
|
|
(Voir §7 pour l'équivalent en CronJob Kubernetes.)
|
|
|
|
## 6. Lancer le dashboard web
|
|
|
|
```bash
|
|
cd api
|
|
uvicorn main:app --reload --port 8000
|
|
```
|
|
|
|
Puis ouvre `http://localhost:8000`.
|
|
|
|
- `/` — vue globale : tous les tenants, coûts mensuels sur 12 mois glissants
|
|
- `/tenant/<aws|azure>/<nom>` — détail d'un tenant : graph 7j/30j avec
|
|
comparaison à la période précédente, répartition par service (30
|
|
derniers jours), total depuis le 1er janvier. Cliquer sur un service (dans
|
|
le graphe ou le tableau) ouvre un panneau avec le détail par ressource
|
|
(bucket, instance, VM...) sur les 14 derniers jours — voir §5 pour les
|
|
limites côté AWS (fenêtre 14j, "Resource IDs" à activer).
|
|
- `/health` — endpoint de liveness/readiness (utilisé par Kubernetes)
|
|
|
|
## 7. Docker
|
|
|
|
**Build :**
|
|
```bash
|
|
docker build -t simple-cost-dashboard:latest .
|
|
```
|
|
|
|
**Lancer le dashboard web :**
|
|
```bash
|
|
docker run -d \
|
|
--name cost-dashboard \
|
|
-p 8000:8000 \
|
|
-v $(pwd)/data:/data \
|
|
-v $(pwd)/config.json:/app/config.json:ro \
|
|
simple-cost-dashboard:latest
|
|
```
|
|
La base SQLite vit dans `/data` (volume monté) pour survivre aux
|
|
redémarrages/recréations du container.
|
|
|
|
**Lancer une collecte ponctuelle (même image, commande différente) :**
|
|
```bash
|
|
docker run --rm \
|
|
-v $(pwd)/data:/data \
|
|
-v $(pwd)/config.json:/app/config.json:ro \
|
|
simple-cost-dashboard:latest \
|
|
python collector/collect.py --config /app/config.json --db /data/cost_dashboard.db
|
|
```
|
|
|
|
C'est la **même image** pour le serveur web et pour la collecte — seule la
|
|
commande change. C'est le pattern que je reprendrai pour Kubernetes :
|
|
un `Deployment` (serveur web, commande par défaut de l'image) et un
|
|
`CronJob` (collecte, commande surchargée), tous deux basés sur la même
|
|
image `simple-cost-dashboard:latest`.
|
|
|
|
## 8. Structure du projet
|
|
|
|
```
|
|
simple-cost-dashboard/
|
|
├── Dockerfile
|
|
├── .dockerignore
|
|
├── requirements.txt
|
|
├── config.json # à créer (voir §2) — non versionné
|
|
├── cost_dashboard.db # créé automatiquement (local) / dans /data (Docker)
|
|
├── db/
|
|
│ └── schema.sql # schéma de la base
|
|
├── collector/
|
|
│ ├── providers.py # accès AWS Cost Explorer / Azure Cost Mgmt + conversion EUR
|
|
│ ├── db.py # connexion SQLite + écriture des relevés
|
|
│ ├── collect.py # cron quotidien (granularité journalière + détail ressources 14j)
|
|
│ ├── backfill_historical.py # rapatrie l'historique dispo depuis AWS/Azure (voir §4)
|
|
│ ├── import_historical.py # import ponctuel d'exports JSON déjà générés
|
|
│ ├── cost_report.py # CLI ponctuel : rapport multi-tenants
|
|
│ └── tenant_detail.py # CLI ponctuel : détail d'un tenant
|
|
├── api/
|
|
│ ├── main.py # app FastAPI (routes API + pages statiques + /health)
|
|
│ └── queries.py # agrégations (vue mensuelle, YTD, comparaison, services, ressources)
|
|
├── frontend/
|
|
│ ├── index.html # page globale
|
|
│ ├── tenant.html # page détail tenant
|
|
│ └── static/
|
|
│ ├── style.css
|
|
│ ├── common.js # utilitaires partagés (formatage, config Chart.js)
|
|
│ ├── app.js # logique page globale
|
|
│ └── tenant.js # logique page détail + graphes + drawer ressources
|
|
└── deploy/ # à venir : manifestes Kubernetes (voir TODO)
|
|
```
|
|
|
|
## 9. À faire
|
|
|
|
**Déploiement Kubernetes** (`deploy/`, pas encore créé) :
|
|
- [ ] Manifeste `Deployment` pour le serveur web (readiness/liveness sur `/health`)
|
|
- [ ] Manifeste `Service` + `Ingress`
|
|
- [ ] Manifeste `CronJob` pour la collecte quotidienne, même image, commande surchargée
|
|
- [ ] `PersistentVolumeClaim` pour `/data` (la base SQLite doit survivre aux redéploiements)
|
|
- [ ] `Secret` Kubernetes pour `config.json` (credentials Azure notamment) plutôt qu'un ConfigMap en clair
|
|
|
|
**Authentification** :
|
|
- [ ] Basic Auth via l'Ingress (annotation nginx `auth-basic` + `Secret` `htpasswd`) — solution de départ validée
|
|
- [ ] Bascule vers SSO Entra ID (app d'entreprise Azure + groupe) une fois le besoin confirmé — probablement via `oauth2-proxy` en sidecar/Ingress plutôt que dans le code de l'appli, pour ne pas coupler l'auth à FastAPI
|
|
|
|
**Credentials AWS/Azure en environnement conteneurisé** :
|
|
- [x] AWS : tranché pour des clés IAM dédiées par tenant (`access_key_id`/
|
|
`secret_access_key` dans `config.json`, droit `ce:GetCostAndUsage`
|
|
uniquement) plutôt que des profils `~/.aws` — les profils SSO ne
|
|
fonctionnent pas en conteneur (le refresh du token demande un
|
|
navigateur). Voir §2. Pas encore fait : créer ces clés IAM dédiées côté
|
|
AWS pour chaque tenant (elles n'existent pas encore, seuls des profils
|
|
SSO existent aujourd'hui) — et envisager `sts:AssumeRole` depuis une
|
|
identité unique si la rotation de 28+ clés IAM statiques devient trop
|
|
lourde à gérer.
|
|
- [ ] `config.json` (les clés AWS et les `client_secret` Azure) doit passer
|
|
en `Secret` Kubernetes monté en volume une fois `deploy/` créé, pas en
|
|
ConfigMap.
|
|
|
|
**Fiabilité / observabilité** :
|
|
- [ ] Alerte (Slack/email) si un tenant est en échec de collecte plusieurs jours de suite — actuellement visible seulement en se rendant sur le dashboard
|
|
- [ ] Sauvegarde régulière de la base SQLite (le PVC seul ne protège pas d'une corruption)
|
|
- [ ] Limite de rétention ou archivage si le volume de la base grossit significativement à long terme (non urgent : le volume attendu reste faible)
|
|
|
|
**Fonctionnel** :
|
|
- [ ] Vérifier le rendu du dashboard avec de vraies données (actuellement testé uniquement sur la structure du code, pas sur un run réel bout-en-bout)
|
|
- [ ] Décider si la page globale doit aussi permettre de filtrer/rechercher un tenant si leur nombre grossit beaucoup |