erpsaas — Architecture de la plateforme Odoo SaaS
Plateforme · legeniconsulting.cm

erpsaas — Odoo en mode SaaS multi-serveurs

Chaque client obtient sa propre instance Odoo isolée (base, conteneurs, sous-domaine, certificat), provisionnée automatiquement par une API. Voici comment la plateforme fonctionne — et le workflow proposé pour livrer le lien au client dès que son déploiement est prêt.

1 passerelle Nginx (passerelle) 3 serveurs dans le pool 1 conteneur ≠ 1 client · isolation totale API FastAPI · registre SQLite Image odoo_saas_base:19

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

ComposantRôleEmplacement
API de provisioningOrchestration : 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 tenantsSource de vérité : slug, sous-domaine, serveur cible, ports, statut, dates.tenants.db
(SQLite)
Pool de serveursCibles 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 Jinja2Génèrent docker-compose.yml, odoo.conf, vhost Nginx (HTTP puis HTTPS) pour chaque tenant.app/templates/
Image de baseOdoo 19 + common_addons (modules maison) préchargés, construite depuis le Dockerfile dédié.odoo_saas_base:19
Dockerfile.odoo19-saas
Passerelle Nginx + CertbotTerminaison TLS et reverse-proxy pour tous les sous-domaines. Certificats Let's Encrypt (webroot).master1 (passerelle)
SauvegardesDump périodique par tenant.scripts/run_backups.py
backup_tenant.sh
Garde-fou ressourcesSurveillance 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

ServeurIPRôle
master1serveur-passerelleLocal · Nginx/Certbot · API
worker1worker1Hôte de tenants (SSH)
master2master2Hô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)

ServicePlage
Odoo (web)9100 – 9499
PostgreSQL9500 – 9799
pgAdmin9800 – 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.

provisioning active suspended failed purged
%%{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}) : provisioningactive
  • 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

  1. Le client valide son pack de service (modules) sur un formulaire → appel POST /tenants qui répond immédiatement provisioning et lance le job en fond.
  2. 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).
  3. 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 ».
  4. En cas de failed, la page montre l'erreur et propose Relancer ; une alerte part vers l'admin.
Point d'ancrage e-mail : à la fin de 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émentValeurÉtat
Sous-domaineecolelesgenies.legeniconsulting.cmactive
Serveur ciblemaster1 (serveur-passerelle)
Port Odoo9107 → 8069up
Conteneurodoo_saas_ecolelesgeniesrunning
DNS→ serveur-passerelle (wildcard)résolu
Certificat TLSLet's Encrypt · exp. 30/10/2026valide
Page de loginHTTPS 200OK

API — endpoints de référence

MéthodeEndpointAction
POST/tenantsCréer un tenant (slug, sous-domaine, e-mail admin, modules)
GET/tenantsLister tous les tenants
GET/tenants/{slug}Détail + statut (base du suivi « prêt ? »)
POST/tenants/{slug}/suspendSuspendre (arrêt des conteneurs)
POST/tenants/{slug}/resumeRéactiver
DELETE/tenants/{slug}Purger (avec confirmation du slug)