Installation durcie pour la production bancaire
Ce guide décrit comment le Manager installe les services en production bancaire. Décision d'architecture : ADR-0171.
Principes
- Les quatre méthodes restent :
binary,podman,pythonetpackage(répartition au moment de la décision dans l'ADR-0171). Les services ne sont pas tous conteneurisés : chaque méthode est durcie à sa façon. - L'installation reste hors ligne : tout vient du miroir / Trusted Hub.
- systemd reste le pilote unique :
navin-<service>-<composant>.service, quelle que soit la méthode. - Les services existants du client (PostgreSQL, Redis, Keycloak) restent connectés, jamais réinstallés.
1. Réseau des conteneurs
Chaque composant Podman déclare sa politique réseau dans le catalogue (run.podman.network) :
| Politique | Effet | Condition |
|---|---|---|
isolated (défaut) | Réseau bridge propre au service, navin-<cluster>-<service>, créé sur l'hôte (podman network create --ignore, ou fichier Quadlet .network) : deux services ne partagent jamais un réseau, un flux entre services passe toujours par un port déclaré | Aucune |
host | Réseau de l'hôte (--network host) | network_justification obligatoire : le besoin technique de ce composant, sinon le catalogue est refusé au chargement |
none | Aucun réseau | Aucune |
Ports d'un composant isolé (ports du catalogue, port de l'hôte = port du conteneur) :
| Déclaration | Publication | Qui l'atteint |
|---|---|---|
public: true | --publish p:p/proto, toutes les adresses de l'hôte | les hôtes autorisés par le pare-feu (firewall.open) |
public: false | --publish 127.0.0.1:p:p/proto | l'agent et les processus de l'hôte, aucun autre hôte |
publish: false | jamais publié | les seuls conteneurs du même service, par leur nom |
list_param et field | un port par entrée de la liste (frontaux HAProxy), selon public | selon public |
modes | publié dans ces modes seulement (ports ringpop du mode cluster de Temporal) | selon public |
Règles vérifiées au chargement du catalogue :
--network,--net,--publish,-p,--exposesont interdits dansextra: le réseau passe uniquement parnetworketports;- les ports publiés restent filtrés par le pare-feu : seuls les ports
public: truesont ouverts (firewall.open).
Les modèles du catalogue écrivent leurs adresses avec net du contexte de rendu, qui ne change rien pour les
méthodes binary, python et package ni pour le réseau de l'hôte :
| Aide | Réseau isolé | Autres cas |
|---|---|---|
net.listen(adresse) | adresse non spécifiée (toutes les interfaces du conteneur) | adresse |
net.local(adresse) | adresse non spécifiée, port publié sur 127.0.0.1 seulement | adresse (127.0.0.1 par défaut) |
net.peer(composant, adresse) | nom du conteneur navin-<service>-<composant> sur le réseau du service | adresse (127.0.0.1 par défaut) |
net.check(adresse) | 127.0.0.1 (contrôle par l'agent d'un port non public) | adresse |
L'adresse annoncée aux pairs et aux clients reste celle de l'hôte (host.ip, host.fqdn) : les autres hôtes
atteignent le port publié.
Politique réseau par service
| Politique | Services | Raison |
|---|---|---|
isolated | Tous les autres composants Podman du catalogue : bases et stockages (postgresql, mysql, redis, etcd, clickhouse, opensearch, qdrant, seaweedfs, nats), passerelles (apisix, haproxy avec un port publié par frontal), observabilité (jaeger, otel-collector), keycloak, temporal (tous les rôles, dans les deux modes), presidio, opik, navin-enterprise, ollama, clamav, docling, litellm, marquez, opa, openfga, opensearch-dashboards, promptfoo, temporal-ui, label-studio (application et interface), llama-factory (GPU par CDI) et vllm hors du mode distributed | Écoute sur l'adresse non spécifiée du conteneur, adresse de l'hôte annoncée aux pairs, composants du même hôte joints par leur nom de conteneur |
host (sonde noyau) | falco, tetragon | eBPF, --privileged, --pid=host : profil spécifique justifié |
host (besoin démontré) | livekit (médias WebRTC, recommandation officielle de LiveKit), vllm en mode distributed seulement (host_network_modes : Ray et NCCL sur des ports dynamiques) | Justification propre au composant, vérifiée au chargement du catalogue |
Migrer un service vers isolated : écrire ses adresses d'écoute avec net.listen ou net.local, ses accès à un
autre composant du même hôte avec net.peer (et déclarer same_host_as sur le composant : l'installation refuse
alors un placement où l'autre composant manque sur l'hôte), ses contrôles de ports non publics avec net.check, annoncer
host.ip ou host.fqdn aux pairs, déclarer tous les ports utilisés, puis passer network: isolated et retirer la
justification. Le test tests/test_catalog_podman_policy.py vérifie qu'aucun composant n'utilise le réseau hôte
sans justification.
L'onglet Sécurité de la fiche du service montre, pour un service installé avec Podman, l'isolation de chaque conteneur telle que le catalogue la déclare : politique réseau et justification, réseau du service, ports publiés et leur portée (sur l'hôte ou sur 127.0.0.1 seulement), privilèges (aucun nouveau privilège, capacités retirées, système de fichiers en lecture seule, capacités ajoutées) et quotas. Cette vue est en lecture seule : l'isolation se change dans le catalogue, où chaque affaiblissement porte sa justification écrite.
Flux entre hôtes
Un port public que seul le cluster utilise est ouvert aux hôtes du cluster et au Manager, jamais à toutes les sources
(« communications internes autorisées explicitement »). Le Manager tire la liste de la matrice des flux
(network/flows.py, page Réseau) : un port est restreint quand chaque flux qui l'atteint sur son hôte vient d'un
hôte, d'un composant ou du Manager du cluster (transport, pairs, réplication, gossip, liaisons entre services,
Prometheus du cluster), ou quand aucun flux ne l'atteint encore et que la matrice le classe entre les nœuds du service
(service à un seul nœud). Un port atteint par clients ou par une source externe (points d'accès de provides,
https, media, bundle, ports d'un lien rapide, UDP) reste ouvert à toutes les sources.
| Pare-feu | Port restreint | Port client |
|---|---|---|
| firewalld | ipset permanent navin-<12 hex de l'id du cluster> (hash:ip, famille inet, plus <set>-v6 en inet6 pour les membres IPv6) et une règle riche source ipset=<set> port port=<p> protocol=<proto> accept par port | --add-port=<p>/<proto> |
| ufw | allow from <membre> to any port <p> proto <proto> comment <set> par membre et par port | allow <p>/<proto> |
Les membres sont les adresses des hôtes du cluster qui ne sont pas retirés et l'adresse du Manager quand
NM_AGENT_URL ou NM_PUBLIC_URL contient une IP (un Manager connu par son seul nom n'atteint aucun port restreint,
ce dont il n'a pas besoin). Chaque firewall.open porte tous les ports restreints de l'hôte : l'agent retire la règle
--add-port ou ufw allow <p>/<proto> d'un port restreint, les règles de l'ensemble qui ne correspondent plus à un
port ou à un membre, et n'ajoute rien quand l'hôte est déjà conforme (deux exécutions ne changent rien). La tâche
périodique network.firewall_members (toutes les 60 s) lance une opération network.firewall_members quand un hôte
arrive, part ou change de cluster, pour que chaque hôte du cluster reçoive le nouvel ensemble ; un rafraîchissement
en échec est retenté après 15 minutes, un cluster sous verrou de sécurité n'est pas touché. Les agents sans root et
les hôtes sans firewalld ni ufw gardent leur comportement (aucune tâche, ou backend: none). Les groupes de sécurité
du cloud ne changent pas : les règles de la page Réseau les couvrent.
2. Quadlet
catalog.render.quadlet(service, composant, ctx) produit, à partir de la même spécification que podman run :
navin-<service>-<composant>.container:Image=par digest,Pull=never,Network=,PublishPort=,Volume=,EnvironmentFile=,NoNewPrivileges=,DropCapability=,ReadOnly=,AddDevice=(CDI),SecurityLabelDisable=(GPU),PidsLimit=, quotas,LogDriver=journald;navin-<cluster>-<service>.networkpour les composants isolés (un réseau par service).
Une option rendue vide (option propre au CPU sur un hôte GPU) est retirée : une clé Quadlet vide remettrait toute sa
liste à zéro (PodmanArgs= vide effacerait les options de sécurité et les quotas déjà posés). Le point d'entrée passe
par PodmanArgs=--entrypoint= : Quadlet n'a pas de clé Entrypoint= avant Podman 5. Le fichier ne pose pas
Notify=true : la disponibilité vient de conmon, comme --sdnotify conmon de l'unité podman run ; Notify=true
attendrait un signal sd_notify que les images n'envoient pas.
Les fichiers vont dans /etc/containers/systemd/ ; le générateur Quadlet produit le même nom d'unité
navin-<service>-<composant>.service : supervision, logs et service.control ne changent pas.
Bascule
Les composants Podman passent sur Quadlet par défaut, hôte par hôte (NM_PODMAN_QUADLET, activé par défaut ; false
garde podman run partout) :
- un hôte systemd dont Podman est en version 4.9 ou plus récente et dont l'agent annonce la tâche
quadlet.unitdans ses faits (task_types, agents Python et Go) reçoitquadlet.unità la place desystemd.unit; elle écrit le.containeret le.networkdu service, désactive puis retire l'unitépodman runqu'elle remplace (une unité de/etc/systemd/systemmasquerait l'unité générée) et recharge systemd ; - les autres hôtes (Podman plus ancien ou pas encore annoncé, agent qui ne connaît pas la tâche) gardent l'unité
podman run, avec les mêmes options d'isolation : le Manager ne planifie jamais une tâche que l'agent refuserait ; - une unité générée ne s'active pas avec
systemctl enable: le Manager la démarre et la section[Install]du fichier la démarre au boot ; - les fichiers Quadlet sont suivis par la détection de dérive comme les unités systemd ; la désinstallation retire le
.containeret l'unité, le.networkdu service reste.
Un hôte déjà installé passe sur Quadlet à la prochaine application de configuration du service, une fois son agent à jour ; le conteneur tourne avec sa nouvelle unité à son prochain redémarrage.
3. Profil de sécurité par service
Champ run.podman | Option Podman | Par défaut |
|---|---|---|
| (toujours, hors privilégié) | --security-opt no-new-privileges | actif |
cap_drop_all: true | --cap-drop ALL (puis cap_add) | inactif, à activer après validation de l'image |
cap_add | --cap-add | exige privileged_justification |
read_only: true | --read-only (tmpfs /tmp, /var/tmp, /run) | inactif |
security_opt | --security-opt (seccomp, AppArmor) ; label=disable exige une justification | vide |
user | --user | image |
devices | --device nvidia.com/gpu=<index> (CDI) | aucun |
extra: [--privileged, --pid=host, ...] | namespaces de l'hôte | exige privileged_justification |
Contrôles de santé : chaque composant permanent (kind: master ou worker) déclare ses health ; le chargeur du
catalogue refuse un composant permanent sans contrôle de santé, quelle que soit sa méthode. Le Manager les exécute
après chaque démarrage, à chaque mise à niveau (hôte canari puis chaque hôte) et en continu pour la supervision.
Quotas (limits du composant), appliqués à toutes les méthodes :
| Champ | Podman | systemd (binary, python) |
|---|---|---|
cpus | --cpus | CPUQuota= |
memory | --memory | MemoryMax= |
pids | --pids-limit | TasksMax= |
Les secrets restent hors de la ligne de commande : fichier d'environnement 0640 rendu par le Manager (valeurs
issues d'OpenBao) ; avec Quadlet, Secret= remplacera ce fichier.
Serveurs MCP des tenants
Les serveurs MCP stdio des projets des tenants tournent sur les hôtes dans le service mcp-host (ADR-0171 ; sur
Kubernetes, le MCP Runtime les isole par utilisateur et par projet). Son paramètre isolation décide comment :
isolation | Chaque serveur tourne comme | Serveurs hébergés |
|---|---|---|
process (par défaut) | un processus enfant de l'utilisateur de l'hôte (dossiers 0700, environnement propre, rlimits) | serveurs validés du catalogue de l'entreprise seulement |
container | un conteneur Podman sans root : un réseau par projet de tenant, racine en lecture seule, /tmp en tmpfs, --cap-drop=ALL, no-new-privileges, --userns=keep-id, quotas de mémoire et de processus, --pull=never | tous les serveurs, non fiables compris |
Un serveur est fiable quand son préréglage figure dans le réglage du Manager mcp_validated_presets
(NM_MCP_VALIDATED_PRESETS, les préréglages que l'entreprise a publiés dans son catalogue MCP) avec sa commande et
ses arguments inchangés. Tout autre serveur (autre préréglage de la bibliothèque Navin, serveur écrit par un
utilisateur) est refusé sur un mcp-host process avec 409 mcp_untrusted_needs_container, et l'environnement
d'exécution le refuse aussi. L'isolation container demande podman et des plages d'uid et de gid subordonnés pour
l'utilisateur du service (contrôles préalables podman_present et subordinate_ids ; l'agent ajoute les plages avec
usermod lors de user.ensure). Les images d'exécution node et python sont fixées par empreinte dans le
catalogue, livrées par le miroir hors ligne et chargées par l'agent dans le magasin d'images en lecture seule
/opt/navin/mcp-host/images, listé par le containers-storage.conf de l'utilisateur du service.
4. Trusted Hub
Avant installation, chaque artefact (binaire, image OCI, wheel, paquet) doit être : présent dans le manifest signé du Hub, vérifié par sha256, signé (Cosign, vérifiable hors ligne), accompagné d'un SBOM, scanné (CVE), conforme à la politique de licences (ADR-0101) et approuvé pour cette version. Le Hub est développé séparément ; le Manager refusera un artefact qui ne remplit pas ces conditions.
Avec le paramètre hub.enforce activé, le Manager vérifie chaque artefact avec le dernier document signé vérifié
(Ed25519, clés épinglées, expiration, pas de retour arrière) avant qu'aucune tâche ne s'exécute ; un refus arrête
le plan avec 409 :
| Nature | Valeur vérifiée | Refus |
|---|---|---|
Image (image.pull, image.load) | empreinte du manifeste de l'entrée du miroir | hub_package_not_approved |
Binaire, archive (artifact.fetch) | sha256:<hex> du fichier | hub_artifact_not_approved |
Wheel (python.venv) | sha256:<hex> de chaque fichier wheel du dépôt | hub_artifact_not_approved |
Une empreinte est approuvée quand elle est le digest d'une cible non révoquée ou le digest d'un de ses files,
et qu'aucune révocation ne la cite. Ce que le Manager attend du document du Hub pour les natures autres que les images
(contrat pour le Hub) : une cible {project, name, version, kind: bundle, ref, digest, service, image, files: [{name, digest: "sha256:<hex>"}]} dont files liste le sha256 de chaque binaire, archive et fichier wheel du bundle (le
digest d'un bundle OCI est l'empreinte de son manifeste, pas celle du fichier) ; une révocation peut reprendre
files pour que les fichiers révoqués restent refusés. Une empreinte de fichier mal formée rend tout le document
invalide (hub_document_invalid). Le profil de cluster banking-secure est bloqué (règle hub_enforced) tant que
hub.enforce est désactivé ou qu'aucun document valide n'est enregistré.
5. Mise à niveau et rollback
| Méthode | Durcissement |
|---|---|
binary | Répertoire immuable par version, lien current basculé ; rollback = rebascule ; signature Cosign du fichier |
python | Wheels du Hub listés avec leur sha256 dans le manifeste du miroir (wheels), versions figées, pip install --no-index --require-hashes --only-binary=:all: -r navin-requirements.txt, un venv par version {opt}/venvs/<version> ; {opt}/venv est un lien basculé par link.ensure, les chemins {opt}/venv/bin/... du catalogue restent valides et le rollback rebascule le lien. La première bascule sur un vrai répertoire {opt}/venv le renomme {opt}/venv.nm-legacy (jamais supprimé) |
package | Dépôts signés du Hub, versions figées dans le catalogue avec nom==version (dnf install nom-version, apt-get install nom=version) ; les vérifications de signature des dépôts du système ne sont jamais désactivées |
podman | Image signée par digest, Quadlet, réseau isolé |
Cycle de vie d'une mise à niveau
Chaque étape du cycle cible est portée par le Manager et prouvée par un test :
| Étape | Ce que fait le Manager | Code et preuve |
|---|---|---|
| 1. Prérequis et droits | Permission services:install, verrou de sécurité, modes et profil du cluster, approbation sous banking-secure, contrôles préalables sur chaque hôte | navin-manager/server/src/navin_manager/services/planner.py, navin-manager/server/src/navin_manager/services/profiles.py ; navin-manager/server/tests/test_lifecycle_profiles.py |
| 2. Artefact signé | Binaire, archive, wheel ou image : une cible approuvée et non révoquée du document signé du Trusted Hub quand hub.enforce est actif | navin-manager/server/src/navin_manager/hub.py, navin-manager/server/src/navin_manager/mirror.py ; navin-manager/server/tests/test_hub_client.py |
| 3. Dépendances | Liaisons exigées par la version cible liées, fournisseur installé et disponible, connexion encore présente ; refus avant toute tâche | navin-manager/server/src/navin_manager/services/upgrades.py ; test test_upgrade_checks_the_dependencies_of_the_target_release |
| 4. Sauvegarde de la configuration et de l'état | Sauvegarde d'un service à état avant la mise à niveau (upgrade.backup_before) ; version précédente (artefacts, fichiers, unités, contrôles, version de configuration) scellée avant toute tâche | navin-manager/server/src/navin_manager/services/backups.py ; test test_stateful_upgrade_backup_migration_refused_rollback_and_restore |
| 5. Installation de la version demandée | Hôte canari d'abord, puis un hôte à la fois : récupération, arrêt, migrations, fichiers et unités, démarrage ; un venv par version pour Python | tests test_stateless_upgrade_with_canary_then_rollback, test_python_upgrade_installs_a_venv_per_release_and_rollback_switches_the_link_back |
| 6. Démarrage et santé | Contrôles de santé de chaque instance après son démarrage, obligatoires pour tout composant permanent | navin-manager/server/src/navin_manager/catalog/loader.py ; test test_a_permanent_component_without_health_check_is_refused |
| 7. Validation applicative | Test de fumée sur l'hôte canari, contrôle du service, test de fumée final | navin-manager/server/src/navin_manager/services/checks.py |
| 8. Confirmation ou retour arrière | Version enregistrée quand tout passe ; en cas d'échec avec auto_rollback, le Manager rejoue aussitôt la version précédente (acteur système, audité) ; après une migration irréversible, le retour passe par la restauration de la sauvegarde | test test_failed_upgrade_with_auto_rollback_brings_the_previous_release_back |
Les tests des étapes 3 à 8 sont dans navin-manager/server/tests/test_lifecycle_upgrades.py (étape 6 :
navin-manager/server/tests/test_catalog_podman_policy.py). Quand une mise à niveau migre un schéma de base, le
rollback restaure la sauvegarde avant de revenir au binaire précédent.
6. Actions privilégiées
Tâches signées par le Manager et autorisées par rôle, journal d'audit de chaque installation, compte de service
à privilèges minimaux sur chaque hôte. La restriction aux unités navin-* est une garde, pas une barrière :
la liste blanche des tâches, le path guard de l'agent et la signature des tâches restent indispensables.
Haute disponibilité
systemd et Podman gèrent un hôte. Quorums, réplicas et mises à jour progressives entre hôtes restent orchestrés par le Manager (disponibilité).