No description
  • Python 95.2%
  • Shell 2.1%
  • Jinja 1.8%
  • Dockerfile 0.9%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-09-06 01:57:45 +02:00
docs docs(readme): rewrite README to match the completed project (T-018) 2026-09-06 01:57:14 +02:00
scripts chore(repo): scaffold operator, spec and tracking docs (T-001) 2026-09-05 19:16:28 +00:00
src/hermes_operator fix(swarm): accept 3-letter binary memory units KiB/MiB/GiB (T-017) 2026-09-06 01:55:53 +02:00
templates feat(rendering): render managed and base config layers with C-03/C-07 guards (T-004) 2026-09-05 23:17:31 +02:00
tests fix(swarm): accept 3-letter binary memory units KiB/MiB/GiB (T-017) 2026-09-06 01:55:53 +02:00
.env.example chore(packaging): operator image built and smoke-tested, group_add for docker socket (T-013) 2026-09-06 00:50:47 +02:00
.gitignore chore(repo): scaffold operator, spec and tracking docs (T-001) 2026-09-05 19:16:28 +00:00
.python-version chore(repo): scaffold operator, spec and tracking docs (T-001) 2026-09-05 19:16:28 +00:00
compose.yml chore(stack): publish operator port 8080 on the host (T-016) 2026-09-06 01:42:53 +02:00
Dockerfile fix(docker): avoid useradd/group name collision with Debian's operator group (T-013) 2026-09-05 22:02:27 +02:00
pyproject.toml chore(repo): scaffold operator, spec and tracking docs (T-001) 2026-09-05 19:16:28 +00:00
README.md docs(readme): rewrite README to match the completed project (T-018) 2026-09-06 01:57:14 +02:00
uv.lock chore(repo): scaffold operator, spec and tracking docs (T-001) 2026-09-05 19:16:28 +00:00

hermes-swarm

Opérateur HTTP d'administration d'instances Hermes Agent déployées comme services Docker Swarm sur un hôte unique, derrière Traefik.

L'opérateur est volontairement bête : il expose des primitives CRUD sur des agents, et rien d'autre. Pas de client, pas de facturation, pas de quota, pas de proxy vers les agents. Il est sans état — tout ce qu'il sait sur un agent vit dans les labels et la spec du service Swarm correspondant, jamais dans une base à lui. Un redémarrage de l'opérateur ne perd rien ; le supprimer entièrement et le redéployer ne perd rien non plus.

Comment ça marche

                         ┌─────────────┐
   client HTTP  ───────► │  opérateur  │ ── socket Docker (lecture/écriture)
  (Authorization:        │  (FastAPI)  │
   Bearer <clé>)         └─────────────┘
                                │
                                ▼
                     ┌────────────────────┐
                     │   Docker Swarm      │
                     │  1 service par agent│
                     └────────────────────┘
                        │        │        │
                     service  volume   config Docker
                     Swarm   de données  managée
                        │
                        ▼
                     ┌─────────┐        ┌─────────┐
        Traefik ───► │ agent A │        │ agent B │  ...
       (routage      └─────────┘        └─────────┘
        par domaine)

POST /agents déclenche, pour un agent, la création de :

  1. un volume Docker (hermes-<name>-data) — l'état mutable de l'agent : sessions, mémoire, son config.yaml édité ;
  2. une config Docker managée (hermes-<name>-managed-<rev>) — la politique imposée par l'opérateur, montée en lecture seule ;
  3. un service Docker Swarm (hermes-<name>, 1 réplique) qui fait tourner l'image Hermes, monte le volume et la config, et porte les labels Traefik + les labels de métadonnées de l'opérateur.

L'opérateur ne fait ensuite aucun appel réseau vers l'agent : GET /agents/{name} répond avec ce que Swarm sait (tâches, digest tourné, état), pas avec une santé applicative sondée en direct. La santé applicative se demande à l'agent lui-même via son /health public.

Les deux couches de configuration d'un agent

Chaque agent Hermes lit deux fichiers config.yaml distincts, avec des rôles et des propriétaires différents. C'est le point le plus facile à mal comprendre du projet :

Managed scope Config de base
Emplacement dans le conteneur /etc/hermes/config.yaml /opt/data/config.yaml (sur le volume)
Généré depuis templates/managed-config.yaml.j2 templates/base-config.yaml.j2
Porté par une Docker config, montée en lecture seule un vrai fichier, écrit une fois par un conteneur one-shot au premier boot
Qui peut l'éditer personne après création (immuable — une modif crée une nouvelle révision) l'utilisateur de l'agent, librement, ensuite
Rôle politique de déploiement non négociable (proxies de confiance, URL publique du dashboard, garde-fous sécurité) préférences métier de l'agent (modèle par défaut, toolsets, personnalité, skills)
Précédence gagne sur tout — le config.yaml utilisateur, son .env, l'environnement du conteneur perdant face au managed scope pour toute clé que celui-ci définit

Concrètement :

  • au premier boot d'un agent, un conteneur éphémère écrit le fichier de base dans le volume, seulement s'il est absent (pour ne jamais écraser le volume d'un agent recréé après un DELETE sans purge) ;
  • la config managée est régénérée à chaque POST /agents (et changerait si sa politique changeait) ; comme une Docker config ne se modifie jamais, une nouvelle révision est créée et le service est basculé dessus plutôt que l'ancienne éditée en place ;
  • aucune clé qui varie par agent (clé API, mot de passe dashboard, clés de provider LLM) n'est jamais placée dans le managed scope : ce serait la même valeur pour tous les agents, silencieusement. La seule exception documentée est dashboard.public_url / dashboard.trusted_proxies, qui varient par agent mais sont des invariants de topologie que l'utilisateur ne doit justement pas pouvoir changer.

Détail complet de cette règle : docs/DECISIONS.md ADR-0003 à ADR-0005.

Surface API

Toutes les routes sauf /healthz exigent Authorization: Bearer <OPERATOR_API_KEY>. Une seule clé, tous les droits, sur tous les agents — pas de rôle ni de scope.

POST   /agents                    → crée un agent, renvoie les creds, 201
GET    /agents                    → liste depuis les labels Swarm
GET    /agents/{name}             → état, image+digest, santé des tâches, URLs
GET    /agents/{name}/credentials → relit les creds depuis la spec du service
POST   /agents/{name}/credentials → rotation (coupure brève, stop-first)
POST   /agents/{name}/update      → {image} ou {digest}, stop-first + rollback auto
POST   /agents/{name}/restart     → force-update, sans changer l'image
DELETE /agents/{name}?purge=false → supprime le service ; purge=true : + volume + configs
GET    /healthz                   → liveness de l'opérateur, non authentifié

Contrat détaillé (payloads, codes d'erreur, exemples) : docs/API.md. Schéma OpenAPI figé : docs/openapi.json.

État

✅ Complet. Les tâches T-001 à T-016 sont done (T-014, CI Forgejo Actions, a été abandonnée — voir ADR-0011). Toutes les routes ci-dessus sont implémentées, la suite d'intégration tourne verte contre un Swarm réel, l'opérateur est packagé et déployé. Il ne reste qu'une vérification manuelle ouverte : V-06, le TLS de bout en bout sur un domaine public (l'hôte sert en HTTP pur, le TLS est terminé par un reverse proxy amont — ADR-0012).

Détail tâche par tâche et journal complet : docs/STATUS.md.

Déploiement

Procédure complète, pièges rencontrés et diagnostic : docs/OPERATIONS.md. Le multi-hôtes est hors périmètre v1 : l'opérateur parle à un seul socket Docker, celui du nœud sur lequel il tourne.

Prérequis de l'hôte

Prérequis Détail
Docker Swarm mono-nœud, rôle manager docker swarm init puis docker node ls doit montrer un nœud Leader. Les API Swarm (service, config) sont manager-only.
Réseau overlay attachable, partagé Traefik ↔ agents docker network create --driver overlay --attachable traefik-public. Ce nom devient TRAEFIK_NETWORK.
Traefik en mode Swarm sur ce réseau --providers.swarm, exposedByDefault=false, écoute sur :80 (entrypoint web). v3.7 minimum face à un daemon Docker récent (une version plus ancienne fige un client API trop vieux et Traefik ne crée aucun router). TLS terminé en amont : pas d'entrypoint :443, pas d'ACME côté Traefik (ADR-0012).
DNS wildcard un enregistrement *.<PUBLIC_DOMAIN> pointant sur l'hôte — chaque agent crée <name>.<domaine> et <name>-dash.<domaine> sans intervention DNS supplémentaire.
TRAEFIK_PROXY_CIDR = sous-réseau réel de l'overlay, borné docker network inspect traefik-public -f '{{ (index .IPAM.Config 0).Subnet }}'. L'opérateur refuse de démarrer si cette valeur est *, 0.0.0.0/0, ::/0 ou tout préfixe /0 (Hermes rejette ces entrées, SPEC C-07).
HERMES_IMAGE pinnée par digest jamais un tag flottant (ADR-0007) : docker pull nousresearch/hermes-agent:latest puis docker image inspect … -f '{{ index .RepoDigests 0 }}'.
DOCKER_GID GID du groupe propriétaire du socket sur l'hôte : stat -c %g /var/run/docker.sock. Nécessaire pour que le conteneur opérateur (uid 10001, non-root) puisse lire/écrire le socket en stack Swarm (group_add n'existe pas en mode stack).

Étapes

# 1. Prérequis hôte (une fois, voir tableau ci-dessus)
docker swarm init
docker network create --driver overlay --attachable traefik-public
# + déployer Traefik sur ce réseau (docs/OPERATIONS.md § 1)

# 2. Configuration de l'opérateur
cp .env.example .env
# renseigner : OPERATOR_API_KEY (openssl rand -hex 32), PUBLIC_DOMAIN,
# TRAEFIK_PROXY_CIDR, HERMES_IMAGE, DOCKER_GID — voir tableau ci-dessus

# 3. Build + déploiement
docker build -t hermes-operator:latest .
docker stack deploy -c compose.yml hermes-operator
docker service logs -f hermes-operator_operator

# 4. Vérification
curl -fsS http://127.0.0.1:8080/healthz
curl -fsS -H "Authorization: Bearer $OPERATOR_API_KEY" http://127.0.0.1:8080/agents

Le compose.yml contraint le service sur un nœud manager et monte /var/run/docker.sock en lecture-écriture — c'est ce qui rend l'opérateur root-equivalent sur l'hôte (§ Sécurité ci-dessous). Il publie le port 8080 sur l'hôte pour que le reverse proxy amont l'atteigne ; aucun router Traefik n'est posé sur l'opérateur lui-même.

Développement local

Sans toucher à un Swarm réel — lint, typecheck, tests unitaires, rechargement à chaud :

uv sync --all-extras
./scripts/check.sh     # format, lint, typecheck, tests unitaires
./scripts/dev.sh       # opérateur en rechargement à chaud

Les tests d'intégration (tests/integration/), eux, exigent un vrai daemon Swarm et ne tournent pas ici (pytest tests/unit dans check.sh les exclut).

Modèle de sécurité

L'opérateur monte le socket Docker : c'est un composant root-equivalent sur l'hôte, pas une sandbox. Conséquences :

  • Ne jamais l'exposer publiquement. Réseau d'administration dédié, ou Traefik avec un allowlist d'IP source, jamais en direct sur Internet.
  • L'OPERATOR_API_KEY est la seule authentification. Une clé compromise vaut un accès root à l'hôte.
  • Les credentials d'agent sont lisibles dans la spec du service Swarm, pas chiffrés (ADR-0009) : quiconque a le socket peut de toute façon déjà tout lire, cela n'ajoute aucune surface d'attaque réelle, mais ça veut dire que l'accès au socket est l'accès aux credentials.

Détail et procédures de rotation : docs/OPERATIONS.md § 3-5.

Documentation

Document Contenu
docs/SPEC.md référence normative : contraintes Hermes citées, architecture, surface API, tests attendus
docs/STATUS.md tâches T-xxx, journal, vérifications manuelles en attente
docs/DECISIONS.md ADR-0001 à ADR-0012
docs/ECARTS.md divergences vis-à-vis du brief et de la doc amont
docs/API.md contrat HTTP détaillé
docs/OPERATIONS.md déploiement, rotations, mise à jour de flotte, diagnostic
docs/WORKFLOW.md branches, commits, PR, CI
docs/CHANGELOG.md changements sous Unreleased

Les huit contraintes qui structurent tout

Elles viennent de la doc Hermes et ne sont pas négociables. Chacune est citée avec sa source dans docs/SPEC.md § 2.

  1. Un seul writer par data dir → replicas: 1, update-order: stop-first.
  2. s6-overlay est PID 1 → jamais init: true, jamais d'override d'entrypoint.
  3. Le managed scope gagne sur l'environnement → aucune clé variable par agent n'y est épinglée (sauf dashboard.public_url, ADR-0005).
  4. Le seed du volume est create-if-missing → le config.yaml est posé avant le premier boot, par un conteneur éphémère, en UID 10000.
  5. Les migrations tournent à chaque boot → ne pas compter sur la préservation des commentaires du config.yaml.
  6. Le dashboard fails closed sans provider d'auth → basic auth toujours fournie.
  7. trusted_proxies doit être borné → TRAEFIK_PROXY_CIDR validé au démarrage.
  8. API_SERVER_KEY ≥ 8 caractères, requis partout → 32 octets hex, et l'API donne un accès complet au toolset, terminal compris.

Licence

Propriétaire.