Architecture et comportement : p2pwatch¶
Une vue d'ensemble lisible du système : sous-systèmes, interactions et cycles de vie à l'exécution. Pour la conception détaillée, voir
agents/specs/(la spec du MVP fait autorité) ; pour exploiter un nœud, voir Faire tourner un nœud ; pour l'état courant et la prochaine étape, voiragents/handoffs/(le plus récent). Ce document décrit comment ça marche, pas comment le déployer.
1. En une phrase¶
p2pwatch surveille en continu le réseau eMule (eD2k + Kad, via un amuled piloté par l'API REST
d'amuleapi) pour retrouver les épisodes perdus du doublage français de Keroro mission Titar, en
cataloguant au passage chaque métadonnée croisée. Le catalogue enregistre des fichiers et, là où
un protocole en publie un, l'identifiant pseudonyme de la source qui les partageait ; jamais
l'adresse réseau d'un pair.
2. Vue d'ensemble des sous-systèmes¶
Un workspace uv de huit paquets, plus des dépendances externes.
Contexte : le nœud et le monde extérieur. Un nœud est deux conteneurs, de deux images. Le
cœur, p2pwatch, ne porte que le crawler et sa webui : pas de superviseur, il tourne directement
sous PUID:PGID. Le client eMule, ed2k (image p2pwatch-amule), fait tourner s6
(s6-svscan est PID 1), qui en supervise deux services, amuled et le port-sync ; c'est amuled
qui démarre amuleapi, et qui l'emporte avec lui en s'arrêtant. Sous la variante VPN, seul ed2k
partage le namespace réseau d'ed2k-gluetun : le cœur reste hors du tunnel.
flowchart LR
subgraph core["p2pwatch · PUID:PGID"]
crawler["crawler + webui"]
end
subgraph client["ed2k · s6"]
amuled["amuled"]
amuleapi["amuleapi · REST + web UI"]
sync["port-sync"]
end
gluetun["ed2k-gluetun · VPN"]
net(("eD2k / Kad"))
prom["Prometheus · operator's own"]
notif["Mail / Slack / Discord"]
out[("ed2k/downloads/incoming")]
crawler -->|"REST · ed2k:4711"| amuleapi
amuled -->|"starts, one-off EC token"| amuleapi
amuleapi -->|"EC · 127.0.0.1:4712"| amuled
sync -->|"amule.conf + s6-svc"| amuled
client -->|"all traffic, vpn variant"| gluetun
gluetun --> net
amuled -->|"finished files"| out
crawler -.->|"free space · statvfs, read-only mount"| out
prom -->|"scrapes /metrics · 8080"| crawler
crawler -->|"notifications · apprise URL"| notif
Conséquences de cette forme, chacune porteuse ailleurs dans ce document :
- Le point d'accès est une constante de code (
ed2k:4711, nom de clientamuled), comme le bind0.0.0.0:8080de la webui.ed2kest le service d'aMule dans la variante directe, et un alias porté pared2k-gluetundans la variante VPN : changer de variante ne touche pascrawler.yml. Celui-ci ne configure que le mot de passe, depuis${AMULE_API_PASSWORD}: c'est le mot de passe admin d'amuleapi, le même que celui de son interface web.${AMULE_EC_PASSWORD}ne sert qu'au lien interne amuleapi ↔ amuled, et le cœur ne le voit pas. - Redémarrer
amuled, c'ests6-svcdansed2k, un redémarrage de processus local, pas un redémarrage de conteneur (§9).amuleapisuit, puisqueamuledle relance. - Les conteneurs démarrent sans ordre (pas de
depends_onentre eux), donc le crawler frappe couramment à la porte avant qu'amuledn'ait démarréamuleapi; « démon injoignable au démarrage » est toléré et absorbé par le backoff. - Le PID 1 d'
ed2kest root (son one-shotpython -m p2pwatch_amule bootcrée l'utilisateuramuledepuisPUID/PGIDet prend les bind mounts), puis chaque service abandonne ses privilèges avecsetpriv. Le cœur n'a ni point d'entrée ni root :user:danscompose.ymlsuffit à ce quedata/reste à l'opérateur. Les deux gardentno-new-privileges,pids_limitetmem_limit, sansread_only:nicap_drop: ALL. - Docker relance le cœur après toute sortie (
restart: unless-stopped), donc une config invalide se voit comme une boucleRestarting. Une sortie propre (le contrôle de redémarrage de la webui) ramène le crawler seul, etamuledgarde ses sessions eD2k et Kad.
Aucun conteneur Prometheus ou Grafana n'est livré avec la pile : la webui sert /metrics sur 8080
et un opérateur qui veut des tableaux de bord y pointe son propre Prometheus.
Composants internes et données partagées (le crawler écrit, la webui lit ; matching est une
bibliothèque importée) :
flowchart RL
crawler["Crawler"]
webui["WebUI · in-process"]
catalog[("catalog.db")]
local[("local.db")]
matching(["matching · lib"])
crawler -->|writes| catalog
crawler -->|writes| local
crawler -->|imports| matching
webui -->|"read-only"| catalog
webui -->|imports| matching
| Paquet | Dist | Rôle |
|---|---|---|
p2pwatch |
p2pwatch |
Crawler : pilote amuled par amuleapi, fait tourner les boucles de recherche et de téléchargement, la persistance, l'observabilité. Contient le sous-paquet webui in-process p2pwatch.adapters.driving.webui (visualiseur de catalogue en lecture seule). |
p2pwatch_amule |
p2pwatch-amule |
Le conteneur d'aMule, bibliothèque standard seule : son one-shot de démarrage (python -m p2pwatch_amule boot) et le port-sync (python -m p2pwatch_amule port-sync, §9). |
catalog_matching |
catalog-matching |
Moteur de matching (bibliothèque partagée) : politique déclarative fichier vers épisode. Importé par le crawler et par la webui. |
pytest_settings |
pytest-settings |
Configuration de test partagée, un seul module et non un hexagone : les réglages de pytest que la suite de chaque paquet applique, chargés par son point d'entrée pytest11. Hors de la portée de vulture, comme les tests. Jamais livré dans une image de prod. |
vex_checks |
vex-checks |
Outillage dev/CI : garde honnêtes nos affirmations OpenVEX. Jamais livré dans une image de prod. |
template_checks |
template-checks |
Outillage dev/CI : vérifie que les templates de la webui ne portent aucune logique (poe template-check), et liste les noms qu'ils lisent, que poe dead-code-check ne doit pas signaler. Jamais livré dans une image de prod. |
coverage_checks |
coverage-checks |
Outillage dev/CI : vérifie que chaque module est couvert par ses propres tests, les fichiers de test qui portent son nom dans le miroir de src (lancé par poe test). Jamais livré dans une image de prod. |
amule_bump |
amule-bump |
Outillage CI : monte l'épingle d'aMule et rédige la PR (Mettre à jour aMule). Jamais livré dans une image de prod. |
Frontières strictes (invariants) : catalog_matching est pur et n'importe jamais p2pwatch ;
p2pwatch_amule et p2pwatch ne s'importent jamais l'un l'autre ; pytest_settings,
vex_checks, template_checks, coverage_checks et amule_bump ne sont jamais
importés par du code livré.
3. Deux modes d'exécution, une seule topologie¶
Le mode découle de la config (crawler.yml, section download), pas d'un flag séparé ni d'un
profil compose. Les deux variantes compose assemblent les mêmes services dans les deux cas.
flowchart TB
app["Supervision · TaskGroup"]
app --> s["Search"]
app --> st["Status"]
app -->|"if download"| d["Download"]
- Téléchargement (
download.enabled: true, le défaut livré) : recherche plus téléchargement. - Catalogue seul (
downloadabsent ouenabled: false) : seule la boucle de recherche tourne, avec celle du statut. Le nœud catalogue et notifie, et ne télécharge rien.
Le port-sync n'est pas une boucle du crawler : il tourne dans le conteneur ed2k (§9), et
crawler.yml refuse une section port_sync:.
Chaque boucle est une itération suivie d'un sommeil (*_interval_seconds depuis la config),
supervisée par un TaskGroup : une boucle qui crashe bruyamment annule ses sœurs (fail-fast), mais
les erreurs d'I/O attendues sont absorbées à l'intérieur d'un cycle (voir §10, discipline de
frontière).
4. Architecture interne : six hexagones (Clean / Hexagonal)¶
Le dépôt compte six hexagones : six applications, chacune avec son cœur et ses adaptateurs, qui ne
s'importent jamais l'une l'autre. Les trois outils de contrôle et amule_bump ne tournent qu'en
développement et en CI, jamais dans une image.
| Hexagone | Paquet | Ports pilotants | Ce que pilotent ses adaptateurs |
|---|---|---|---|
p2pwatch |
packages/crawler/, avec catalog_matching (packages/matching/), son domaine dans une distribution à part |
CrawlerRunner, ConfigValidator, CatalogMerger pour la ligne de commande, un port par page ou contrôle de la webui, /metrics compris, que Prometheus appelle |
amuleapi, les deux bases SQLite, les horloges, apprise |
p2pwatch_amule |
packages/amule/ |
ContainerBooter, PortSyncLoopRunner |
amule.conf, s6, gluetun, les comptes et répertoires d'amuled, amuleapi --set-admin-pass |
vex_checks |
packages/vex_checks/ |
ImageClaimsChecker, AppliedClaimsChecker, ClaimCoverageChecker |
les VEX, le SBOM Syft, Grype, le SARIF, les règles source d'ast-grep |
template_checks |
packages/template_checks/ |
LogicFreeChecker, TemplateAttributeLister |
les gabarits de la webui |
coverage_checks |
packages/coverage_checks/ |
UnitCoverageChecker |
.coverage, l'arbre du paquet et son pyproject.toml |
amule_bump |
packages/amule_bump/ |
AmuleBumper |
l'API GitHub, le Dockerfile, le corps de la pull request, GITHUB_OUTPUT |
Chaque hexagone se découpe en quatre anneaux, lus dans le chemin : domain/, application/,
adapters/ et composition/, les ports et adapters/ séparés en driving/ (ce qui appelle le
cœur : la ligne de commande, la webui) et driven/ (ce que le cœur appelle : SQLite, amuleapi). Les
ports, les cas d'usage, les étapes et le cycle de vie sont les couches de tach dans l'anneau
application/. Un __main__.py racine appartient à la composition. Le graphe des imports est un DAG qui pointe vers
le domaine pur.
flowchart TB
comp["composition/ · __main__.py"]
drivingAdp["adapters/driving/"]
appl["application/ · cycle de vie, étapes, cas d'usage"]
ports["application/ports/ · ABC"]
dom["domain/ · pure"]
drivenAdp["adapters/driven/ · I/O"]
comp --> drivingAdp
comp --> appl
comp -->|injecte| drivenAdp
drivingAdp -->|appelle| ports
appl -.->|implémente| ports
drivenAdp -.->|implémente| ports
ports --> dom
appl --> dom
- Un adaptateur n'importe jamais un autre adaptateur ; un adaptateur pilotant ne dépend que des ports pilotants, un adaptateur piloté que des ports pilotés qu'il implémente ou consomme.
application/range ses ports (ports/, un port par fichier, nommé d'après lui), ses cas d'usage (use_cases/), ses étapes (steps/) et au plus un cycle de vie (<x>_lifecycle.py). Un cas d'usage est appelé du dehors, par un port pilotant qu'il implémente sur des ports pilotés. Une étape est un stade du graphe du cycle de vie, appelée seulement par lui ou par une autre étape : une classe, une par module, nommé d'après elle (steps/node_id_resolver.pycontientNodeIdResolver), qui reçoit ses dépendances à sa construction et les valeurs du run (lenode_id, un fichier) à l'appel. Le cycle de vie (CrawlerLifecycle,PortSyncLifecycle) est le programme de son démarrage à son arrêt, que la composition lance ; il n'a pas de port. Cas d'usage et étapes ne s'importent pas entre eux, ni le cycle de vie. Ce qui n'appelle aucun port va au domaine.- Chaque hexagone expose
build_command_line(...)danscomposition/command_line.py, qui construit les adaptateurs, les cas d'usage et la ligne de commande ;__main__.pylit l'état du processus et appellebuild_command_line(...).run(argv). La composition construit chaque graphe quand ce qu'il lui faut existe : au démarrage, dansbuild_command_line; pendant un appel, en implémentant le port pilotant qui le fait (CrawlerComposition,BootComposition,PortSyncComposition,UnitCoverageComposition). - Seul un
__main__.pylit l'état du processus (argv, l'environnement, le répertoire courant, le logger racine, le répertoire temporaire) et le passe en valeurs ; un adaptateur reçoit de la composition ce qu'il pilote (un répertoire, une URL, un exécutable). Aucun test ne patche un module :monkeypatchetunittest.mock.patchsont refusés parcode-pattern-check. - Un contrôle rend un rapport, un
<Thing>Reportet sesproblems, que la ligne de commande imprime. tach checktient ces règles surtach.toml, où chaque module est déclaré avec ses dépendances ; les règles ast-grep*-imports.ymlgardent le cœur sans I/O.
Le crawler¶
domain/est pur : pas d'I/O, pas deyaml/DB/réseau/horloge/logging, aucune lecture d'env. L'adapter de config résout${VAR}dans toute valeur texte decrawler.ymlavant que quoi que ce soit n'atteigne le domaine.application/orchestre les cas d'usage asynchrones en parlant à des ports (des ABC). Ses journaux passent eux aussi par un port,Logger, queStdlibLoggerimplémente sous le nom que la composition dérive du code qui journalise, celui de son module (p2pwatch.application.steps.search_worker), que filtre un opérateur.adapters/portent toutes les I/O et héritent des ports qu'ils implémentent.composition/(CrawlerComposition) charge la config par l'adapter de config, le domaine la valide fail-fast (domain/crawler_config.py), câble les adapters concrets qu'elle décrit, puis lanceCrawlerLifecycle(application/crawler_lifecycle.py), la racine du graphe : il résout lenode_id, annonce le démarrage, réévalue le catalogue si la politique a changé, puis supervise les boucles.composition/command_line.py(build_command_line) construit les portscrawl,validate-configetmerge(le cas d'usageMergeCatalogs) et les passe à la ligne de commande,adapters/driving/cli/, qui lit la sous-commande et rend son code de sortie ;python -m p2pwatchlit l'état du processus et la lance.
5. Les tâches de recherche¶
C'est le cœur du système et sa raison d'être : être là 24/7 pour attraper un fichier rare à
l'instant où une source le partage. Une tâche par (client, canal, mot-clé) cherche en boucle,
toutes en même temps (application/steps/search_task_runner.py).
flowchart TD
gate["Wait while paused"]
backoff["Sleep until the backoff ends"]
search["search(keyword, channel, budget)"]
record["Record each observation"]
persist["Persist the backoff<br/>if it changed"]
gate --> backoff --> search --> record --> persist --> gate
Points clés en chemin :
- Deux canaux par mot-clé :
ed2k(multi-serveurs eD2k) etkad. Les noms de canaux sont opaques pour le cœur, qui ne teste jamais leur valeur. - Pas d'intervalle dans le cœur : un canal cherche aussi souvent que les règles de son réseau
le permettent, et c'est l'adaptateur qui les applique dans
search(). Pour aMule : deux départsed2kà 60 s d'écart au moins, un seul en vol ; deux départs Kad sur un même mot Kad (le premier mot de 3 octets du mot-clé) à 60 s d'écart au moins. Un canal lent ne retient jamais un canal rapide. - Mots-clés minimaux, depuis la config :
search.keywords, obligatoire et sans défaut dans le code (keroro+titardans lecrawler.ymllivré).kerororatisse large ;titarest une sentinelle française rare et non saturable (voir le handoff de simplification de la recherche pour le pourquoi : les données réelles ont montré que chercher davantage n'aide pas). La génération de mots-clés par cible a été supprimée. - Une tâche en backoff dort jusqu'à la fin de son backoff (celui du client ou celui du canal),
elle ne boucle jamais à vide. L'attente se mesure sur l'horloge monotone ;
backoff_statesen garde le début et le délai, et au démarrage suivant le temps écoulé est borné à[0, délai]parremaining_wait, si bien qu'une horloge reculée pendant l'arrêt n'allonge jamais l'attente. La redondance multi-nœuds est entièrement affaire de faire tourner plusieurs nœuds et de fusionner leurs catalogues. - Le statut n'est pas la liveness : « le processus est vivant » n'implique pas « on peut
trouver quelque chose là, maintenant ». Une boucle lit le statut de chaque client toutes les
minutes, par canal (
connected,connectable), et alerte sur un état dégradé qui dure : 5 min pour un canal, 2 min pour l'API injoignable, puis envoie son rétablissement. - Résilience : une
StoreErrorà l'écriture du backoff est journalisée, et l'écriture retentée après la recherche suivante.
5.1 Une tâche de recherche, de bout en bout¶
sequenceDiagram
participant W as SearchWorker
participant A as amuled
participant C as catalog.db
participant E as MatchingEngine
participant D as Waker
W->>A: search(keyword, channel, budget)
A-->>W: SearchAnswer (reported files, skipped results)
loop each reported file
W->>C: record_observation(FileObservation)
W->>C: read_known_filenames(file)
W->>E: evaluate_file(candidates)
E-->>W: list[MatchDecision]
loop each changed decision
W->>C: record_decision(DatedDecision)
W-)D: wake (if download tier)
end
end
- Un résultat devient un
ReportedFileviaadapters/driven/amule_api/mapping.py(capture exhaustive : l'identité du fichier, uneFileKeydont le hash MD4 est l'identifiant natif sur le réseaued2k, le nom, la taille, le nombre de sources, plus chaque clé non mappée). La durée, le débit et le codec arrivent maintenant avec le résultat, quand le serveur qui répond les annonce : c'est une déclaration du réseau, jamais une mesure locale, et le champ vautnullsur la plupart des résultats globaux et Kad. Rien n'ouvre jamais le fichier pour en savoir plus. Un fichier annoncé sous plusieurs noms arrive replié en un seul résultat ; le mapper le redéplie en unReportedFilepar nom, comme l'EC en produisait un par entrée. Un résultat sans hash, sans taille exploitable, sans aucun nom ou qui n'est pas un objet est écarté et compté : laSearchAnswerporte ce compte jusqu'àSearchExecuted, qui l'ajoute àp2pwatch_search_results_skipped. - amuleapi ne date pas ses résultats :
Cataloguer(application/steps/cataloguer.py) fait de chaqueReportedFileunFileObservationdaté parwall_clock.now()juste avant l'écriture, l'instant où le crawler a appris l'existence du fichier, et marqué du nœud qui l'observe. search()rend la main quand amuled signale la fin de la recherche, au plus tard 120 s (le budget, constante du cœur) après son départ réseau. Attendre est l'affaire de l'adaptateur.- Dans l'adaptateur, une recherche Kad est élargie à chaque tour de sondage à partir du deuxième
(
POST /search/{id}/more, le bouton « More » d'aMule) jusqu'à ce que Kad réponde409 kad_more_exhausted. Au premier tour, Kad n'a encore interrogé personne : relancer gaspillerait l'une de ses 4 relances. Un échec de cet appel n'arrête jamais la recherche : on cesse simplement de l'élargir. - L'observation est écrite (
files,observation_variants,observations) puis matchée. La décision (target_id,rule_name,tier) va dansmatch_decisions. En mode téléchargement, un tierdownloadpousse la boucle de téléchargement pour qu'elle réagisse sans attendre son intervalle. - Une opération que le démon refuse (
400 amuled_rejected) met ce canal en backoff (base × factor^échecs + jitter), persisté à chaque changement.
6. Du fichier à la décision : le moteur de matching¶
Maintenant que la recherche est « bête » (2 mots-clés), c'est le matcher qui porte toute la précision. C'est un moteur fixe minimal plus une politique 100 % YAML (des données validées fail-fast, pas de code par cible).
flowchart LR
yaml["YAML policy"]
tgt["targets"]
eng["MatchingEngine"]
cand["FileCandidate"]
dec{"decision?"}
yaml --> eng
tgt --> eng
cand --> eng --> dec
dec -->|download| a1["download"]
dec -->|notify| a2["alert"]
dec -->|catalog| a3["catalog"]
dec -->|None| a4["discard"]
- Deux natures de tokens : les identifiants d'épisode (spécifiques à une cible : numéro de
segment, couverture du titre) face aux marqueurs de source (agnostiques :
teletoon,idf1,vf). Un marqueur seul n'identifie aucun épisode, donc il ne fait que rehausser une identification faible (notify -> download), il n'en porte jamais une à lui seul. - Vetos : ce que le fichier n'est pas. La section
vetoes:liste des tokens jugés sur tous les noms connus d'un fichier : un seul nom qui déclenche un veto exclut la cible pour tout le fichier, à tous les tiers.foreign_lang(version étrangère, écritures chinoise, japonaise ou coréenne) etnot_episode(film, générique, extrait) excluent toutes les cibles ;other_episode(un numéro d'épisode explicite de 1 à 3 chiffres, commeN°065A,#065,ep 65ouS02E14, qui n'est pas celui de la cible) n'exclut que les autres épisodes. Le nom de la série n'annonce pas de numéro :Titar 2008oukeroro 1080pn'excluent rien. Les règles restent positives : elles disent ce que le fichier est. - Fragments :
fragments:nomme des morceaux de regex bruts (le repère d'un numéro d'épisode, la garde contre les dates), substitués en{nom}dans les tokensregexau chargement. Un fragment ne peut ni porter le nom d'un champ de cible (absolute_number,title…) ni en citer un autre. - Portée des règles : chaque règle déclare un
scopeobligatoire.segmentdésigne un segment (un titre, un numéro lettré),episodedésigne l'épisode entier (un numéro nu, donc chacun de ses segments),unattributedne désigne rien. Unmatcher.ymlsansscopeest refusé au démarrage. - Séparation des formats en trois : la vidéo obtient les tiers actionnables ; une archive obtient
notify(pour revue) ; tout le reste (mp3, pdf et compagnie) n'obtient que le tier catalogue permissif (l'invariant « cataloguer chaque métadonnée »). - Décision déterministe : par cible, la meilleure règle sur tous les noms (le tier le plus
haut,
download > notify > catalog, puis l'index de règle). Les règlessegmentetepisodedonnent une décision par segment désigné ; sans elles, une seule décisionunattributed(même ordre, puis le plus petittarget_id). L'explication est retournée (pour la webui), jamais loggée.
7. Du téléchargement à la complétion¶
Actif en mode téléchargement seulement. Une itération de DownloadCycle.run, que DownloadLoop
répète, enchaîne trois étapes sur une seule session amuleapi, puis, toutes les
download.sources_interval_seconds, relève les sources des téléchargements du crawler (§8.3).
flowchart TD
dec["Decision<br/>download tier"]
cand["New candidates"]
pol["decide_download"]
add["start · amuled"]
mon["Monitor"]
comp["Completion<br/>completed flag"]
done["state = completed<br/>+ notification"]
dec --> cand --> pol --> add
add --> mon --> comp --> done
Invariants porteurs (à ne pas violer) :
- Le crawler PROD ne lit jamais les octets téléchargés, et ne touche jamais au répertoire de sortie
du tout. La complétion est un signal positif du client, jamais un compte d'octets : chez
aMule, une entrée de la file au statut
completed(déplacée, en attente de retrait), ou un fichier présent dans les fichiers partagés d'amuledet absent de la file (terminé puis retiré). Une entrée encore en file n'est pas complète, même listée dans les partagés : amuled partage aussi les téléchargements partiels. Ce n'est jamais une inférence sur le contenu. - Rien ne déplace le fichier terminé. amuled écrit directement dans son propre
IncomingDir, bind-mounté sured2k/downloads/incomingcôté hôte ; le crawler enregistre le changement d'état et notifie, rien de plus. - Rien n'inspecte le fichier. Il n'y a pas de sniffing de type, pas d'
ffprobe, pas d'analyse antivirus : depuis la réduction de périmètre du 2026-09-13, juger si un téléchargement terminé est réellement l'épisode est une étape manuelle effectuée par l'opérateur sur le répertoire de sortie. - La décision (
decide_download) est conservatrice : on saute sitier != download, si la cible estcomplete, si le hash a déjà été téléchargé (dédup), ou si admettre le fichier casserait le plancher disque. Un épisode déjàfoundest retéléchargé quand un nouveau hash lui correspond (redondance d'archivage délibérée). - Le plancher disque (
download.min_free_bytes) est mesuré, pas comptabilisé (2026-09-13) : un candidat n'est admis que quandfree - outstanding - size >= min_free, oùfreeest un seul appelshutil.disk_usagesurdownload.output_diretoutstandingce qu'il reste à transférer à la file d'amuled, pris dans l'instantané de file déjà présent dans le cycle. Le cœur monte le répertoire de sortie en lecture seule, et n'y ouvre aucun fichier :statvfslit des métadonnées de système de fichiers, jamais des octets. Leed2k/downloadsde l'hôte est monté en entier, pas comme ses deux sous-répertoires, précisément pour que cestatvfsmesure le système de fichiers qui se remplit plutôt que la couche inscriptible du conteneur. Le cœur ne mesure qu'un répertoire de sortie, celui d'aMule. L'espace libre seul serait faux, puisque le système de fichiers ne sait rien des octets encore à venir. - Un téléchargement que le client ne liste plus est relancé, d'un
start()par cycle, tant qu'il estqueuedoudownloadinget jusqu'à ce que le client le liste de nouveau : aMule garde ses téléchargements à travers un redémarrage, slskd non. amuled ne refuse jamais un fichier qu'il détient encore, donc relancer coûte une requête. Au-delà dedownload.lost_after_seconds(24 h par défaut) sans apparaître, la ligne devientfailedavec la raisonlost. L'absence se mesure en mémoire, sur l'horloge monotone :DownloadTrackertient l'échéance de chaque téléchargementqueuedoudownloading, repoussée à chaque cycle où le client le liste. Chaque cycle estampille aussilatest_seen_atpour chaque fichier quedownloads()liste, en file ou partagé : au démarrage suivant, l'échéance repart de cette date, le temps écoulé borné à[0, lost_after]parremaining_wait(domain/persisted_wait.py), si bien qu'une horloge reculée pendant l'arrêt n'allonge jamais l'attente au-delà de sa durée. C'est sûr parce qu'une entrée reste dans la file d'amuled même avec zéro source : une absence signifie vraiment disparu. Le client reste l'autorité : une lignefailedqui réapparaît dans sa liste repasse endownloading, sauf s'il y signale lui-même une erreur (raisonerror), et une ligne qui se complète notifie, mêmefailed. - Le plancher ne supprime jamais rien, et
is_tracked()reste aveugle à l'état : une lignefailedcontinue de bloquer la remise en file automatique, donc les fichiers terminés s'accumulent toujours sans borne et une nouvelle tentative manuelle passe par la suppression de la ligne.
DownloadState est une enum fermée : queued -> downloading -> completed, ou failed. completed
est terminal ; failed n'est terminal que jusqu'à ce qu'amuled en décide autrement.
8. Persistance : deux bases, deux rôles¶
flowchart LR
subgraph cat["catalog.db · append-only"]
f["files"]
ov["observation_variants"]
o["observations"]
md["match_decisions"]
s["sources"]
sv["source_variants"]
so["source_observations"]
end
subgraph loc["local.db · per node"]
ni["node_identity"]
dl["downloads"]
bs["backoff_states"]
rs["reevaluation_state"]
end
catalog.db(version de schéma 15) : la connaissance accumulée, append-only, chaque table (triggersBEFORE UPDATE/DELETE -> ABORT), pour que N nœuds fusionnent en un seul catalogue (python -m p2pwatch merge). Les insertions sont idempotentes (ON CONFLICT DO NOTHING), donc sans danger en cas de redémarrage en pleine écriture ; jamaisINSERT OR IGNORE, qui avalerait aussi une violation deNOT NULLou deCHECK.local.db(version de schéma 15) : l'état d'exécution du nœud (identité, suivi des téléchargements et de leur progression, backoff des recherches, empreinte de la politique réévaluée). Jamais fusionnée : elle appartient à un seul nœud.
La migration catalog/0005 a supprimé file_verifications et local/0004 a supprimé
verification_tasks, en réécrivant en completed toute ligne de téléchargement quarantined
survivante (cette valeur a quitté DownloadState, et la relire lèverait une erreur). local/0005 a
ajouté downloads.last_seen_at, rempli avec queued_at pour qu'aucune ligne antérieure à la mise à
niveau ne soit condamnée au premier démarrage. catalog/0006 a supprimé sources,
source_observations et file_observation_ranges, que plus rien n'écrit ; un catalogue dont
file_observation_ranges contient une ligne refuse de migrer, car un jour compacté ne redevient
pas des observations.
catalog/0007 stocke les observations en variantes plus horodatages : une variante
(observation_variants) porte ce qui se répète d'une observation à l'autre (nom, durée, débit,
raw_meta, mot-clé, nœud ; la taille reste dans files, une seule par fichier, un hash eD2k
nommant un seul contenu), et observations ne garde par ligne que l'instant, en
microsecondes, et le nombre de sources. raw_meta devient un objet JSON : codec, file_type
et complete_source_count à son premier niveau, le reste sous unmapped.
catalog/0008 identifie un fichier par (network, native_id), avec pour
clé file_id, un UUID v5 de ce couple, identique sur tous les nœuds ; aich_hash disparaît.
catalog/0009 rend au disque, par un VACUUM, la place que ces réécritures ont libérée.
catalog/0010 crée l'historique des sources en plages de présence. catalog/0011 le refait en
points (§8.3) : il supprime source_ranges et source_variants, et recrée la seconde avec
node_id, vide, à côté de source_observations. Aucune version livrée n'avait écrit ces plages.
catalog/0012 donne à trois colonnes le nom du code (duration_seconds, connectable,
session_received_bytes) : un RENAME COLUMN réécrit le schéma, aucune ligne.
local/0006 efface l'état du cycle de recherche, qui n'existe plus, et la carte des backoffs.
local/0007 identifie un téléchargement par le même file_id que le catalogue, et garde
(network, native_id) : file_id est à sens unique, et la boucle doit retrouver le fichier.
local/0008 ajoute le cycle de vie d'un téléchargement : octets reçus, dernier progrès, raison
d'attente et raison d'échec. bytes_done reste NULL sur les lignes existantes, dont on ignore
la progression passée, pour que la première lecture n'invente pas un progrès.
local/0009 renomme la table et la colonne de l'empreinte de la politique, désormais
reevaluation_state.policy_fingerprint, sans toucher à sa valeur. local/0010 renomme la clé de
la carte des backoffs, désormais backoff_states, et la raison d'attente d'un fichier qu'aMule
vérifie ou alloue, désormais local_processing. local/0011 remplace scheduler_state par la
table backoff_states, STRICT, une ligne par client ou canal en backoff : ses tentatives, le
début de son attente en microsecondes et son délai. Les attentes en cours à la mise à niveau sont
perdues, et chaque backoff repart de sa base. catalog/0013 renomme raw_meta en raw_metadata
dans observation_variants et source_variants, local/0012 downloads.last_seen_at et
last_progress_at en latest_seen_at et latest_progress_at : des RENAME COLUMN, comme
catalog/0012, qui ne réécrivent aucune ligne. catalog/0014 renomme observed_at en
observed_at_micros dans observations et source_observations, local/0013
backoff_states.started_at en started_at_micros : un instant stocké en entier de microsecondes
porte son unité dans son nom, par des RENAME COLUMN eux aussi.
catalog/0015 reconstruit match_decisions pour stocker l'instant d'une décision en
microsecondes, decided_at_micros, au lieu du texte ISO-8601 ; chaque ligne garde son id, qui
départage deux décisions du même instant. local/0014 reconstruit downloads en table STRICT,
ses quatre instants en microsecondes : queued_at_micros, completed_at_micros,
latest_seen_at_micros et latest_progress_at_micros. local/0015 remplace la table clé-valeur
node_runtime par node_identity, une seule ligne typée : node_id et created_at_micros. Une
base dont node_runtime porte un node_id sans son created_at refuse de migrer plutôt que de
perdre l'identité du nœud. reevaluation_state est reconstruite elle aussi, si bien que chaque table
de local.db est désormais STRICT. Chaque table du catalogue l'est aussi, déclarée ainsi là où
catalog/0008, 0010, 0011 et 0015 la créent, avec un CHECK sur chaque quantité (>= 0), sur
tier et connectable, et sur chaque *_micros, entre datetime.min et datetime.max en UTC.
8.1 Écrire une migration¶
À chaque ouverture d'une base, au démarrage du crawler comme pour la destination de merge,
l'ouvreur (SqliteDatabaseOpener, dans adapters/driven/persistence_sqlite/sqlite_database_opener.py)
applique dans l'ordre chaque script NNNN_*.sql de migrations/catalog/ ou migrations/local/ dont
le numéro dépasse le PRAGMA user_version de la base, puis y inscrit ce numéro. Une base plus récente
que le code est refusée. Les règles d'écriture :
- Le nom. Un préfixe à quatre chiffres, strictement croissant ; les trous sont permis. Une migration ne s'insère jamais sous un numéro déjà livré : une base inscrite plus haut ne la verrait pas.
- Une transaction par script. L'ouvreur ouvre la transaction, inscrit la version dedans et la
valide : un échec annule le script entier et laisse la version inchangée. Le script n'écrit donc ni
BEGIN, niCOMMIT, niROLLBACK; unCOMMITégaré lèveMigrationError. - Pas de
CREATE TEMP TABLE. L'ouvreur changetemp_storeautour des migrations, ce qui détruit toute table temporaire. foreign_keysreste actif.PRAGMA foreign_keys = OFFest ignoré dans une transaction, sans erreur : la recette ci-dessous s'en passe.-
Remplacer une table, dans cet ordre :
- supprimer les triggers et les index des anciennes tables ;
- renommer les anciennes tables ;
- créer les nouvelles sous leur nom définitif ;
- copier ;
- supprimer les anciens enfants avant l'ancien parent ;
- recréer les triggers append-only.
Trois comportements de SQLite dictent cet ordre. Renommer une table réécrit les
REFERENCESde ses enfants vers le nouveau nom : un enfant qui doit désigner la nouvelle table est donc reconstruit lui aussi. Un index garde son nom quand sa table est renommée, et entrerait en collision avec celui de la nouvelle table. Enfin, chaque enfant d'un parent supprimé doit être supprimé aussi, même vide : il garderait unREFERENCESvers une table absente, et toute insertion échouerait ensuite surno such table. - Les grosses réécritures posent en tête de scriptPRAGMA secure_delete = OFFetPRAGMA cache_size = -262144(256 Mo). Là où SQLite est compilé avecSECURE_DELETE, comme dans l'image, leDROPd'une grosse table garde tout son contenu dans le journal de l'instruction, en mémoire, jusqu'au manque de mémoire ; le cache plus grand accélère une copie limitée par les entrées-sorties. L'ouvreur relit ces deux pragmas avant chaque script et les rétablit après : un script règle ce dont il a besoin et ne remet jamais rien en place. - Les tris passent par des fichiers temporaires (temp_store = FILE) dans le dossier temporaire du conteneur, pas dans/data: un tri en mémoire n'a pas de plafond. - La directive-- migration: no-transaction, seule et exacte en première ligne, fait exécuter le script hors transaction, pour ce qui l'exige commeVACUUM; la version est inscrite après son succès. Le script doit alors pouvoir être rejoué, car un arrêt entre sa fin et l'inscription le relance au démarrage suivant. - La directive-- migration: copy-before, seule et exacte en première ligne, marque un script qui supprime ou réécrit des lignes : unDELETE, unUPDATE, unDROP TABLE, unINSERT ... SELECT, unINSERT OR REPLACE, unREPLACE INTO, unINSERT ... ON CONFLICT DO UPDATEou unALTER TABLE ... DROP COLUMN. Le corps d'unCREATE TRIGGERne compte pas : il ne s'exécute pas quand le script crée le trigger. Avant d'appliquer des migrations dont une au moins la porte, l'ouvreur copie la base à côté d'elle (la copie d'avant migration).test_migration_directives.pyanalyse chaque script livré avec sqlfluff et exige la directive exactement quand le script contient l'une de ces instructions : un script qui l'oublie fait échouer la gate. - Une directive au plus par script, aucun n'ayant besoin des deux. Toute autre première ligne commençant par-- migration:est refusée au chargement des scripts.
8.2 Ce qui mérite un CHECK¶
SQLite ne sait pas modifier un CHECK : en changer un reconstruit la table (la recette de §8.1). Une
colonne reçoit donc un CHECK seulement si les trois conditions tiennent :
- Quelque chose en dépend : une règle, une jointure, une unicité ou une route la lit, et une mauvaise valeur la casserait, au lieu de seulement paraître étrange.
- La ligne seule l'énonce : un
CHECKne voit aucune autre table. - Elle ne change qu'avec le schéma (un nouveau réseau, un nouveau rôle), jamais avec la version d'un client ni un détail d'adaptateur.
Sinon, le domaine la valide, et lui seul. Le CHECK n'est jamais la seule défense : le domaine
valide d'abord, car son erreur nomme la valeur fautive, là où celle du CHECK ne nomme que son
expression. Le CHECK
arrête les écrivains qui contournent le domaine : merge, une migration. Par exemple,
sources.native_id en 32 caractères hexadécimaux minuscules sur eD2k en a un (une majuscule ferait
une seconde identité de la même source), mais pas source_variants.contact_point, dont le format
dépend d'un réseau que la ligne ne porte pas.
8.3 L'historique des sources¶
La dernière étape du cycle de téléchargement lit les sources de chaque téléchargement du crawler que
l'instantané de file liste sans le dire terminé (SourceClient.sources : GET
/downloads/{hash}/clients en pages, puis un GET /clients/{ecid} par source), et les écrit en une
transaction par téléchargement. Elle vient après les étapes qui font avancer les téléchargements,
pour qu'une erreur en lisant des sources n'en saute aucune. Elle tourne au premier tour après un
démarrage, puis au premier tour qui suit download.sources_interval_seconds (300 s par défaut)
depuis son dernier départ, que la lecture ait abouti ou non : deux lectures sont donc espacées de
l'intervalle, plus un tour au plus. SourceWatcher tient cet instant en mémoire, sur l'horloge
monotone.
flowchart LR
s["sources<br/>network, native_id"] --> v["source_variants<br/>file_id, name, software, role,<br/>contact_point, connectable,<br/>raw_metadata, node_id"]
v --> o["source_observations<br/>observed_at_micros, session_received_bytes"]
sources, l'identité :source_idest un UUID v5 denetwork:native_id, commefile_idmais sous son propre espace de noms. Sur eD2k,native_idest le user hash en minuscules ; un hash de 32 zéros, celui d'un pair qui ne l'a pas encore envoyé, ne fait pas une identité.source_variants, un jeu de propriétés qu'un nœud a vu sur un fichier,node_idcompris, dédupliqué parcontent_hash, commeobservation_variants.source_idestNULLsans user hash.raw_metadatane garde que les clés stables que l'adaptateur choisit : une vitesse, un rang de file ou un compteur ferait une variante à chaque lecture. Aucune colonne ne porte l'adresse d'un pair ;contact_pointest celle du serveur eD2k de la source.source_observations, un point par variante et par lecture, en microsecondes, avecsession_received_bytes, le compteur d'octets qu'aMule tient pour ce client (reçus de lui tous fichiers confondus, remis à zéro quand aMule recrée le client). La clé primaire(variant_id, observed_at_micros)refuse un second point au même instant ; l'écrivain l'insère avecON CONFLICT DO NOTHING, jamaisOR IGNORE, qui avalerait aussi leCHECKdu compteur (§8.2).
Un état qu'aMule traverse de lui-même (idle, connecting...) prend l'utilité du dernier point de
la source sur ce fichier et ce nœud (par source_id, ou par nom sans user hash), si ce point tombe
dans les 3 x (download.sources_interval_seconds + download.poll_interval_seconds) qui précèdent la
lecture et que son compteur ne dépasse pas celui de la lecture ; sinon le rôle est undetermined.
Le délai tolère une lecture manquée.
Toutes ces tables sont append-only, comme le reste du catalogue. merge copie sources et
source_variants comme les autres tables, et les points comme observations, la variante
retrouvée par son content_hash : une union, indépendante de l'ordre et rejouable.
La webui ne stocke aucune période : elle les calcule à l'affichage, en SQL, par des fonctions de
fenêtre sur les seuls points de l'intervalle de la page, 7 jours au plus. Une période réunit les
points consécutifs d'une source sur un fichier et un nœud, et se coupe quand la variante change,
quand l'écart entre deux points passe webui.source_period_gap_seconds, ou quand le compteur
baisse. Seule une période sending montre des octets reçus : la croissance du compteur entre son
premier et son dernier point.
Les alertes first_useful et reappeared se décident pour chaque lecture utile d'un téléchargement,
une fois sa transaction validée, d'après les points de la source antérieurs au début du tour
(Historique des sources). Le dernier point de chaque
variante avant ce début coûte une recherche sur la clé primaire, quelle que soit la longueur de
l'historique.
9. Port-sync High-ID (optionnel)¶
Derrière un VPN, le port entrant change ; sans High-ID, la connectabilité (et donc la couverture) se
dégrade. Le port-sync appartient au client : c'est un service s6 du conteneur ed2k
(python -m p2pwatch_amule port-sync, sous l'utilisateur amule), et le cœur n'en sait rien.
Toutes les PORT_SYNC_POLL_SECONDS (60 s par défaut), il compare le port forwardé de gluetun
au [eMule] Port d'amule.conf. S'ils diffèrent, et si le dernier redémarrage date de plus de
PORT_SYNC_RESTART_MIN_SECONDS (300 s) :
flowchart LR
g["gluetun's port<br/>differs"] --> d["s6-svc -wD -d amuled"]
d -->|"exit 0"| w["write Port + UDPPort"]
d -->|"failed or 60 s timeout"| u
w --> u["s6-svc -u amuled<br/>(always)"]
- amuled n'est jamais laissé arrêté. Un
-dlaisse le service voulu arrêté : le-uest dans unfinally, et le port-sync en envoie aussi un à son démarrage, qui répare un port-sync tué entre les deux. - L'écriture n'est jamais perdue. amuled écrit toute sa config en sortant : le fichier n'est lu
et écrit qu'une fois l'arrêt confirmé par
-wD, puis remplacé d'un bloc (fichier temporaire etrename), jamais réécrit sur place. - Les deux ports reçoivent la même valeur (
Porten TCP,UDPPorten UDP pour Kad) : un tunnel ne forwarde qu'un port. amuled ne les lit qu'au démarrage, d'où le redémarrage. - Les droits. s6 réserve le pilotage d'un service à root, et l'abonnement à ses événements
(dont
-wDa besoin) au groupe de root. Le scriptrundu port-sync, en root, attend le superviseur d'amuled, lui accordes6-svperms -G amule -E amule, puis descend versamule. - Interrupteur.
PORT_SYNCreçoitVPN_PORT_FORWARDINGtel quel ; éteint, le one-shot de démarrage pose le fichierdowndu service, et s6 ne le lance jamais. Allumé, il valide les réglages (Devenir High-ID) avant tout, et sort en nommant une valeur invalide. - Aucun événement, aucune métrique. Le port-sync journalise chaque changement et chaque échec,
rien de plus. Le Low-ID se lit dans le cœur, par la boucle de statut : la jauge
p2pwatch_channel_connectableet l'alerte sur un canal non joignable depuis 5 minutes.
Risque accepté : un High-ID augmente l'exposition, voir Devenir High-ID.
10. Observabilité¶
flowchart LR
uc["étape<br/>dispatch(Event)"]
desc["plan_observability()<br/>→ ObservabilityPlan"]
disp["Dispatcher"]
log["logs"]
store[("InMemoryNodeMetricStore")]
prom["webui /metrics"]
notif["Notifications<br/>apprise URL"]
uc --> desc --> disp
disp --> log
disp -->|NodeMetricWriter| store
prom -->|"ListNodeMetrics · NodeMetricReader"| store
disp -->|best-effort| notif
Le domaine émet des Event purs ; une politique pure (plan_observability) les route vers des
ObservabilityPlan (sévérité de log + CounterIncrement et GaugeSetting des métriques du
nœud + notification, dont le genre donne l'audience COMMUNITY ou OPERATIONS) ;
l'étape ObservabilityDispatcher (application/steps/observability_dispatcher.py) les applique.
Une notification à la communauté y est une valeur typée (TierRise, SourceAlert,
CompletedDownload) : le domaine n'écrit que du texte brut, l'adaptateur AppriseNotifier en écrit
le Markdown. Aux opérations, la notification relaie la ligne de log (RelayedLogLine). Un événement
qui notifie porte son node_id, que sa notification reprend.
Les métriques du nœud sont un état à nous : le dispatcher les écrit par le NodeMetricWriter
d'InMemoryNodeMetricStore, et la route /metrics de la webui les lit par le cas d'usage
ListNodeMetrics, derrière son port pilotant NodeMetricLister, puis écrit elle-même le format
texte 0.0.4 de Prometheus. Le domaine porte le catalogue des métriques, compteurs NodeCounter et
jauges NodeGauge, chacun avec sa description et sa classe de labels (ChannelLabels,
ClientLabels, DecisionLabels, SourceAlertLabels, ou aucune) : un CounterIncrement ne
prend qu'un compteur, un GaugeSetting qu'une jauge, et des labels d'une autre classe que celle
de la métrique lèvent une erreur à la construction. Chaque classe écrit ses paires (nom, texte),
que le store garde telles quelles pour l'exposition. Rien ne scrape /metrics
par défaut : pointez-y votre propre Prometheus si vous voulez des tableaux de bord.
Discipline de frontière (E-D13) : les échecs de notifieur (apprise) et les échecs d'appel au
démon sont
absorbés (dégradation) ; un échec dans un composant in-process testé à 100 % (par exemple
InMemoryNodeMetricStore) crashe bruyamment, parce que c'est un bug, pas un transitoire.
11. Invariants de conception (récapitulatif)¶
- Le catalogue enregistre des fichiers et, là où un protocole en publie un, l'identifiant pseudonyme de la source qui les partageait ; il ne stocke jamais l'adresse réseau d'un pair (IP, port, pays), et ne relie jamais des identifiants d'un réseau à l'autre.
- Le crawler PROD ne lit jamais les octets et ne touche jamais au répertoire de sortie ; la complétion est un signal positif.
- Frontières de paquets :
catalog_matchingn'importe jamaisp2pwatch;p2pwatch_amuleetp2pwatchne s'importent jamais ;pytest_settings,vex_checks,template_checks,coverage_checksetamule_bumpne sont jamais importés par du code livré. - Deux modes d'exécution pilotés par la config (téléchargement / catalogue seul), une seule topologie compose par variante.
- Politique de matching 100 % en YAML ; le moteur reste fixe et minimal.
domain/est pur ; toutes les I/O vivent dansadapters/; le graphe de dépendances est un DAG.catalog.dbest append-only, chaque table, et fusionnable ;local.dbn'est jamais fusionnée.- Discipline de frontière : absorber les échecs d'I/O externes attendus, laisser crasher le code interne testé.
12. Repères dans le code¶
| Sous-système | Emplacement (sous packages/crawler/src/p2pwatch/ sauf mention) |
|---|---|
| Câblage | composition/command_line.py (build_command_line), composition/crawler_composition.py (CrawlerComposition) |
| Démarrage et boucles | application/crawler_lifecycle.py (CrawlerLifecycle), application/steps/loop_supervisor.py (LoopSupervisor) |
| Ligne de commande | __main__.py, adapters/driving/cli/ (crawl, validate-config, merge) |
| Boucles | application/steps/ : search_task_runner.py (SearchTaskRunner, sur les SearchWorker de search_worker.py et leur BackoffRegistry de backoff_registry.py), status_loop.py, download_loop.py (DownloadLoop, qui répète le cycle DownloadCycle de download_cycle.py et ses étapes download_tracker.py, download_decider.py, download_starter.py, source_watcher.py, et download_labeler.py, qui nomme un téléchargement dans ses notifications) |
| Cas d'usage | application/use_cases/ : les pages (summarize_crawl.py, describe_*.py, list_*.py, run_console_query.py), les contrôles (pause_crawl.py, resume_crawl.py, request_shutdown.py), validate_config.py, merge_catalogs.py |
| Config | adapters/driven/config/ (lit les trois fichiers, résout ${VAR}), domain/crawler_config.py (contrôle et construit CrawlerConfig) |
| Recherche (pure) | domain/search/ (keywords, backoff) |
| Matching | packages/matching/src/catalog_matching/ (moteur + politique deploy/matcher.yml) |
| Frontière amuleapi | adapters/driven/amule_api/ (client / mapping / erreurs) ; ports application/ports/driven/search_client.py, download_client.py, source_client.py |
| Persistance | adapters/driven/persistence_sqlite/ (migrations .sql, magasins et lecteurs) |
| Observabilité | domain/observability/, application/steps/observability_dispatcher.py, adapters/driven/observability/ |
| WebUI | adapters/driving/webui/ (in-process, thread dédié) |
| Conteneur d'aMule | packages/amule/src/p2pwatch_amule/ (câblage : composition/command_line.py (build_command_line) ; le one-shot : la ligne de commande appelle le port ContainerBooter, implémenté par la composition composition/boot_composition.py (BootComposition), qui lit les variables puis lance le cas d'usage application/use_cases/boot_container.py ; le port-sync : la ligne de commande appelle le port PortSyncLoopRunner, implémenté par la composition composition/port_sync_composition.py (PortSyncComposition), qui assemble application/port_sync_lifecycle.py) ; image packages/amule/Dockerfile, services s6 sous packages/amule/docker/services.d/ |
| Déploiement | deploy/compose.yml (le cœur et un include:), deploy/ed2k/ (service, direct et vpn.compose.yml) |
13. Le healthcheck lit la sortie de s6-svstat, pas son code de retour¶
Si vous écrivez une sonde autour de s6-svstat (un contrôle de supervision, un healthcheck:
maison), testez la valeur qu'elle imprime, pas son code de retour : sinon elle déclarera sain un
amuled arrêté.
s6-svstat sort en 0 même pour un service arrêté ; avec -u, il imprime false sur la sortie
standard. Un code de retour non nul signifie tout autre chose : c'est s6-supervise lui-même qui ne
tourne pas pour ce répertoire de service. Le healthcheck livré teste donc la valeur imprimée :
(le $$ est l'échappement compose d'un $ littéral ; dans le conteneur, la commande est
test "$(s6-svstat -u /etc/services.d/amuled)" = true).
Ce que unhealthy signifie, et ne signifie pas. Le healthcheck est celui du conteneur ed2k,
qui ne passe unhealthy que quand amuled est arrêté. Le cœur n'en a pas : Docker le relance déjà
après toute sortie, et l'état d'amuled qu'un healthcheck lui aurait fait rapporter, sa boucle de
statut le rapporte et l'alerte.