Architecture¶
Comprendre comment Repod est structuré, et pourquoi il est conçu ainsi, vous aide à raisonner sur ses garanties de sécurité, ses limites opérationnelles et ses modes de défaillance. Cette page explique les décisions de conception derrière la stack — non pas comment l'exploiter, mais pourquoi elle fonctionne de cette manière.
Un backend unique, agnostique du format¶
Repod est un backend FastAPI unique, une base PostgreSQL unique et un frontend React
unique — il n'existe pas de scission « édition APT » vs « édition RPM ». La variable
d'environnement REPO_FORMAT (lue une seule fois au démarrage par
services/format_router.py) contrôle quel(s) format(s) de paquet le backend gère :
REPO_FORMAT |
.deb (reprepro) |
.rpm (createrepo_c) |
.apk (apk index) |
|---|---|---|---|
apt (par défaut) |
✅ | ||
rpm |
✅ | ||
apk |
✅ | ||
both |
✅ | ✅ | |
all |
✅ | ✅ | ✅ |
Tous les validateurs, gestionnaires de distribution et importateurs sont
dispatchés au runtime via les helpers is_apt() / is_rpm() / is_apk() dans
format_router.py. Le pipeline d'upload, le modèle RBAC, le journal d'audit,
l'export SBOM et le workflow CVE sont identiques quels que soient les formats actifs.
Community vs Enterprise¶
Repod est distribué en deux éditions qui partagent le même code source et la même topologie de conteneurs — il n'existe pas de « build Enterprise » séparé à installer.
Community Edition (AGPL-3.0) inclut : - Pipeline d'upload complet (scan antivirus, analyse CVE, signature GPG, vérification des dépendances) - Gestion des paquets (upload, import depuis un dépôt amont, suppression, gestion des distributions) - Authentification JWT + token API avec RBAC à 5 rôles - Journal d'audit (JSONL en append-only) - Authentification LDAP / Active Directory - Tableau de bord et endpoints de santé
Enterprise Edition ajoute, en plus de la Community :
- File de revue CVE avec workflow d'approbation RSSI (enrichissement EPSS + CISA KEV)
- Export SBOM au format CycloneDX 1.5 et SPDX 2.3
- Authentification SSO/OIDC, tokens API pour CI/CD
- Politique CVE configurable par sévérité (block / review / warn / allow)
- Suivi de SLA et alertes SLA automatisées
- Synchronisation de sécurité quotidienne automatisée, sauvegardes planifiées avec auto-vérification
- Snapshots multi-versions historiques
- Politiques de rétention des paquets, cache amont pour sites air-gap
- API de paramètres avancés (webhook, notifications SMTP, LDAP, rétention, politique CVE)
- Endpoint de rapport de sécurité pour preuves d'audit NIS2 / ISO 27001
- Formats de paquets Maven, PyPI, npm et registre de conteneurs OCI
- Scan de profils de conformité CIS/STIG et détection de dérive de configuration
- Contrôle d'accès par distribution et par machine, filtres de contenu
- Tableau de bord exécutif (tendances d'exposition CVE à l'échelle du parc)
- Inventaire et scan de machines via SSH, jobs d'installation à distance
- Haute disponibilité multi-réplique / actif-passif
Basculer d'édition consiste simplement à activer une clé de licence Enterprise signée — la surface d'API et la topologie de conteneurs sont identiques dans les deux cas.
Vue d'ensemble¶
Repod est construit sur trois principes : la sécurité dès la conception, la séparation des responsabilités, et le privilège minimal.
Le principe de séparation des responsabilités est visible dans le découpage des
conteneurs. Les serveurs de dépôt (depot-apt, depot-rpm) sont de purs
instances Nginx qui servent des fichiers statiques — ils ne savent rien des
utilisateurs, de l'authentification, ni de la validité d'un paquet. Le backend API
(backend-api) gère toute la logique métier : validation, indexation, RBAC,
journal d'audit, planification. Le frontend (frontend-ui) est une application
React compilée livrée par son propre processus Nginx, sans code côté serveur.
PostgreSQL (db) détient tout l'état relationnel. Chaque conteneur a une
responsabilité unique et bien définie, et aucun ne peut se substituer à un autre.
Le principe de sécurité dès la conception signifie que le modèle de menace a été
pris au sérieux dès la phase de conception. La conséquence la plus visible est
l'absence du socket Docker. Le backend ne monte jamais /var/run/docker.sock.
Il invoque à la place reprepro/createrepo_c directement contre un volume
partagé — services/reprepro.py pour APT (add_package()/remove_package()),
add-rpm.sh pour RPM — et les opérations GPG utilisent un volume partagé
/repos/gnupg. Le backend ne parle jamais au daemon Docker.
Une troisième décision structurelle est que les serveurs de dépôt sont
délibérément « bêtes ». depot-apt sert dists/, pool/ et apk/ ;
depot-rpm sert les arborescences gérées par createrepo_c. Aucun des deux
n'a de logique applicative, de contenu dynamique ou de credentials. Un client
qui atteint ces ports obtient exactement l'expérience qu'il obtiendrait d'un
miroir public — car c'est exactement ce que sont ces serveurs. L'intelligence
réside entièrement dans le backend.
Diagramme des composants¶
graph TD
Browser["Browser / curl"] -->|":3003 (HTTP)"| Frontend["frontend-ui\nNginx + React SPA"]
Browser -->|":80 (APT/APK)"| AptRepo["depot-apt\nNginx — static repo"]
Browser -->|":8080 (RPM)"| RpmRepo["depot-rpm\nNginx — static repo"]
Frontend -->|"REST API :8000"| Backend["backend-api\nFastAPI (Python)"]
AptCli["apt / apk"] -->|":80"| AptRepo
RpmCli["dnf / zypper"] -->|":8080"| RpmRepo
Backend -->|"clamscan subprocess"| ClamAV["ClamAV\n(in-container binary)"]
Backend -->|"grype subprocess"| Grype["Grype\n(in-container binary)"]
Backend -->|"gpg subprocess"| GnupgVol[("/repos/gnupg\nShared GPG keyring")]
Backend -->|"SQL (SQLAlchemy Core)"| PG[("PostgreSQL 16\nusers, manifests index,\ninventory, ssh_known_hosts")]
Backend -->|"JSON / JSONL"| ReposVol[("/repos/\nPackage artifacts, manifests,\naudit logs, security caches")]
AptRepo -->|"read-only"| PoolDists["pool/ + dists/ + apk/\n(served over HTTP)"]
RpmRepo -->|"read-only"| RpmTrees["rpm/<distro>/<arch>/\n(served over HTTP)"]
Backend -->|"read/write"| PoolDists
Backend -->|"read/write"| RpmTrees
Backend -->|"reprepro.add_package() / apk index"| AptRepo
Backend -->|"add-rpm.sh (createrepo_c)"| RpmTrees
GnupgVol -.->|"shared volume"| AptRepo
GnupgVol -.->|"shared volume"| RpmRepo
style ClamAV fill:#f9f,stroke:#333
style Grype fill:#f9f,stroke:#333
style GnupgVol fill:#ffe,stroke:#999
style PG fill:#ffe,stroke:#999
style ReposVol fill:#ffe,stroke:#999
ClamAV et Grype s'exécutent comme des invocations subprocess à l'intérieur du
conteneur backend-api — ce ne sont pas des conteneurs séparés. C'est un
compromis délibéré : cela simplifie le déploiement (pas de réseau
inter-conteneurs pour les outils de sécurité) au prix de partager le budget
CPU/mémoire du backend. Les limites de ressources dans docker-compose.yaml
(2,5 Go de RAM, 1,5 CPU) reflètent cela — clamd seul a besoin d'environ
800 Mo pour charger sa base de signatures.
Découpage des conteneurs¶
Le docker-compose.yaml par défaut est livré avec REPO_FORMAT=all et démarre
cinq conteneurs :
| Conteneur | Image | Port par défaut | Rôle | Montages clés |
|---|---|---|---|---|
repod-db |
postgres:16-alpine |
(interne uniquement) | Base de données applicative | Volume postgres_data |
depot-apt |
Custom (Nginx) | :80 |
Sert les dépôts .deb (APT) et .apk (Alpine) |
/repos/dists, /repos/pool, /repos/apk, /repos/gnupg, /repos/logs |
depot-rpm |
Custom (Nginx) | :8080 |
Sert les dépôts .rpm |
/repos/rpm, /repos/gnupg, /repos/logs |
backend-api |
Custom (Python 3.12 + FastAPI) | :8000 |
Toute la logique métier : pipeline d'upload, revue CVE, RBAC, audit, scheduler | /repos/*, /repos/gnupg, /var/lib/clamav, /repos/grype-db, clé SSH pour l'inventaire |
frontend-ui |
Custom (Node build + Nginx) | :3003 |
Sert la SPA React compilée, proxy /api/ vers backend-api |
Aucun (intégré à l'image au moment du build) |
Si REPO_FORMAT est défini sur apt, rpm ou apk, le conteneur de dépôt
inutilisé (depot-rpm ou les volumes spécifiques .apk sur depot-apt) est
simplement inutilisé — vous pouvez le retirer de docker-compose.yaml ou le
laisser tourner inutilisé.
Le frontend est entièrement sans état à l'exécution. Sa configuration —
REACT_APP_API_URL et REACT_APP_REPO_URL — est intégrée au moment du build
Docker. REACT_APP_API_URL doit être vide pour que tous les appels
/api/v1/... restent relatifs et soient proxyfiés par le Nginx du frontend
vers backend-api.
Flux de données : chemin d'upload¶
Cette séquence décrit ce qui se passe depuis le moment où un utilisateur ou un pipeline CI/CD envoie un paquet à l'API jusqu'à ce qu'il soit disponible pour les clients, quel que soit le format.
- Le client envoie une requête multipart
POST /upload/(ou/upload/streampour le SSE) avec le fichier du paquet et une distribution cible (ex.jammy,almalinux9,alpine3.20). Un JWT ou un token API est requis ; le rôle doit êtreuploader,maintainerouadmin. - Le backend écrit le fichier dans
/repos/staging/incoming/— une zone de dépôt temporaire jamais servie en HTTP. - Le pipeline de validation en 6 étapes s'exécute de manière synchrone via
asyncio.to_thread()(voir Le pipeline de sécurité pour le détail). Le pipeline lit le fichier depuis le staging mais ne le modifie jamais. - Si la validation échoue (erreur de format, incohérence SHA-256, virus
ClamAV, ou politique CVE bloquante) : le fichier est déplacé vers
/repos/staging/quarantine/, un événementFAILUREest écrit dans le journal d'audit JSONL, et l'API retourne422avec le résultat détaillé étape par étape. Le paquet est inaccessible aux clients. - Si la validation passe mais qu'une CVE déclenche une politique de
revue : le fichier est déplacé vers
/repos/pool/, un manifeste est généré à/repos/manifests/<name>_<version>_<arch>.manifest.jsonavecstatus: pending_review, et le paquet est ajouté à l'index central (/repos/manifests/index.json, également reflété dans PostgreSQL) mais pas promu dans l'arborescence du dépôt. Il est stocké mais non installable. - Si la validation passe sans réserve : le backend appelle l'outil de
dépôt spécifique au format —
- APT :
reprepro includedeb <distribution> <path>(viaservices/reprepro.py:add_package()), mettant à jourdists/<distribution>/et re-signantInRelease - RPM :
createrepo_c --update <distrib>/<arch>/(viaadd-rpm.sh), régénérantrepodata/et signantrepomd.xml - APK : reconstruction de
APKINDEX.tar.gzpourapk/<distrib>/main/<arch>/et signature
- APT :
Le manifeste est mis à jour à status: indexed.
7. Le journal d'audit enregistre un événement UPLOAD / SUCCESS avec le hash
SHA-256, le nom d'utilisateur de l'uploader, et les résultats complets de
validation étape par étape intégrés dans l'entrée.
Flux de données : chemin d'installation client¶
- Le client lit
/etc/apt/sources.list.d/repod.list, pointant vershttp://<host>:80 <distribution> main. apt updaterécupèredists/<distribution>/InReleaseet vérifie sa signature GPG contre la clé de confiance. Si la vérification échoue, APT refuse le dépôt.depot-aptn'a aucun rôle au-delà de servir le fichier.- APT parse
Packages.gz. Seuls les paquets avecstatus: indexedexistent dans cette arborescence ; les paquetspending_reviewouquarantinedsont invisibles. apt install <package>télécharge les fichiers.debdepuispool/et vérifie leur SHA-256 contrePackages.gz— comportement APT standard.
- Le client lit
/etc/yum.repos.d/repod.repo, pointant vershttp://<host>:8080/repos/<distribution>/<arch>/. dnf/zypperrécupèrerepodata/repomd.xmlet vérifie sa signature GPG (gpgcheck=1).depot-rpmne fait que servir les fichiers.- La liste des paquets et les métadonnées de dépendances proviennent de
repodata/, générée parcreaterepo_c. Seuls les paquets indexés apparaissent. dnf install <package>télécharge les fichiers.rpmet vérifie les checksums depuisrepodata/— comportement DNF/Zypper standard.
- Le client a
http://<host>:80/apk/<distrib>/maindans/etc/apk/repositorieset la clé de signature dans/etc/apk/keys/. apk updaterécupèreAPKINDEX.tar.gzet vérifie sa signature embarquée contre la clé de confiance.apk add <package>télécharge les fichiers.apket vérifie les checksums enregistrés dansAPKINDEX— comportement apk-tools standard.
Organisation du stockage¶
Les données relationnelles — utilisateurs, l'index de manifestes, les clients/paquets
d'inventaire, les jobs d'installation, la recherche full-text de l'index de paquets,
et les empreintes TOFU ssh_known_hosts — vivent entièrement dans PostgreSQL
(DATABASE_URL, géré via SQLAlchemy Core + Alembic). Le système de fichiers sous
/repos/ contient tout ce qui n'est pas de la donnée relationnelle : artefacts de
paquets, manifestes JSON, arborescences de métadonnées de dépôt, le trousseau GPG,
les caches et les journaux.
/repos/
├── pool/ # Canonical package store — all uploads land here
│ └── main/<initial>/<package>/<name>_<version>_<arch>.{deb,rpm}
├── dists/ # APT index tree — managed by reprepro
│ └── <distribution>/main/binary-amd64/{Packages,Packages.gz}
├── rpm/ # RPM index trees — managed by createrepo_c
│ └── <distribution>/<arch>/repodata/
├── apk/ # Alpine index trees — managed by apk index
│ └── <distribution>/main/<arch>/APKINDEX.tar.gz
├── manifests/ # One JSON manifest per package version
│ ├── index.json # Aggregated index (atomic writes via os.replace)
│ └── <name>_<version>_<arch>.manifest.json
├── conf/ # reprepro config (distributions file)
├── db/ # reprepro internal database (APT repo metadata only)
├── audit/ # Append-only audit log, one file per day
│ └── YYYY-MM-DD.jsonl
├── gnupg/ # Shared GPG keyring (backend + depot-apt + depot-rpm)
├── staging/ # Transient area — never served over HTTP
│ ├── incoming/ # Files arrive here before validation
│ └── quarantine/ # Failed or rejected packages
├── imports/ # Packages fetched by sync/mirror jobs
├── security/ # CVE decisions and threat intelligence caches
│ ├── kev_cache.json # CISA KEV cache (TTL 24h)
│ └── epss_cache.json # EPSS scores cache (TTL 24h)
├── settings.json # Runtime config (scheduler, LDAP, sync, CVE policy)
├── grype-db/ # Grype vulnerability database cache
├── clamav-db/ # ClamAV signature database (daily.cld, main.cvd)
├── logs/ # Nginx access logs (shared with depot-* containers)
└── package-index/ # Full-text search working files
Les données PostgreSQL vivent dans le volume Docker postgres_data (voir
docker-compose.yaml), pas sous /repos/. /repos/ (partagé en RWX entre
les répliques) et le endpoint PostgreSQL doivent tous deux être accessibles
depuis chaque réplique du backend dans un déploiement multi-réplique.
La séparation entre pool//rpm//apk/ (binaires + index) et manifests/
(métadonnées) est importante. Le manifeste contient l'intégralité de
l'historique de validation — chaque résultat d'étape du pipeline, tous les
résultats CVE, l'analyse des dépendances, et l'enregistrement complet
d'intégrité SHA-256/SHA-512. La posture de sécurité de n'importe quel paquet
peut être reconstituée à partir des seuls manifestes, sans relancer le scanner.
Frontières de sécurité¶
Ce à quoi depot-apt / depot-rpm peuvent accéder : Leurs arborescences
de dépôt respectives (lecture/écriture, gérées par reprepro / createrepo_c /
apk index) et le trousseau GPG partagé (pour signer les fichiers d'index).
Ils ne peuvent pas lire les manifestes, les journaux d'audit, la base
PostgreSQL, les fichiers de staging, ni les décisions de sécurité.
Ce à quoi backend-api peut accéder : Tout ce qui se trouve sous
/repos/ et la base PostgreSQL. Il écrit dans les arborescences de dépôt en
invoquant reprepro/createrepo_c/apk index directement (APT via
services/reprepro.py, RPM via add-rpm.sh, APK via
services/distributions_apk.py) contre le volume partagé — jamais via le
daemon Docker, auquel il n'a pas accès.
Ce à quoi frontend-ui peut accéder : Rien sur le système de fichiers au
runtime. C'est un serveur de fichiers statiques. Toutes les données sont
récupérées depuis l'API backend par le navigateur de l'utilisateur.
Pourquoi la suppression du socket Docker est importante : Si le backend
montait /var/run/docker.sock, toute exploitation de path-traversal, tout
bug de désérialisation, ou toute vulnérabilité de dépendance dans le
processus FastAPI pourrait être escaladée jusqu'à un accès root complet à
l'hôte via l'API Docker. L'approche par volume partagé limite le rayon
d'impact : un backend compromis peut modifier les fichiers auxquels il a
accès via le volume, mais il ne peut pas lancer de conteneurs, exécuter des
commandes dans d'autres conteneurs, ni modifier l'hôte.
GPG via volume partagé : /repos/gnupg est monté dans le backend et dans
chaque conteneur de dépôt. Le backend signe les fichiers d'index en utilisant
gpg --homedir /repos/gnupg, et l'outillage des conteneurs de dépôt
(reprepro/createrepo_c/apk) utilise le même trousseau. La clé privée ne quitte
jamais le volume ; il n'y a ni appel API, ni saut réseau.
Réseau¶
Tous les conteneurs partagent un unique réseau bridge Docker (repod_network
dans le docker-compose.yaml fourni). Aucun trafic inter-conteneurs n'est
chiffré — c'est approprié car ils tournent sur le même hôte et le réseau
n'est pas exposé à l'extérieur. La seule exposition externe se fait via les
ports publiés.
Ports par défaut¶
| Port | Conteneur | Exposé à | Objet |
|---|---|---|---|
:80 |
depot-apt |
Configurable via BIND_HOST |
Dépôts APT + APK (HTTP en clair, contenu signé) |
:8080 |
depot-rpm |
Configurable via BIND_HOST |
Dépôts RPM (HTTP en clair, contenu signé) |
:8000 |
backend-api |
Configurable via BIND_HOST |
API REST (navigateur, CI/CD, curl) — ne jamais exposer directement en production |
:3003 |
frontend-ui |
Configurable via BIND_HOST |
Interface web |
BIND_HOST a pour valeur par défaut 0.0.0.0, qui écoute sur toutes les
interfaces. En production, derrière un reverse proxy, définissez
BIND_HOST=127.0.0.1 pour empêcher l'accès externe direct à ces ports et
laisser le proxy gérer la terminaison TLS.
Les protocoles de dépôt utilisent délibérément du HTTP en clair. L'intégrité
du contenu est garantie par la vérification de signature GPG (InRelease,
repomd.xml, APKINDEX), pas par TLS. Un attaquant en position
d'homme-du-milieu peut observer quels paquets sont téléchargés mais ne peut
pas substituer un paquet malveillant sans la clé GPG privée. TLS ajoute la
confidentialité pour la liste de téléchargement (utile dans certains modèles
de menace) mais ne renforce pas la garantie d'intégrité. Si TLS est requis
pour la conformité, terminez-le au niveau du reverse proxy — voir
docker-compose.tls.yml.
Placement du reverse proxy
Lors du placement de Repod derrière Nginx ou Caddy, configurez
TRUSTED_PROXIES dans backend.env pour y inclure la plage d'adresses
du proxy. Le backend utilise cette liste pour extraire correctement les
IP clientes depuis les en-têtes X-Forwarded-For pour la limitation de
débit et le journal d'audit. La valeur par défaut couvre 127.0.0.1 et
les plages privées RFC 1918.
Haute disponibilité (actif-passif)¶
Pour les déploiements multi-réplique, backend/services/leader_election.py
fournit une élection de leader basée sur des verrous advisory PostgreSQL,
permettant à plusieurs répliques backend-api de tourner contre la même base
de données et un /repos partagé (NFS/EFS ou équivalent). Au démarrage,
chaque réplique tente un pg_try_advisory_lock() ; la réplique qui l'obtient
devient leader, les autres restent passives. Le verrou est scopé à la
session, donc si le processus leader meurt, PostgreSQL le libère
automatiquement et une autre réplique peut l'acquérir à son prochain
redémarrage.
Seul le leader exécute les jobs cron APScheduler — scheduler_state.scheduler
reste non défini sur les répliques passives. Les endpoints qui démarrent un
job en arrière-plan suivi en mémoire (scan d'inventaire, job d'installation,
job de mirror, job de sync) sont protégés derrière une dépendance
require_leader et retournent 503 sur une réplique passive, car la
progression, l'annulation et les logs du job ne vivent que dans la mémoire
processus de la réplique qui les a créés — un client interrogeant une autre
réplique ne verrait rien.
GET /health expose checks.info.ha.is_leader/instance_id/
scheduler_active afin qu'un load balancer ou un opérateur puisse
identifier le leader actuel. Voir docker-compose.ha.yml pour un exemple
d'overlay documenté.
Sur tout dialecte de base de données non-PostgreSQL (ex. SQLite dans les
tests) ou en cas d'erreur pendant l'élection, le leadership est par défaut
True — les déploiements mono-instance et de test ne sont pas affectés par
ce mécanisme.
Haute disponibilité (actif-actif)¶
Le leadership actif-passif résout la duplication des jobs cron, mais il ne rend pas, à lui seul, les jobs en arrière-plan propres à une réplique visibles pour les autres répliques. Quatre trackers de jobs (scan d'inventaire, installation, mirror, sync) plus le flux d'événements live du dashboard (Server-Sent Events) plus le tail de logs backend en direct sont, par défaut, un état en mémoire par processus — invisible pour toute réplique autre que celle qui les a démarrés. C'est la véritable raison pour laquelle la création de job doit être protégée par le leader ci-dessus : rien dans l'exécution d'un scan ou d'une installation ne requiert réellement d'être le leader, seul le fait que son état n'a nulle part ailleurs où vivre.
Définir JOB_STATE_BACKEND=redis (et le pointer vers une instance Redis
accessible via JOB_STATE_REDIS_URL ou REDIS_URL) déplace cet état vers
Redis au lieu de la mémoire processus : la progression, les signaux
d'annulation/confirmation, les créneaux de concurrence et les logs sont tous
écrits dans des clés Redis au lieu de variables locales, de sorte que toute
réplique peut lire ou mettre à jour un job quelle que soit la réplique qui
l'a créé. Une fois qu'un flux de job est backend-vérifié comme distribué, la
protection require_leader pour créer ce type de job est levée — création,
polling, annulation et confirmation de job deviennent tous sûrs à router
vers n'importe quelle réplique.
Ceci est opt-in et désactivé par défaut (JOB_STATE_BACKEND=local), ce qui
correspond exactement au comportement actuel en mémoire — un déploiement qui
ne définit jamais cette variable ne voit aucun changement. Une fois activé,
cela couvre l'intégralité de la surface actif-actif, pas un sous-ensemble
partiel :
- Les quatre trackers de jobs — scan d'inventaire, installation, mirror, sync
- Le bus d'événements SSE du dashboard (
GET /dashboard/events) - Le flux de logs backend en direct (
GET /logs/stream)
Fail-soft, jamais une régression HA silencieuse : si JOB_STATE_BACKEND=redis
est défini mais que Redis est injoignable, chaque composant retombe sur un
comportement local uniquement, journalise ce repli bruyamment, et continue
d'appliquer la protection du leader exactement comme si aucun Redis n'était
configuré du tout — une réplique passive ne commence jamais silencieusement
à accepter la création de jobs simplement parce que le backend distribué prévu
se trouvait indisponible à ce moment. Le bus d'événements SSE et le flux de
logs n'ont pas de protection leader au départ (toute réplique accepte déjà
des abonnés), donc leur repli signifie seulement que la livraison redevient
mono-réplique, pas que quoi que ce soit commence à rejeter des requêtes.
GET /health expose checks.info.ha.job_state_backend.{scan,install,mirror,sync,sse,logs},
chacun rapportant "redis" ou "local" — le backend réellement utilisé en
ce moment, après tout repli, pas seulement l'intention configurée. Cela
permet à un opérateur de voir exactement quels flux tournent réellement en
actif-actif à un instant donné.
L'état de job actif-actif et l'élection de leader actif-passif sont complémentaires, pas alternatifs : l'élection de leader décide toujours qui exécute le scheduler et (pour tout flux pas encore backend-vérifié comme distribué) qui peut créer un nouveau job ; la couche d'état backée par Redis décide si l'état en cours de ce job est visible à l'échelle du parc une fois qu'il existe.
Mode SaaS multi-tenant¶
Repod peut fonctionner en mode multi-tenant (DEPLOYMENT_MODE=saas) où un
déploiement unique sert plusieurs organisations indépendantes, chacune avec
ses propres utilisateurs, paquets, distributions et paramètres, sans aucune
visibilité sur les données d'un autre tenant. Ce mode est orthogonal aux
mécanismes de haute disponibilité ci-dessus — l'un ou l'autre peut être
activé indépendamment, et les deux peuvent être combinés dans le même
déploiement. Il est désactivé par défaut : une installation on-premise
standard ou Community Edition tourne en mode standalone, où rien de tout
cela ne s'applique.
Résolution du tenant. Le tenant d'une requête est dérivé du sous-domaine
de son en-tête Host — acme.repod.io résout vers le tenant de slug acme.
Cette résolution se produit une fois par requête, dans le middleware, avant
l'exécution de tout handler de route, et le tenant résolu est exposé au
reste de la requête via une variable de contexte plutôt que d'être propagé
à travers chaque signature de fonction — toute fonction de service qui lit
déjà le contexte de tenant courant devient tenant-aware gratuitement, y
compris du code sans requête HTTP en cours (voir les jobs cron ci-dessous).
Un sous-domaine qui ne résout vers aucun tenant actif est rejeté avant
d'atteindre la logique applicative. Les requêtes sans sous-domaine (une IP
brute ou localhost) résolvent vers aucun tenant, ce qui est exactement à
quoi ressemble le mode standalone depuis le même chemin de code.
Isolation de la base de données : schéma par tenant. Les données relationnelles de chaque tenant vivent dans son propre schéma PostgreSQL au sein de la même base de données, plutôt que dans des bases séparées ou dans des tables partagées partitionnées par une colonne de tenant. Une fois le tenant d'une requête résolu, les connexions à la base de données pour cette requête ciblent de manière transparente le propre schéma du tenant — le code applicatif qui lit et écrit via la couche d'accès à la base de données normale n'a besoin d'aucun filtrage de tenant par requête, car la frontière de schéma fait ce travail. Un petit ensemble de données réellement cross-tenant (le registre des tenants lui-même, l'état de facturation/abonnement) vit dans un schéma partagé séparé, en dehors du schéma propre à chaque tenant.
L'isolation du système de fichiers suit le même modèle. Les artefacts de
paquets, les manifestes, les journaux d'audit et les paramètres runtime sous
/repos/ sont résolus vers des sous-répertoires scopés par tenant de la
même manière que le schéma de base de données — via le même contexte de
tenant par requête, de sorte qu'une fonction qui résout déjà un chemin comme
/repos/pool/ résout de manière transparente vers le propre sous-répertoire
de ce tenant une fois le contexte de tenant actif, sans chemin de code
séparé pour le cas SaaS.
Les jobs planifiés se déploient par tenant. Les jobs cron d'APScheduler (nettoyage de rétention, synchronisation de l'index de sécurité amont, imports de mirror) lisent une planification globale unique, mais leur travail réel porte sur des données scopées par tenant — donc en mode SaaS ils bouclent sur chaque tenant actif et s'exécutent une fois par tenant, plutôt qu'une fois globalement, en utilisant le même mécanisme de variable de contexte que le code cron utilise pour tourner sous un tenant spécifique sans requête HTTP impliquée. Le travail réellement tenant-agnostique — une sauvegarde complète de base de données, ou le téléchargement d'un index de paquets amont public dont le contenu ne diffère pas selon le tenant — s'exécute une seule fois quel que soit le nombre de tenants ; seule l'application de la politique par tenant (quelles CVE signaler, quels paquets retenir) est répétée par tenant.
Un tenant dont l'abonnement expire n'est pas supprimé : ses données restent isolées et intactes, mais l'API est restreinte aux endpoints d'authentification, de facturation et de santé jusqu'à résolution de l'abonnement.