01Vue d'ensemble
Un ERP SaaS “à la Odoo.sh” auto-hébergé : le service erpsaas-provisioning reçoit une commande (slug du client + pack de modules) et fabrique de bout en bout une instance Odoo dédiée, joignable en HTTPS sur un sous-domaine.
🧩 Multi-tenant isolé
Chaque client = un stack Docker séparé (Odoo + PostgreSQL + pgAdmin) avec sa propre base, ses volumes et ses ports. Aucune donnée partagée entre clients.
🌐 Passerelle unique
Nginx + Certbot sur serveur-passerelle sont le point d'entrée de tous les tenants, même ceux hébergés sur un autre serveur du pool.
⚙️ Provisioning par API
Une API interne (FastAPI, port 9000, pare-feu) orchestre placement, ports, conteneurs, base, admin et certificat. Registre dans tenants.db.
📦 Socle applicatif commun
Image odoo_saas_base:19 avec les modules maison (legeni_*, cm_*, rapports) préchargés ; modules spécifiques du client ajoutés au montage.
02Architecture globale
DNS générique → passerelle Nginx → stack du tenant sur le serveur choisi. L'API de provisioning pilote les serveurs (en local sur la passerelle, en SSH sur les autres).
%%{init:{"theme":"base","themeVariables":{"primaryColor":"#e6f3ec","primaryBorderColor":"#178f4e","primaryTextColor":"#16201a","lineColor":"#8aa596","fontfamily":"monospace"}}}%%
flowchart TB
U([Client / Navigateur]):::ext
U -->|https://client.legeniconsulting.cm| DNS[["DNS wildcard
*.legeniconsulting.cm → passerelle"]]
DNS --> NG{{"Nginx + Certbot
Passerelle unique · passerelle"}}:::gw
NG -->|proxy_pass serveur:port| S1
NG -->|proxy_pass serveur:port| S2
NG -->|proxy_pass serveur:port| S3
subgraph POOL["Pool de serveurs (placement selon RAM libre)"]
direction LR
subgraph M1["master1 · passerelle (local)"]
API["API Provisioning
FastAPI :9000 (pare-feu)"]:::api
REG[("tenants.db
SQLite")]:::db
S1["Stack tenant A
odoo_saas_A · pg · pgadmin"]:::ten
end
subgraph W1["worker1 · worker1"]
S2["Stack tenant B"]:::ten
end
subgraph M2["master2 · master2"]
S3["Stack tenant C"]:::ten
end
end
API --- REG
API -.commande locale / SSH.-> M1
API -.SSH.-> W1
API -.SSH.-> M2
IMG["Image odoo_saas_base:19
+ common_addons"]:::img -.->|base commune| S1 & S2 & S3
classDef gw fill:#178f4e,stroke:#0d6a39,color:#fff;
classDef api fill:#dbeafe,stroke:#0e7490,color:#0b3a45;
classDef db fill:#fff5da,stroke:#b9820b,color:#5a3f00;
classDef ten fill:#eef7f1,stroke:#178f4e,color:#16201a;
classDef img fill:#f0eefb,stroke:#6d5ce0,color:#2c2560;
classDef ext fill:#fff,stroke:#8a988e,color:#16201a;
Fig. 1 — La passerelle route chaque sous-domaine vers le serveur:port du tenant, où qu'il soit hébergé.
03Composants
| Composant | Rôle | Emplacement |
|---|---|---|
| API de provisioning | Orchestration : créer / suspendre / reprendre / purger un tenant. FastAPI servi par uvicorn, service systemd erpsaas-provisioning. Non exposé à Internet (règle iptables erpsaas-fw). | passerelle:9000 (interne) app/main.py |
| Registre des tenants | Source de vérité : slug, sous-domaine, serveur cible, ports, statut, dates. | tenants.db (SQLite) |
| Pool de serveurs | Cibles de placement : master1 (la passerelle, local), worker1, master2. Choix du serveur avec le plus de RAM libre, au-dessus du seuil. | config.py SERVER_POOL |
| Gabarits Jinja2 | Génèrent docker-compose.yml, odoo.conf, vhost Nginx (HTTP puis HTTPS) pour chaque tenant. | app/templates/ |
| Image de base | Odoo 19 + common_addons (modules maison) préchargés, construite depuis le Dockerfile dédié. | odoo_saas_base:19 Dockerfile.odoo19-saas |
| Passerelle Nginx + Certbot | Terminaison TLS et reverse-proxy pour tous les sous-domaines. Certificats Let's Encrypt (webroot). | master1 (passerelle) |
| Sauvegardes | Dump périodique par tenant. | scripts/run_backups.py backup_tenant.sh |
| Garde-fou ressources | Surveillance RAM / disque des 5 serveurs, alertes e-mail si seuils franchis ; refuse le provisioning sous 1536 Mo de RAM libre. | scripts/resource_guard.py |
04Serveurs, placement & conventions
Pool de placement
| Serveur | IP | Rôle |
|---|---|---|
| master1 | serveur-passerelle | Local · Nginx/Certbot · API |
| worker1 | worker1 | Hôte de tenants (SSH) |
| master2 | master2 | Hôte de tenants (SSH, ufw) |
Placement = serveur avec le plus de RAM libre ≥ 1536 Mo. En dessous, le provisioning est refusé.
Plages de ports (par hôte)
| Service | Plage |
|---|---|
| Odoo (web) | 9100 – 9499 |
| PostgreSQL | 9500 – 9799 |
| pgAdmin | 9800 – 9899 |
L'API attribue le prochain port libre de chaque plage (vérifié avec ss -tln).
Anatomie d'un stack tenant
# /root/erpsaas_clients/<slug>/ docker-compose.yml # odoo_saas_<slug> + postgres + pgadmin odoo_config/odoo.conf # addons_path = /mnt/extra-addons,/mnt/common-addons modules/ # modules spécifiques du client (bind → /mnt/extra-addons) odoo_data/ # filestore + sessions (chown 100:101 !) postgresql/ # données PostgreSQL
05Cycle de vie d'un tenant
Le statut vit dans tenants.db et pilote l'affichage côté client.
%%{init:{"theme":"base","themeVariables":{"primaryColor":"#eef7f1","primaryBorderColor":"#178f4e","lineColor":"#8aa596","primaryTextColor":"#16201a","fontFamily":"monospace"}}}%%
stateDiagram-v2
[*] --> provisioning: POST /tenants
provisioning --> active: déploiement OK
provisioning --> failed: erreur (rollback statut)
active --> suspended: /suspend (compose stop)
suspended --> active: /resume (compose start)
active --> purged: DELETE (compose down -v + nettoyage)
failed --> purged: DELETE
purged --> [*]
06Flux de déploiement actuel
Ce que fait create_tenant(), dans l'ordre. C'est aujourd'hui synchrone : l'appel rend la main une fois l'instance prête, avec l'URL et les identifiants admin.
%%{init:{"theme":"base","themeVariables":{"primaryColor":"#e6f3ec","primaryBorderColor":"#178f4e","lineColor":"#8aa596","actorBkg":"#178f4e","actorTextColor":"#fff","fontFamily":"monospace"}}}%%
sequenceDiagram
autonumber
participant C as Commande (client/admin)
participant API as API Provisioning
participant S as Serveur cible
participant N as Nginx/Certbot (passerelle)
C->>API: POST /tenants {slug, modules, email}
API->>API: valider slug · choisir serveur (RAM) · allouer ports
API->>API: registre → statut « provisioning »
API->>S: rendre compose+conf · docker compose up
API->>S: attendre Odoo (/web/login = 200/303)
API->>S: init base -i base,modules --without-demo
API->>S: définir login + mot de passe admin du client
API->>N: vhost HTTP · certbot (Let's Encrypt) · vhost HTTPS · reload
API->>API: registre → statut « active »
API-->>C: url + admin_email + admin_password
Fig. 3 — En cas d'erreur, le statut passe à « failed » et l'exception remonte.
07Workflow proposé — « choix du service → lien livré »
Objectif : après que le client a choisi son service, savoir quand le déploiement est prêt, puis afficher le lien et/ou l'envoyer par e-mail avec ses identifiants. Exactement ce qui a été simulé avec ecolelesgenies.
%%{init:{"theme":"base","themeVariables":{"primaryColor":"#e6f3ec","primaryBorderColor":"#178f4e","lineColor":"#8aa596","primaryTextColor":"#16201a","fontFamily":"monospace"}}}%%
flowchart LR
A["1 · Client choisit
un pack de service"]:::step --> B["2 · Commande
POST /tenants (async)"]:::step
B --> P{{"3 · Job de provisioning
en tâche de fond"}}:::gw
P --> ST{"Statut
(GET /tenants/slug)"}:::step
ST -->|provisioning| W["Page d'attente
+ progression / polling"]:::wait
W -.re-vérifie.-> ST
ST -->|active ✔| R["4 · PRÊT"]:::ok
ST -->|failed ✖| F["Échec → alerte
+ relance possible"]:::err
R --> L["5a · Afficher le lien
https://client…"]:::ok
R --> M["5b · E-mail au client :
lien + identifiants"]:::ok
classDef step fill:#eef7f1,stroke:#178f4e,color:#16201a;
classDef gw fill:#178f4e,stroke:#0d6a39,color:#fff;
classDef wait fill:#dbeafe,stroke:#0e7490,color:#0b3a45;
classDef ok fill:#dff3e6,stroke:#178f4e,color:#0d6a39;
classDef err fill:#fbe4e0,stroke:#c0392b,color:#7a231a;
Ce qui existe déjà ✔
- Création complète d'instance (
POST /tenants) - Statut par tenant (
GET /tenants/{slug}) : provisioning → active - Retour de l'URL + identifiants admin
- Placement, ports, TLS, admin — automatisés
Ce qu'il reste à ajouter ➕
- Asynchrone : lancer le déploiement en tâche de fond (le client n'attend pas la requête)
- Étapes de statut plus fines (ex.
en_attente → conteneurs → base → certificat → prêt) - Notification e-mail à
active: lien + identifiants (SMTP) - Page/portail client : choix du pack + suivi en temps réel
Séquence “prêt → lien” à implémenter
- Le client valide son pack de service (modules) sur un formulaire → appel
POST /tenantsqui répond immédiatement provisioning et lance le job en fond. - La page de suivi interroge
GET /tenants/{slug}toutes les quelques secondes et affiche l'avancement (tableau d'état type ecolelesgenies : DNS, conteneur, base, TLS). - Dès le passage à active, un hook déclenche l'envoi e-mail (lien
https://…+ login + mot de passe temporaire) et affiche le bouton « Ouvrir mon espace ». - En cas de failed, la page montre l'erreur et propose Relancer ; une alerte part vers l'admin.
create_tenant(), juste après status = "active", appeler une fonction notify_ready(tenant, url, admin_email, admin_password) qui envoie l'e-mail. Le resource_guard montre qu'un canal e-mail (alertes) existe déjà — on réutilise la même configuration SMTP.08Exemple concret — ecolelesgenies
Un tenant réel, provisionné par ce système, tel qu'il apparaît dans le registre et sur la passerelle.
| Élément | Valeur | État |
|---|---|---|
| Sous-domaine | ecolelesgenies.legeniconsulting.cm | active |
| Serveur cible | master1 (serveur-passerelle) | — |
| Port Odoo | 9107 → 8069 | up |
| Conteneur | odoo_saas_ecolelesgenies | running |
| DNS | → serveur-passerelle (wildcard) | résolu |
| Certificat TLS | Let's Encrypt · exp. 30/10/2026 | valide |
| Page de login | HTTPS 200 | OK |
API — endpoints de référence
| Méthode | Endpoint | Action |
|---|---|---|
| POST | /tenants | Créer un tenant (slug, sous-domaine, e-mail admin, modules) |
| GET | /tenants | Lister tous les tenants |
| GET | /tenants/{slug} | Détail + statut (base du suivi « prêt ? ») |
| POST | /tenants/{slug}/suspend | Suspendre (arrêt des conteneurs) |
| POST | /tenants/{slug}/resume | Réactiver |
| DELETE | /tenants/{slug} | Purger (avec confirmation du slug) |