diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 541f741..c318350 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -9,6 +9,60 @@ et le projet suit le [versionnage sémantique](https://semver.org/lang/fr/). ## [Non publié] +## [0.1.65] - 2026-08-24 + +### Ajouté + +- **`--json` couvre désormais toutes les commandes dont la sortie a une + structure.** `show`, `scores`, `next`, `doctor` et `validate-structure` + rejoignent `list-labs`, `progress`, `check`, `status` et `support` : dix + commandes, un document chacune, toutes passant par `machine.emit()` et + portant donc `schema`. Une intégration ne pouvait lire qu'un quart de ce que + l'outil sait ; pour le reste, elle devait analyser des tableaux Rich dont la + largeur dépend du terminal. + +- **Un verdict se lit sans le traduire.** `doctor` donne à chaque contrôle une + `key` stable (`kvm`, `pytest`, `libvirt_pool`…) et un `state` en jeton (`ok`, + `failed`, `choice_required`) ; `validate-structure` donne à chaque anomalie la + `key` de la règle qui a parlé, ses `params`, et un `kind` qui nomme la + famille. Le libellé traduit est posé à côté, pour l'affichage seulement. La + conception paresseuse aurait recopié la phrase affichée dans un champ : + d'apparence complète, et inutilisable, puisque aucun consommateur ne peut + distinguer le vert du rouge sans analyser du français ou de l'anglais. Un test + joue `doctor --json` dans les deux langues et exige des clés et des états + identiques là où les libellés diffèrent. + +- **[Une page de documentation pour la sortie machine](docs/machine-output.fr.md) + ([EN](docs/machine-output.md))** : chaque document champ par champ, les codes + de retour, et la règle d'évolution. Un champ ajouté laisse `schema` où il + est ; un champ qui change de sens l'incrémente. Le texte traduit et la sortie + brute de pytest sont explicitement hors du contrat ; les jetons stables et les + codes de retour y sont. Le `fullhelp` a gagné la section correspondante, dans + les deux langues. + +### Corrigé + +- **Un diagnostic qui plantait en diagnostiquant.** `virsh version` et + `incus list` sont joués avec un délai de cinq secondes, et la `TimeoutExpired` + n'était pas rattrapée : sur un hôte dont la socket libvirt ne répond jamais, + elle emportait toute la commande `doctor`. Depuis que `doctor --json` est une + interface, elle emportait avec elle le document de l'appelant et lui rendait + une trace Python. Une sonde qui ne répond pas est désormais rapportée comme un + composant qui ne répond pas, avec le geste qui le corrige. + +### Modifié + +- `doctor --json --fix` est refusé, et dit pourquoi : les commandes de + remédiation écrivent sur la sortie standard, et le document sortirait précédé + de la sortie d'apt. On lit le diagnostic d'abord, on agit ensuite. + +- `Check` porte son identité (`key`) et en dérive son libellé, au lieu de les + écrire tous les deux à chaque appel, où rien n'empêchait qu'ils divergent. Son + `status_key` devient un `state`, pour que le mot affiché au terminal et le + jeton rendu à un programme viennent de la même source. + +Closes #83. + ## [0.1.64] - 2026-08-24 ### Corrigé diff --git a/CHANGELOG.md b/CHANGELOG.md index 15595f0..4e664d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.65] - 2026-08-24 + +### Added + +- **`--json` now covers every command whose output has a structure.** `show`, + `scores`, `next`, `doctor` and `validate-structure` join `list-labs`, + `progress`, `check`, `status` and `support`: ten commands, one document each, + all going through `machine.emit()` and therefore all carrying `schema`. An + integration could read a quarter of what the tool knows; for the rest it had + to parse Rich tables whose width follows the terminal. + +- **A verdict can be read without translating it.** `doctor` gives every check + a stable `key` (`kvm`, `pytest`, `libvirt_pool`…) and a `state` token (`ok`, + `failed`, `choice_required`); `validate-structure` gives every issue the + `key` of the rule that fired, its `params`, and a `kind` naming the family. + The translated label sits beside them, for display only. The lazy design + would have copied the displayed sentence into a field: complete-looking, and + unusable, since no consumer can tell green from red without parsing French or + English. A test runs `doctor --json` in both languages and asserts the keys + and states are identical while the labels are not. + +- **[A documentation page for the machine output](docs/machine-output.md) + ([FR](docs/machine-output.fr.md))**: every document field by field, the exit + codes, and the evolution rule. Adding a field keeps `schema`; changing what a + field means increments it. Translated text and pytest's raw output are + explicitly outside the contract; the stable tokens and the exit codes are + inside it. `fullhelp` gained a matching section, in both languages. + +### Fixed + +- **A diagnostic that crashed while diagnosing.** `virsh version` and + `incus list` are run with a five-second timeout, and the `TimeoutExpired` was + not caught: on a host whose libvirt socket never answers, it took the whole + `doctor` command down. Now that `doctor --json` is an interface, it took the + caller's document down with it and handed back a Python traceback. A probe + that does not answer is now reported as a component that does not answer, + with the gesture that fixes it. + +### Changed + +- `doctor --json --fix` is refused, and says why: the remediation commands write + to standard output, and the document would come out preceded by apt's output. + The diagnosis is read first, acted upon second. + +- `Check` carries its identity (`key`) and derives its label from it, instead of + spelling both out at each call site where nothing prevented them from + diverging. Its `status_key` becomes a `state`, so the terminal wording and the + machine token come from one source. + +Closes #83. + ## [0.1.64] - 2026-08-24 ### Fixed diff --git a/docs/README.fr.md b/docs/README.fr.md index 02d846c..2f6377b 100644 --- a/docs/README.fr.md +++ b/docs/README.fr.md @@ -27,6 +27,7 @@ dit ce que c'est en trente secondes ; ces pages disent comment cela fonctionne. | [Le contrat v1](./contract-v1.fr.md) | `meta.yml` et `lab.yaml`, champ par champ, avec ce que la v1 garantit | | [Référence des commandes](./commands.fr.md) | Toutes les commandes, produites par la CLI elle-même | | [Où dsoxlab écrit](./files.fr.md) | Chaque fichier que dsoxlab crée, et les variables d'environnement qu'il lit | +| [La sortie machine](./machine-output.fr.md) | Ce que rend `--json`, champ par champ, et ce sur quoi on peut bâtir | | [La marque](./brand.fr.md) | Nom, logo et conditions d'usage | Les contributeurs ont [CONTRIBUTING.fr.md](../CONTRIBUTING.fr.md) : installation, diff --git a/docs/README.md b/docs/README.md index 490e257..91e5559 100644 --- a/docs/README.md +++ b/docs/README.md @@ -27,6 +27,7 @@ thirty seconds; these pages say how it works. | [The v1 contract](./contract-v1.md) | `meta.yml` and `lab.yaml`, field by field, with what version 1 guarantees | | [Command reference](./commands.md) | Every command, generated from the CLI itself | | [Where dsoxlab writes](./files.md) | Every file dsoxlab creates, and the environment variables it reads | +| [The machine output](./machine-output.md) | What `--json` prints, field by field, and what may be built on it | | [The mark](./brand.md) | Name, logo and their usage terms | Contributors have [CONTRIBUTING.md](../CONTRIBUTING.md): setup, quality gates, diff --git a/docs/machine-output.fr.md b/docs/machine-output.fr.md new file mode 100644 index 0000000..717c46d --- /dev/null +++ b/docs/machine-output.fr.md @@ -0,0 +1,433 @@ +# La sortie machine + +**Public :** vous écrivez quelque chose qui *lit* dsoxlab : une extension +d'éditeur, un tableau de bord, une étape de CI, un script de suivi. Cette page +est le contrat sur lequel vous pouvez vous appuyer, et la seule partie de la +sortie faite pour être analysée. + +**Langue :** [English](./machine-output.md) · [Français](./machine-output.fr.md) + +Tout le reste de ce que dsoxlab affiche est fait pour des yeux : des tableaux +Rich dont la largeur suit le terminal, des couleurs, des barres de progression. +C'est fait pour bouger. `--json` rend un document à la place, et cette page dit +ce qu'il y a dedans. + +--- + +## Trois règles + +**1. La sortie standard porte le document, et rien d'autre.** En mode `--json`, +le rappel du contexte actif, les astuces et l'avis de mise à jour partent tous +sur la sortie d'erreur. `json.loads(stdout)` fonctionne donc sans rien retirer, +et c'est exactement ainsi que la suite de tests l'exige : un message glissé +devant le document, ou une barre de progression restée derrière, la fait lever. + +**2. Chaque document porte un `schema`.** C'est son premier champ, et il dit à +un consommateur s'il parle la même langue avant qu'il ne lise le reste. La +valeur courante est **1**. + +**3. Un verdict se lit dans une clé et un état, jamais dans un libellé.** Les +contrôles et les anomalies portent un identifiant stable (`key`) et, quand il y +a verdict, un état en jeton (`ok`, `failed`, `choice_required`). Le libellé +traduit est posé *à côté*, pour l'affichage. Aucune intégration ne devrait avoir +à analyser du français ou de l'anglais pour savoir si c'est vert ou rouge. + +Et une conséquence qui mérite d'être dite à part : **`--json` change la forme de +la sortie, jamais le verdict ni le code de retour.** Un `check` sur un lab en +échec sort en 1 avec ou sans lui ; `validate-structure` sort en 1 dès qu'un lab +échoue ; `doctor` sort en 0 dans les deux modes et met son verdict dans `ok`. +Sur une erreur *dure* : identifiant de lab inconnu, `meta.yml` illisible, la +sortie standard reste vide, la cause part sur la sortie d'erreur, et le code ne +bouge pas. Lisez le code de retour d'abord. + +--- + +## Les commandes qui prennent `--json` + +| Commande | Document | Codes de retour | +| --- | --- | --- | +| `dsoxlab list-labs` | le catalogue | 0 | +| `dsoxlab show ` | un lab et l'état de son runtime | 0, ou 1 si l'identifiant est inconnu (aucun document) | +| `dsoxlab progress` | le catalogue et un résumé de progression | 0 | +| `dsoxlab next` | le lab suggéré et ce qui reste | 0, ou 1 sans contexte actif (aucun document) | +| `dsoxlab scores` | l'historique des notes et les verdicts d'examen | 0 | +| `dsoxlab check ` | le résultat des tests et la note | 0, ou 1 si le lab échoue (le document est rendu quand même) | +| `dsoxlab status` | la joignabilité SSH des hôtes déclarés | 0, ou 1 dès qu'un hôte déclaré ne répond pas (le document est rendu quand même) | +| `dsoxlab doctor` | le diagnostic de l'environnement | 0, toujours : le verdict est dans `ok` | +| `dsoxlab validate-structure` | chaque anomalie de contrat trouvée | 0, ou 1 dès qu'un lab échoue (le document est rendu quand même) | +| `dsoxlab support` | le rapport de diagnostic anonymisé | 0 | + +`doctor --json --fix` est refusé, et le dit sur la sortie d'erreur : les +commandes de remédiation écrivent sur la sortie standard, et le document en +deviendrait illisible. On lit le diagnostic d'abord, on agit ensuite. + +--- + +## L'objet lab + +Cinq documents (`list-labs`, `show`, `progress`, `next` et `check`) embarquent +le même objet lab. Il est décrit une fois, ici. + +| Champ | Type | Sens | +| --- | --- | --- | +| `id` | chaîne | l'identifiant du lab, unique dans le catalogue, et la clé que prennent les commandes | +| `title` | chaîne | titre d'affichage, dans la langue du catalogue | +| `section` | chaîne | la section d'appartenance, `repo.category` par défaut | +| `bloc` | entier ou null | le bloc pédagogique, dérivé de la position dans le `meta.yml` | +| `bloc_order` | entier ou null | le rang dans ce bloc : c'est l'ordre que suit `next` | +| `level` | chaîne | niveau libre (`l1`, `rhcsa`…) | +| `type` | chaîne | `lab`, `challenge` ou `capstone` | +| `exam_passing_score` | entier ou null | seuil de réussite, en pourcentage du barème. `null` sur un lab ordinaire | +| `difficulty` | chaîne ou null | libre, jamais validé | +| `estimated_time` | chaîne ou null | libre, par exemple `"30m"` | +| `skills` | liste de chaînes | jamais vide : le validator l'exige | +| `distros` | liste de chaînes | jamais vide, de même | +| `doc_url` | chaîne | le guide en ligne, en `http` ou `https` | +| `path` | chaîne | chemin **absolu** du répertoire du lab, pour qu'un éditeur puisse ouvrir ses fichiers | +| `runtime.type` | chaîne | `shell` ou `vm` | +| `runtime.session` | chaîne | `target` ou `local` | +| `runtime.target` | chaîne ou null | l'hôte cible résolu, `null` sur un lab `shell` | +| `runtime.workdir` | chaîne | répertoire de travail, relatif à `path` | +| `best_score` | objet ou null | `{"points": entier, "max": entier}`, ou `null` quand le lab n'a **jamais été tenté** | + +`best_score: null` n'est pas un zéro. Un lab jamais joué et un lab joué puis raté +sont deux états différents, et une interface qui les confond raconte à +l'apprenant quelque chose de faux. + +--- + +## `list-labs` + +```json +{ + "schema": 1, + "labs": [ { "id": "l1-first-terminal", "…": "…" } ], + "count": 20 +} +``` + +| Champ | Type | Sens | +| --- | --- | --- | +| `labs` | liste d'objets lab | filtrée par les options et par le contexte actif | +| `count` | entier | la taille de `labs`, pour qu'un consommateur n'ait pas à la calculer | + +## `show` + +```json +{ + "schema": 1, + "lab": { "id": "l1-first-terminal", "…": "…" }, + "status": "ready" +} +``` + +| Champ | Type | Sens | +| --- | --- | --- | +| `lab` | objet lab | avec son `best_score` | +| `status` | chaîne ou null | `ready`, `stopped`, ou `null` quand le runtime ne sait pas répondre | + +`status` est un jeton, pas une phrase : il ne suit pas la langue d'affichage. + +## `progress` + +```json +{ + "schema": 1, + "labs": [ { "…": "…" } ], + "summary": { "total": 84, "attempted": 12, "points": 940, "max_points": 1200 } +} +``` + +| Champ | Type | Sens | +| --- | --- | --- | +| `labs` | liste d'objets lab | triée par `bloc`, puis `bloc_order`, puis `id` | +| `summary.total` | entier | labs dans le périmètre | +| `summary.attempted` | entier | labs qui portent au moins un résultat | +| `summary.points` | entier | points obtenus, sommés sur les seuls labs tentés | +| `summary.max_points` | entier | le barème de ces mêmes labs | + +## `next` + +```json +{ + "schema": 1, + "context": { "section": "l1", "level": null }, + "next": { "id": "l1-first-terminal", "…": "…" }, + "all_done": false, + "remaining": 12 +} +``` + +| Champ | Type | Sens | +| --- | --- | --- | +| `context.section` | chaîne | la section active : `next` en exige une, et sort en 1 sans | +| `context.level` | chaîne ou null | le niveau actif, s'il y en a un | +| `next` | objet lab ou null | le premier lab sans résultat enregistré, dans l'ordre pédagogique | +| `all_done` | booléen | vrai seulement si la section porte des labs et que tous ont un résultat | +| `remaining` | entier | labs sans aucun résultat enregistré | + +`all_done` et `next: null` ne disent pas la même chose : une section vide rend +elle aussi `next: null`, et un consommateur qui féliciterait l'apprenant +fêterait un parcours qui n'a jamais commencé. + +## `scores` + +```json +{ + "schema": 1, + "results": [ + { + "lab_id": "aws-provider-aws-first-ec2", + "section": "aws", + "score": 100, + "max_score": 100, + "passed_tests": 11, + "total_tests": 11, + "hints_used": 0, + "validated_at": "2026-08-13T13:30:12.831759+00:00", + "exam": null + } + ], + "count": 1 +} +``` + +| Champ | Type | Sens | +| --- | --- | --- | +| `results` | liste | les plus récents d'abord, bornée par `--top` | +| `results[].score` / `max_score` | entier | la note enregistrée et son barème | +| `results[].passed_tests` / `total_tests` | entier | ce que pytest a rapporté | +| `results[].hints_used` | entier | indices pris, c'est-à-dire ce qui a fait baisser la note | +| `results[].validated_at` | chaîne | ISO 8601, en UTC | +| `results[].exam` | objet ou null | `null` sur un lab ordinaire ; sinon `{"passing_score", "percentage", "passed"}` | + +`exam: null` veut dire *ce n'est pas un examen*, et ce n'est délibérément pas +`false` : un lab ordinaire n'est pas un examen recalé. La comparaison derrière +`passed` se fait en entiers, jamais sur un pourcentage arrondi : un seuil +d'examen ne s'arrondit pas en faveur du candidat. + +## `check` + +```json +{ + "schema": 1, + "lab": { "id": "premiers-pas", "…": "…" }, + "check": { + "ok": true, + "passed": 3, + "total": 3, + "score": 100, + "max_score": 100, + "output": "=== test session starts ===\n…" + } +} +``` + +| Champ | Type | Sens | +| --- | --- | --- | +| `check.ok` | booléen | tous les tests passent | +| `check.passed` / `total` | entier | tests réussis, tests joués | +| `check.score` / `max_score` | entier | la note enregistrée dans la base du catalogue | +| `check.output` | chaîne | la sortie brute de pytest, où vit le détail d'un échec | + +La commande sort en 1 quand `ok` vaut faux, et rend le document quand même. + +## `status` + +```json +{ + "schema": 1, + "provider": "kvm", + "hypervisor": { "queryable": true, "error": null }, + "hosts": [ + { + "fqdn": "alma-rhcsa-1.lab", + "ip": "10.10.10.11", + "reachable": false, + "reason": "Connection timed out", + "domain": "alma-rhcsa-1", + "domain_state": "shut off", + "cause": "domain_not_running" + } + ], + "summary": { "reachable": 0, "total": 1 } +} +``` + +| Champ | Type | Sens | +| --- | --- | --- | +| `provider` | chaîne ou null | le provider d'infra actif ; `null` sur un catalogue sans hôte | +| `hypervisor.queryable` | booléen | l'état des machines a-t-il pu être demandé au backend | +| `hypervisor.error` | chaîne ou null | pourquoi il ne l'a pas pu, le cas échéant | +| `hosts[].reachable` | booléen | SSH a répondu | +| `hosts[].reason` | chaîne ou null | la dernière ligne de l'échec SSH, quand il y en a eu un | +| `hosts[].domain` / `domain_state` | chaîne ou null | ce que dit l'hyperviseur, quand on peut le lui demander | +| `hosts[].cause` | chaîne | un **jeton stable** qui nomme le diagnostic, pas une phrase | +| `summary.reachable` / `total` | entier | hôtes qui ont répondu, hôtes déclarés | + +La commande sort en 1 dès qu'un hôte déclaré ne répond pas, et rend le document +quand même : c'est justement lui qui dit lequel, et pourquoi. + +Un catalogue sans bloc `infra:` est un cas normal, pas une erreur : il rend +`provider: null`, `hosts: []` et un résumé à zéro, et sort en 0. + +## `doctor` + +```json +{ + "schema": 1, + "ok": true, + "required": [ + { + "key": "pytest", + "state": "ok", + "ok": true, + "label": "pytest", + "detail": "embarqué avec dsoxlab (celui qu'utilise « check »)", + "fix": null, + "hint": null + } + ], + "informational": [ + { + "key": "kvm", + "state": "failed", + "ok": false, + "label": "virsh/KVM", + "detail": "virsh introuvable", + "fix": "sudo apt install libvirt-clients libvirt-daemon-system qemu-kvm", + "hint": null + } + ], + "notes": ["Aucun lab de ce dépôt n'utilise de VM : les hyperviseurs ci-dessus sont informatifs."] +} +``` + +| Champ | Type | Sens | +| --- | --- | --- | +| `ok` | booléen | **le verdict**, et il ne porte que sur `required` | +| `required` | liste de contrôles | ce qui bloque *ce* catalogue | +| `informational` | liste de contrôles | composants dont ce catalogue n'a pas besoin, jamais une erreur | +| `notes` | liste de chaînes | phrases traduites qui expliquent *pourquoi* un composant est informatif ici | + +Chaque contrôle : + +| Champ | Type | Sens | +| --- | --- | --- | +| `key` | chaîne | **l'identité stable** : `python`, `pytest`, `shell`, `provider`, `kvm`, `incus`, `terraform`, `ansible`, `libvirt_pool`, `iso_tool`, `labs`, `lab_home` | +| `state` | chaîne | `ok`, `failed` ou `choice_required` | +| `ok` | booléen | la même chose que `state == "ok"`, gardé pour une lecture vert/rouge immédiate | +| `label` | chaîne | le nom du composant, traduit : pour l'affichage seulement | +| `detail` | chaîne | ce qui a été mesuré : une version, une ligne d'erreur, un compte | +| `fix` | chaîne ou null | une commande shell que `dsoxlab doctor --fix` joue telle quelle | +| `hint` | chaîne ou null | un geste que seul un humain doit poser : une page d'installation, une décision | + +`state: choice_required` existe parce qu'une décision n'est pas une panne : un +catalogue qui déclare plusieurs providers sans qu'aucun soit choisi bloque bien +le provisionnement, mais rien n'est cassé, et l'afficher en rouge reviendrait à +traiter un choix comme une avarie. + +`ok` ne porte que sur `required`, délibérément. Un hyperviseur que ce catalogue +n'utilisera jamais n'a pas à peindre en rouge une machine qui va très bien. + +`fix` et `hint` restent séparés à dessein : l'un est une commande, l'autre une +phrase. Les fondre ferait exécuter une URL de documentation par une +automatisation. + +## `validate-structure` + +```json +{ + "schema": 1, + "ok": false, + "labs_checked": 87, + "doc_urls_checked": false, + "issues": [ + { + "kind": "structure", + "key": "struct_missing_file", + "params": { "name": "test_functional.py" }, + "message": "Fichier manquant : test_functional.py", + "lab": "labo-tordu", + "path": "/home/…/labs/domaine/labo-tordu/challenge/tests/test_functional.py", + "field": null + } + ], + "counts": { + "contract": 0, "unknown_key": 1, "structure": 1, + "content": 1, "doc_url": 0, "metadata": 3 + } +} +``` + +| Champ | Type | Sens | +| --- | --- | --- | +| `ok` | booléen | le verdict, aligné sur le code de retour : `false` veut dire code 1 | +| `labs_checked` | entier | labs réellement découverts et validés | +| `doc_urls_checked` | booléen | `--check-urls` a-t-il été passé ; sans lui, `doc_url: 0` veut dire *non regardé*, pas *toutes vivantes* | +| `issues` | liste | chaque anomalie, dans l'ordre où les contrôles sont joués | +| `counts` | objet | une entrée par famille, **toujours toutes**, y compris à zéro | + +Chaque anomalie : + +| Champ | Type | Sens | +| --- | --- | --- | +| `kind` | chaîne | la famille : `contract`, `unknown_key`, `structure`, `content`, `doc_url`, `metadata` | +| `key` | chaîne | **l'identité stable de la règle qui a parlé** : c'est là-dessus qu'on filtre, compte et compare | +| `params` | objet | les faits de cette règle, valeurs ramenées à des chaînes et des nombres | +| `message` | chaîne | la même chose dite à un humain, traduite | +| `lab` | chaîne ou null | l'identifiant du lab ; `null` pour les anomalies trouvées avant la découverte (`contract`, `unknown_key`) | +| `path` | chaîne ou null | chemin absolu du fichier en cause | +| `field` | chaîne ou null | le champ de métadonnée en cause, sur les anomalies `metadata` seulement | + +`counts` porte toujours les six familles. En omettre les vides laisserait un +tableau de bord incapable de distinguer une famille saine d'une famille que +cette version de l'outil ne connaît pas. + +Quand le `meta.yml` lui-même est illisible, la validation s'arrête là : le +document garde la même forme, `labs_checked` vaut 0, et le code de retour est 1. +Ce fichier décrit tout le catalogue, donc chaque contrôle suivant ne serait plus +qu'une supposition. + +## `support` + +Le rapport anonymisé que `dsoxlab support` rend en Markdown, sous forme de +document. Ses clés de premier niveau sont `dsoxlab`, `python`, `systeme`, +`distribution`, `architecture`, `shell`, `outils`, `catalogue`, `etat` et +`journal`. C'est un dossier de diagnostic destiné à une issue, pas un état sur +lequel bâtir un tableau de bord : les chemins personnels et les adresses +publiques y sont remplacés avant l'affichage. + +--- + +## La règle d'évolution + +**Un champ ajouté laisse `schema` où il est.** Un consommateur qui ignore les +champs qu'il ne connaît pas continue de fonctionner, et c'est pourquoi il faut +qu'il le fasse. Les données facultatives arrivent par ce chemin. + +**Un champ qui change de sens, qui est renommé ou qui disparaît incrémente +`schema`.** De même pour le sens d'un jeton `state` ou `kind`, et pour la forme +d'un objet imbriqué. Un consommateur qui lit `schema` en premier peut alors +refuser de deviner. + +Deux choses ne font explicitement **pas** partie du contrat, et ne doivent pas +être analysées : + +- **le texte traduit** : `label`, `message`, `detail`, `notes`. Il suit + `DSOXLAB_LANG` et est réécrit dès que la formulation s'améliore ; +- **la sortie brute d'un autre outil** : `check.output` est celle de pytest, + telle quelle. + +Deux choses en font partie, et s'oublient facilement : + +- les **jetons stables** : `key`, `state`, `kind`, `status`, `cause`, ainsi que + le `type` et la `session` du runtime. De nouvelles valeurs peuvent apparaître : + traitez une valeur inconnue comme inconnue, pas comme une erreur ; +- les **codes de retour**, que `--json` ne change jamais. + +Les règles vivent à côté du code, dans `src/dsoxlab/reporting/machine.py`, et +les tests qui les tiennent dans `tests/test_json_output.py` et +`tests_e2e/test_parcours.py`. Ces derniers lancent le binaire installé dans un +sous-processus et analysent sa sortie standard sans rien en retirer : c'est la +seule façon d'attraper un message qu'aurait imprimé autre chose que la CLI. diff --git a/docs/machine-output.md b/docs/machine-output.md new file mode 100644 index 0000000..3de891b --- /dev/null +++ b/docs/machine-output.md @@ -0,0 +1,424 @@ +# The machine output + +**Audience:** you are writing something that *reads* dsoxlab — an editor +extension, a dashboard, a CI step, a grading script. This page is the contract +you may build on, and the only part of the output that is meant to be parsed. + +**Language:** [English](./machine-output.md) · [Français](./machine-output.fr.md) + +Everything else dsoxlab prints is for eyes: Rich tables whose width follows the +terminal, colours, progress bars. It is *made* to move. `--json` gives you a +document instead, and this page says what is in it. + +--- + +## Three rules + +**1. Standard output carries the document, and nothing else.** In `--json` mode +the context banner, the tips and the update notice all go to standard error. So +`json.loads(stdout)` works without stripping anything, and that is exactly how +the test suite asserts it — a message slipped in front of the document, or a +progress bar left behind it, makes it raise. + +**2. Every document carries a `schema`.** It is the first field, and it tells a +consumer whether it speaks the same language before reading the rest. The +current value is **1**. + +**3. A verdict is read from a key and a state, never from a label.** Checks and +issues carry a stable identifier (`key`) and, where there is a verdict, a state +token (`ok`, `failed`, `choice_required`). The translated label sits *beside* +them, for display. No integration should ever have to parse English or French +to know whether something is green or red. + +And one consequence worth stating on its own: **`--json` changes the shape of +the output, never the verdict nor the exit code.** `check` on a failing lab +exits 1 with or without it; `validate-structure` exits 1 as soon as one lab +fails; `doctor` exits 0 either way and puts its verdict in `ok`. On a *hard* +error — an unknown lab id, an unreadable `meta.yml` — standard output stays +empty, the reason goes to standard error, and the exit code is unchanged. Read +the exit code first. + +--- + +## The commands that take `--json` + +| Command | Document | Exit codes | +| --- | --- | --- | +| `dsoxlab list-labs` | the catalog | 0 | +| `dsoxlab show ` | one lab and its runtime status | 0, or 1 if the id is unknown (no document) | +| `dsoxlab progress` | the catalog plus a progression summary | 0 | +| `dsoxlab next` | the suggested lab and what remains | 0, or 1 with no active context (no document) | +| `dsoxlab scores` | the score history and exam verdicts | 0 | +| `dsoxlab check ` | the test result and the score | 0, or 1 when the lab fails (document still printed) | +| `dsoxlab status` | SSH reachability of the declared hosts | 0, or 1 when a declared host does not answer (document still printed) | +| `dsoxlab doctor` | the environment diagnosis | 0, always — the verdict is in `ok` | +| `dsoxlab validate-structure` | every contract issue found | 0, or 1 as soon as one lab fails (document still printed) | +| `dsoxlab support` | the anonymised diagnostic report | 0 | + +`doctor --json --fix` is refused, and says so on standard error: the remediation +commands write to standard output, which would leave the document unreadable. +Read the diagnosis first, act on it second. + +--- + +## The lab object + +Five documents (`list-labs`, `show`, `progress`, `next` and `check`) embed the +same lab object. It is described once here. + +| Field | Type | Meaning | +| --- | --- | --- | +| `id` | string | the lab identifier, unique in the catalog, and the key every command takes | +| `title` | string | display title, in the catalog language | +| `section` | string | the section it belongs to, defaulting to `repo.category` | +| `bloc` | int or null | the teaching block, derived from the position in `meta.yml` | +| `bloc_order` | int or null | the rank inside that block — `next` follows this order | +| `level` | string | free-form level (`l1`, `rhcsa`, …) | +| `type` | string | `lab`, `challenge` or `capstone` | +| `exam_passing_score` | int or null | pass mark, as a percentage of the scale. `null` on an ordinary lab | +| `difficulty` | string or null | free-form, never validated | +| `estimated_time` | string or null | free-form, e.g. `"30m"` | +| `skills` | array of strings | never empty; the validator requires it | +| `distros` | array of strings | never empty; same | +| `doc_url` | string | the online guide, `http` or `https` | +| `path` | string | **absolute** path of the lab directory, so an editor can open its files | +| `runtime.type` | string | `shell` or `vm` | +| `runtime.session` | string | `target` or `local` | +| `runtime.target` | string or null | the resolved target host, `null` on a `shell` lab | +| `runtime.workdir` | string | working directory, relative to `path` | +| `best_score` | object or null | `{"points": int, "max": int}`, or `null` when the lab was **never attempted** | + +`best_score: null` is not a zero. A lab that has never been played and a lab +played and failed are different states, and an interface that merges them tells +the learner something false. + +--- + +## `list-labs` + +```json +{ + "schema": 1, + "labs": [ { "id": "l1-first-terminal", "…": "…" } ], + "count": 20 +} +``` + +| Field | Type | Meaning | +| --- | --- | --- | +| `labs` | array of lab objects | filtered by the options and by the active context | +| `count` | int | the size of `labs`, so a consumer need not compute it | + +## `show` + +```json +{ + "schema": 1, + "lab": { "id": "l1-first-terminal", "…": "…" }, + "status": "ready" +} +``` + +| Field | Type | Meaning | +| --- | --- | --- | +| `lab` | lab object | including its `best_score` | +| `status` | string or null | `ready`, `stopped`, or `null` when the runtime cannot answer | + +`status` is a token, not a sentence: it does not follow the display language. + +## `progress` + +```json +{ + "schema": 1, + "labs": [ { "…": "…" } ], + "summary": { "total": 84, "attempted": 12, "points": 940, "max_points": 1200 } +} +``` + +| Field | Type | Meaning | +| --- | --- | --- | +| `labs` | array of lab objects | sorted by `bloc`, then `bloc_order`, then `id` | +| `summary.total` | int | labs in scope | +| `summary.attempted` | int | labs with at least one recorded result | +| `summary.points` | int | points obtained, summed over the attempted labs only | +| `summary.max_points` | int | the scale of those same labs | + +## `next` + +```json +{ + "schema": 1, + "context": { "section": "l1", "level": null }, + "next": { "id": "l1-first-terminal", "…": "…" }, + "all_done": false, + "remaining": 12 +} +``` + +| Field | Type | Meaning | +| --- | --- | --- | +| `context.section` | string | the active section — `next` needs one, and exits 1 without | +| `context.level` | string or null | the active level, when one is set | +| `next` | lab object or null | the first lab with no recorded result, in teaching order | +| `all_done` | bool | true only when the section holds labs and every one has a result | +| `remaining` | int | labs with no recorded result at all | + +`all_done` and `next: null` are not the same statement: an empty section also +yields `next: null`, and a consumer that congratulated the learner on it would +be celebrating a course that never started. + +## `scores` + +```json +{ + "schema": 1, + "results": [ + { + "lab_id": "aws-provider-aws-first-ec2", + "section": "aws", + "score": 100, + "max_score": 100, + "passed_tests": 11, + "total_tests": 11, + "hints_used": 0, + "validated_at": "2026-08-13T13:30:12.831759+00:00", + "exam": null + } + ], + "count": 1 +} +``` + +| Field | Type | Meaning | +| --- | --- | --- | +| `results` | array | most recent first, capped by `--top` | +| `results[].score` / `max_score` | int | the recorded mark and its scale | +| `results[].passed_tests` / `total_tests` | int | what pytest reported | +| `results[].hints_used` | int | hints taken, which is what lowered the score | +| `results[].validated_at` | string | ISO 8601, UTC | +| `results[].exam` | object or null | `null` on an ordinary lab; otherwise `{"passing_score", "percentage", "passed"}` | + +`exam: null` means *not an exam*, and it is deliberately not `false`: an +ordinary lab is not a failed exam. The comparison behind `passed` is done in +integers, never on a rounded percentage — a pass mark does not round in the +candidate's favour. + +## `check` + +```json +{ + "schema": 1, + "lab": { "id": "premiers-pas", "…": "…" }, + "check": { + "ok": true, + "passed": 3, + "total": 3, + "score": 100, + "max_score": 100, + "output": "=== test session starts ===\n…" + } +} +``` + +| Field | Type | Meaning | +| --- | --- | --- | +| `check.ok` | bool | every test passed | +| `check.passed` / `total` | int | tests passed, tests run | +| `check.score` / `max_score` | int | the mark recorded in the catalog database | +| `check.output` | string | pytest's raw output, where the detail of a failure lives | + +The command exits 1 when `ok` is false, and still prints the document. + +## `status` + +```json +{ + "schema": 1, + "provider": "kvm", + "hypervisor": { "queryable": true, "error": null }, + "hosts": [ + { + "fqdn": "alma-rhcsa-1.lab", + "ip": "10.10.10.11", + "reachable": false, + "reason": "Connection timed out", + "domain": "alma-rhcsa-1", + "domain_state": "shut off", + "cause": "domain_not_running" + } + ], + "summary": { "reachable": 0, "total": 1 } +} +``` + +| Field | Type | Meaning | +| --- | --- | --- | +| `provider` | string or null | the active infra provider; `null` on a catalog with no hosts | +| `hypervisor.queryable` | bool | whether machine state could be asked of the backend | +| `hypervisor.error` | string or null | why it could not, when it could not | +| `hosts[].reachable` | bool | SSH answered | +| `hosts[].reason` | string or null | the last line of the SSH failure, when it failed | +| `hosts[].domain` / `domain_state` | string or null | what the hypervisor says, when it can be asked | +| `hosts[].cause` | string | a **stable token** naming the diagnosis, not a sentence | +| `summary.reachable` / `total` | int | hosts that answered, hosts declared | + +The command exits 1 as soon as one declared host does not answer, and prints +the document anyway: that document is precisely what says which one, and why. + +A catalog with no `infra:` block is a normal case, not an error: it yields +`provider: null`, `hosts: []` and a zeroed summary, and exits 0. + +## `doctor` + +```json +{ + "schema": 1, + "ok": true, + "required": [ + { + "key": "pytest", + "state": "ok", + "ok": true, + "label": "pytest", + "detail": "bundled with dsoxlab (the one `check` uses)", + "fix": null, + "hint": null + } + ], + "informational": [ + { + "key": "kvm", + "state": "failed", + "ok": false, + "label": "virsh/KVM", + "detail": "virsh not found", + "fix": "sudo apt install libvirt-clients libvirt-daemon-system qemu-kvm", + "hint": null + } + ], + "notes": ["No lab in this catalog uses a VM: the hypervisors above are informational."] +} +``` + +| Field | Type | Meaning | +| --- | --- | --- | +| `ok` | bool | **the verdict**, and it covers `required` only | +| `required` | array of checks | what blocks *this* catalog | +| `informational` | array of checks | components this catalog does not need, never an error | +| `notes` | array of strings | translated sentences explaining *why* a component is informational here | + +Each check: + +| Field | Type | Meaning | +| --- | --- | --- | +| `key` | string | **the stable identity**: `python`, `pytest`, `shell`, `provider`, `kvm`, `incus`, `terraform`, `ansible`, `libvirt_pool`, `iso_tool`, `labs`, `lab_home` | +| `state` | string | `ok`, `failed`, or `choice_required` | +| `ok` | bool | the same thing as `state == "ok"`, kept for a plain green/red reading | +| `label` | string | the component's name, translated — for display only | +| `detail` | string | what was measured: a version, an error line, a count | +| `fix` | string or null | a shell command that `dsoxlab doctor --fix` runs as is | +| `hint` | string or null | a step only a human should take — an install page, a decision | + +`state: choice_required` exists because a decision is not a failure: a catalog +that declares several providers and has none selected blocks provisioning, but +nothing is broken, and painting it red treats a choice like an outage. + +`ok` covers `required` only, deliberately. A hypervisor this catalog will never +use must not turn a perfectly healthy machine red. + +`fix` and `hint` are kept apart on purpose: one is a command, the other is a +sentence. Merging them would have an automation run a documentation URL. + +## `validate-structure` + +```json +{ + "schema": 1, + "ok": false, + "labs_checked": 87, + "doc_urls_checked": false, + "issues": [ + { + "kind": "structure", + "key": "struct_missing_file", + "params": { "name": "test_functional.py" }, + "message": "Missing file: test_functional.py", + "lab": "labo-tordu", + "path": "/home/…/labs/domaine/labo-tordu/challenge/tests/test_functional.py", + "field": null + } + ], + "counts": { + "contract": 0, "unknown_key": 1, "structure": 1, + "content": 1, "doc_url": 0, "metadata": 3 + } +} +``` + +| Field | Type | Meaning | +| --- | --- | --- | +| `ok` | bool | the verdict, matching the exit code: `false` means exit 1 | +| `labs_checked` | int | labs actually discovered and validated | +| `doc_urls_checked` | bool | whether `--check-urls` was passed — without it, `doc_url: 0` means *not looked at*, not *all alive* | +| `issues` | array | every anomaly, in the order the checks run | +| `counts` | object | one entry per family, **always all of them**, zero included | + +Each issue: + +| Field | Type | Meaning | +| --- | --- | --- | +| `kind` | string | the family: `contract`, `unknown_key`, `structure`, `content`, `doc_url`, `metadata` | +| `key` | string | **the stable identity of the rule that fired** — filter, count and compare on this | +| `params` | object | the facts of that rule, values reduced to strings and numbers | +| `message` | string | the same thing said to a human, translated | +| `lab` | string or null | the lab id; `null` for issues found before discovery (`contract`, `unknown_key`) | +| `path` | string or null | absolute path of the file at fault | +| `field` | string or null | the metadata field at fault, on `metadata` issues only | + +`counts` always carries all six families. Omitting the empty ones would leave a +dashboard unable to tell a healthy family from one this version of the tool does +not know about. + +When `meta.yml` itself cannot be read, validation stops there: the document has +the same shape, `labs_checked` is 0, and the exit code is 1. The file describes +the whole catalog, so every later check would be guesswork. + +## `support` + +The anonymised report `dsoxlab support` prints as Markdown, as a document. Its +top-level keys are `dsoxlab`, `python`, `systeme`, `distribution`, +`architecture`, `shell`, `outils`, `catalogue`, `etat` and `journal`. It is a +diagnostic bundle meant for an issue, not a state to build a dashboard on: +personal paths and public addresses are replaced before it is printed. + +--- + +## The evolution rule + +**Adding a field keeps `schema` where it is.** A consumer that ignores unknown +fields keeps working, which is why one should. New optional data lands this way. + +**Changing what a field means, renaming it, or removing it increments +`schema`.** So does changing the meaning of a `state` or `kind` token, or the +shape of a nested object. A consumer that reads `schema` first can refuse to +guess. + +Two things are explicitly **not** part of the contract, and must not be parsed: + +- **translated text** — `label`, `message`, `detail`, `notes`. They follow + `DSOXLAB_LANG` and are rewritten whenever the wording improves; +- **the raw output of another tool** — `check.output` is pytest's, verbatim. + +Two things that *are* part of it, and are easy to overlook: + +- the **stable tokens**: `key`, `state`, `kind`, `status`, `cause`, and the + runtime `type` and `session`. New values may appear — treat an unknown one as + unknown, not as an error; +- the **exit codes**, which `--json` never changes. + +The rules live next to the code, in `src/dsoxlab/reporting/machine.py`, and the +tests that hold them in `tests/test_json_output.py` and +`tests_e2e/test_parcours.py`. The end-to-end ones run the installed binary in a +subprocess and parse its standard output without stripping it: that is the only +way to catch a stray message printed by something other than the CLI itself. diff --git a/pyproject.toml b/pyproject.toml index 5369603..2d5c6f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "dsoxlab" -version = "0.1.64" +version = "0.1.65" description = "Turn declarative exercises into reproducible, runnable and verifiable lab environments" readme = "README.md" requires-python = ">=3.11" diff --git a/src/dsoxlab/cli.py b/src/dsoxlab/cli.py index 0b3f55e..5548567 100644 --- a/src/dsoxlab/cli.py +++ b/src/dsoxlab/cli.py @@ -860,6 +860,7 @@ def list_labs( def show( lab_id: Annotated[str, typer.Argument(help=_("cmd_show_arg"), autocompletion=_complete_lab_id)], lab_home: LabHomeOption = None, + as_json: Annotated[bool, typer.Option("--json", help=_("opt_json"))] = False, ) -> None: root = _root(lab_home) lang = _lang(root) @@ -869,10 +870,25 @@ def show( error(str(exc)) raise typer.Exit(1) from None + # Le drapeau et non une comparaison de chaînes : `status` est un jeton + # (`ready` / `stopped`) sauf dans ce cas-là, où c'est une phrase traduite. + # Le mode machine doit distinguer les deux sans lire du français. + indisponible = False try: status = lab_status(lab) except RuntimeError: status = _("runtime_unavailable") + indisponible = True + + if as_json: + # `best_score` doit dire la vérité : le laisser à `null` sur un lab + # déjà noté se lirait « jamais tenté ». + scores = get_best_scores(root, [lab.id]) + machine.emit({ + "lab": machine.lab_dict(lab, scores.get(lab.id)), + "status": None if indisponible else status, + }) + return print_lab_detail(lab, status=status) @@ -1430,6 +1446,7 @@ def scores( section: Annotated[str | None, typer.Option("--section", "-s", help=_("opt_section"))] = None, lab_id: Annotated[str | None, typer.Option("--lab", "-l", help=_("opt_filter_lab"))] = None, top: Annotated[int, typer.Option("--top", help=_("opt_top"))] = 20, + as_json: Annotated[bool, typer.Option("--json", help=_("opt_json"))] = False, ) -> None: root = _root(lab_home) ctx = read_context(root) @@ -1440,9 +1457,15 @@ def scores( affiches = {r["lab_id"] for r in results} seuils = { lab.id: lab.exam_passing_score - for lab in _catalogue(root, _lang(root)) + for lab in _catalogue(root, _lang(root), quiet=as_json) if lab.exam_passing_score and lab.id in affiches } + if as_json: + machine.emit({ + "results": [machine.score_dict(r, seuils.get(r["lab_id"])) for r in results], + "count": len(results), + }) + return print_scores_table(results, seuils) @@ -1493,16 +1516,20 @@ def progress( @app.command("next", help=_("cmd_next_help")) def next_lab( lab_home: LabHomeOption = None, + as_json: Annotated[bool, typer.Option("--json", help=_("opt_json"))] = False, ) -> None: root = _root(lab_home) ctx = read_context(root) lang = _lang(root) + # Erreur dure, en `--json` comme ailleurs : sans contexte il n'y a rien à + # décrire. La cause part sur stderr, la sortie standard reste vide, et le + # code de retour ne change pas. if not ctx.section: error(_("next_no_context")) raise typer.Exit(1) - labs = _catalogue(root, lang) + labs = _catalogue(root, lang, quiet=as_json) if ctx.section: labs = [lab for lab in labs if lab.section == ctx.section] if ctx.level: @@ -1511,6 +1538,18 @@ def next_lab( scores_data = get_best_scores(root, [lab.id for lab in labs]) upcoming = next_pending_lab(labs, scores_data) + if as_json: + # `next` et `all_done` disent deux choses différentes : un contexte + # sans lab du tout rendrait aussi `next: null`, et l'appelant fêterait + # une section terminée qui est en fait vide. + machine.emit({ + "context": {"section": ctx.section, "level": ctx.level or None}, + "next": None if upcoming is None + else machine.lab_dict(upcoming, scores_data.get(upcoming.id)), + "all_done": upcoming is None and bool(labs), + "remaining": sum(1 for lab in labs if lab.id not in scores_data), + }) + return if upcoming is None: success(_("next_all_done")) return @@ -1600,12 +1639,30 @@ def clean( # ── validate-structure ──────────────────────────────────────────────────────── +#: Les familles de contrôle de ``validate-structure``, dans l'ordre où elles +#: sont jouées. Elles figurent **toutes** dans ``counts``, à zéro s'il le faut : +#: un tableau de bord qui n'aurait pas la clé ne saurait pas si la famille est +#: saine ou si cette version de l'outil ne la connaît pas. +_FAMILLES_ANOMALIES = ( + "contract", "unknown_key", "structure", "content", "doc_url", "metadata", +) + + +def _compter(anomalies: list[dict[str, Any]]) -> dict[str, int]: + """Le nombre d'anomalies par famille, toutes familles présentes.""" + compte = dict.fromkeys(_FAMILLES_ANOMALIES, 0) + for anomalie in anomalies: + compte[str(anomalie["kind"])] += 1 + return compte + + @app.command("validate-structure", help=_("cmd_validate_help")) def validate_structure_cmd( lab_home: LabHomeOption = None, check_urls: Annotated[bool, typer.Option( "--check-urls", help=_("opt_check_urls"), )] = False, + as_json: Annotated[bool, typer.Option("--json", help=_("opt_json"))] = False, ) -> None: from .discovery.repo import read_repo_metadata from .discovery.scanner import discover_labs @@ -1628,42 +1685,74 @@ def _rendu(chemin: Path) -> Path: except ValueError: return chemin + # Le document machine se construit au fil des contrôles, dans le même ordre + # que l'affichage : une seule passe, deux rendus, et donc aucun risque que + # l'un rapporte une anomalie que l'autre tait. + documents: list[dict[str, Any]] = [] + + def _rendre(entete: str, lignes: list[str]) -> None: + """Affiche un groupe d'anomalies, sauf en mode machine.""" + if as_json or not lignes: + return + console.print(_(entete)) + for ligne in lignes: + console.print(ligne) + # D'ABORD, et à la source : un `schema_version` illisible ou trop récent # empêche le fichier d'être découvert, donc TOUS les contrôles suivants # l'ignoreraient — c'est le trou connu du validator, qui n'itère que sur ce # qui a déjà été chargé. Ce contrôle-ci relit les fichiers du disque. contract = validate_schema_versions(root) - if not contract.ok: - console.print(_("contract_issues_header")) - for anomalie in contract.issues: - console.print( - f" [red]✘[/red] {_rendu(anomalie.path)}: " - f"{_(anomalie.key, **anomalie.params)}" - ) - # Le meta.yml décrit tout le catalogue : illisible, il rend chaque - # contrôle suivant douteux, et la découverte lèverait de toute façon. - # Un lab isolé, lui, n'empêche pas de valider les 283 autres. - if contract.meta_is_unreadable: - error(_("labs_have_issues")) + documents += [ + machine.issue_dict("contract", a.key, a.params, path=a.path) + for a in contract.issues + ] + _rendre("contract_issues_header", [ + f" [red]✘[/red] {_rendu(a.path)}: {_(a.key, **a.params)}" + for a in contract.issues + ]) + # Le meta.yml décrit tout le catalogue : illisible, il rend chaque + # contrôle suivant douteux, et la découverte lèverait de toute façon. + # Un lab isolé, lui, n'empêche pas de valider les 283 autres. + if not contract.ok and contract.meta_is_unreadable: + if as_json: + # Même forme de document que la sortie complète : un appelant ne + # doit pas avoir deux structures à gérer selon l'endroit où la + # validation s'est arrêtée. + machine.emit({ + "ok": False, + "labs_checked": 0, + "doc_urls_checked": False, + "issues": documents, + "counts": _compter(documents), + }) raise typer.Exit(1) + error(_("labs_have_issues")) + raise typer.Exit(1) # Ensuite, toujours à la source : les clés que le moteur n'ira jamais lire. # Le parseur les ignore et continuera de le faire — c'est une garantie de # la v1 — mais « toléré » n'est pas « voulu » : onze labs d'examen ont posé # un seuil de réussite que personne ne lisait, sans que rien ne le dise. unknown = validate_unknown_keys(root) - if not unknown.ok: - console.print(_("unknown_keys_header")) - for anomalie in unknown.issues: - console.print( - f" [red]✘[/red] {_rendu(anomalie.path)}: " - f"{_(anomalie.key, **anomalie.params)}" - ) + documents += [ + machine.issue_dict("unknown_key", a.key, a.params, path=a.path) + for a in unknown.issues + ] + _rendre("unknown_keys_header", [ + f" [red]✘[/red] {_rendu(a.path)}: {_(a.key, **a.params)}" + for a in unknown.issues + ]) structure_reports = validate_all_structure(root) metadata_reports = validate_all_metadata(root) - print_structure_reports(structure_reports) + documents += [ + machine.issue_dict("structure", i.key, i.params, lab=r.lab_id, path=i.path) + for r in structure_reports for i in r.issues + ] + if not as_json: + print_structure_reports(structure_reports) # Contrôles de contenu : locaux, donc jouables hors ligne et par défaut. # Un lien mort ou une solution en clair ne casse aucun test fonctionnel, @@ -1700,38 +1789,47 @@ def _rendu(chemin: Path) -> Path: for souci in rapport.issues: content_issues.append((lab.id, souci.path, souci)) - if content_issues: - console.print(_("content_issues_header")) - for lab_id, chemin, souci in content_issues: - console.print( - f" [red]✘[/red] {lab_id} — {_rendu(chemin)}: " - f"{_(souci.key, **souci.params)}" - ) + documents += [ + machine.issue_dict("content", s.key, s.params, lab=lab_id, path=chemin) + for lab_id, chemin, s in content_issues + ] + _rendre("content_issues_header", [ + f" [red]✘[/red] {lab_id} — {_rendu(chemin)}: {_(s.key, **s.params)}" + for lab_id, chemin, s in content_issues + ]) url_issues: list[tuple[str, str, ContentIssue]] = [] if check_urls: - info(_("checking_doc_urls", count=len(labs))) + # Sur stdout : tue en mode machine, où un « ℹ » suffit à rendre le + # document illisible. + if not as_json: + info(_("checking_doc_urls", count=len(labs))) for lab in labs: injoignable = check_doc_url(lab) if injoignable is not None: url_issues.append((lab.id, lab.doc_url, injoignable)) - if url_issues: - console.print(_("doc_url_issues_header")) - for lab_id, url, raison in url_issues: - console.print( - f" [red]✘[/red] {lab_id} — {url} — " - f"{_(raison.key, **raison.params)}" - ) + documents += [ + # L'URL entre dans les paramètres : c'est le fait que l'appelant + # veut, et il n'a pas à relire le catalogue pour l'obtenir. + machine.issue_dict( + "doc_url", r.key, {**r.params, "url": url}, lab=lab_id, path=r.path, + ) + for lab_id, url, r in url_issues + ] + _rendre("doc_url_issues_header", [ + f" [red]✘[/red] {lab_id} — {url} — {_(r.key, **r.params)}" + for lab_id, url, r in url_issues + ]) issues = [r for r in metadata_reports if not r.ok] - if issues: - console.print(_("metadata_issues_header")) - for report in issues: - for issue in report.issues: - console.print( - f" [red]✘[/red] {report.lab_id} — {issue.field}: " - f"{_(issue.key, **issue.params)}" - ) + documents += [ + machine.issue_dict("metadata", i.key, i.params, lab=r.lab_id, field=i.field) + for r in issues for i in r.issues + ] + _rendre("metadata_issues_header", [ + f" [red]✘[/red] {r.lab_id} — {i.field}: {_(i.key, **i.params)}" + for r in issues for i in r.issues + ]) all_ok = ( contract.ok @@ -1741,6 +1839,23 @@ def _rendu(chemin: Path) -> Path: and not content_issues and not url_issues ) + if as_json: + machine.emit({ + "ok": all_ok, + "labs_checked": len(labs), + # `--check-urls` est le seul contrôle réseau, et le seul qui ne + # tourne pas par défaut : sans ce champ, un appelant ne peut pas + # distinguer « aucune URL morte » de « les URL n'ont pas été + # regardées ». + "doc_urls_checked": check_urls, + "issues": documents, + "counts": _compter(documents), + }) + # Le verdict et le code de retour sont ceux du mode terminal : seule la + # forme de la sortie change. + if not all_ok: + raise typer.Exit(1) + return if all_ok: success(_("all_labs_valid")) else: @@ -1754,9 +1869,19 @@ def _rendu(chemin: Path) -> Path: def doctor( lab_home: LabHomeOption = None, fix: Annotated[bool, typer.Option("--fix", help=_("opt_fix"))] = False, + as_json: Annotated[bool, typer.Option("--json", help=_("opt_json"))] = False, ) -> None: root = _root(lab_home) + # `--fix` joue des commandes système (`apt`, `systemctl`, `usermod`) dont la + # sortie va sur la sortie standard, qu'aucune option ne détourne. Les deux + # options ensemble produiraient donc un document précédé de la sortie d'apt, + # c'est-à-dire un flux qu'aucun appelant ne peut lire. On le dit plutôt que + # de rendre du JSON cassé. + if as_json and fix: + error(_("doctor_json_sans_fix")) + raise typer.Exit(1) + # Le meta.yml décide de ce qui est bloquant ici : un dépôt sans lab `vm` # n'a besoin d'aucun hyperviseur, et un dépôt qui a choisi son provider # n'a pas besoin des autres. Son absence n'est pas une erreur : on @@ -1770,6 +1895,13 @@ def doctor( repo_meta = None report = collect_checks(root, repo_meta) + if as_json: + # Le code de retour ne change pas : `doctor` sort en 0 même quand un + # contrôle échoue, et le verdict se lit dans `ok`. Un `--json` qui + # inventerait un code non nul ferait diverger les deux modes. + machine.emit(machine.doctor_dict(report)) + return + print_doctor(report) if fix: diff --git a/src/dsoxlab/i18n/strings/en.py b/src/dsoxlab/i18n/strings/en.py index b449efa..ee16bbb 100644 --- a/src/dsoxlab/i18n/strings/en.py +++ b/src/dsoxlab/i18n/strings/en.py @@ -318,8 +318,10 @@ [dim]--level / -l[/dim] Filter by level. [dim]--type / -t[/dim] Filter by type: [bold]lab[/bold], [bold]challenge[/bold] or [bold]capstone[/bold]. [dim]--bloc / -b[/dim] Filter by bloc number (1–8). + [dim]--json[/dim] The catalog as a machine document. [cyan]show [/cyan] Full details of a lab (skills, runtime, links …). + [dim]--json[/dim] The lab and its runtime status, as a document. [cyan]run [/cyan] Start the lab environment (a shell, or a provisioned vm). @@ -347,6 +349,7 @@ [cyan]check[/cyan] [dim][][/dim] Run tests, compute score, save to history. Score = 100 − (hints used × cost per hint). + [dim]--json[/dim] The result as a document. Same exit code. [dim][/dim] is optional if a lab is active in the session. [cyan]submit[/cyan] [dim][][/dim] Final submission: run tests, save score, then type [bold]exit[/bold] to end the session. @@ -356,14 +359,17 @@ [dim][/dim] is optional if a lab is active in the session. [cyan]progress[/cyan] Bloc-by-bloc progression summary (labs done, score, challenge, capstone). + [dim]--json[/dim] The same progression, as a document. [cyan]next[/cyan] Recommend the next lab to complete in the active context. + [dim]--json[/dim] The suggestion and what remains, as a document. [cyan]scores[/cyan] Show score history. A [bold]Verdict[/bold] column appears when the catalog holds at least one lab with an exam pass mark. [dim]--section / -s[/dim] Filter by section. [dim]--lab / -l[/dim] Filter by lab. [dim]--top / -n[/dim] Limit number of results. + [dim]--json[/dim] The history and each exam verdict, as a document. [cyan]reset [/cyan] Clean + restart the lab from scratch. @@ -371,12 +377,16 @@ [dim]--yes / -y[/dim] Skip confirmation. [cyan]validate-structure[/cyan] Check all lab.yaml files and directory layout. + [dim]--json[/dim] Every issue with its rule key, as a document. + Same exit code: 1 as soon as one lab fails. [cyan]doctor[/cyan] Diagnose the environment. The [bold]Required[/bold] table lists what blocks this very repo; a hypervisor useless here stays [bold]Informational[/bold] and never shows up as an error. [dim]--fix[/dim] Remediate the missing required components. Informational components are left alone. + [dim]--json[/dim] The diagnosis as a document, each check carrying a + stable key and state. Not with [bold]--fix[/bold]. [cyan]demo[/cyan] Install a demonstration catalog and a first lab you can play right away, with nothing to clone or provision. @@ -393,6 +403,7 @@ be queried, the hypervisor is [bold]asked[/bold]: a domain that does not exist, one that is stopped and one that is booting call for three different gestures. + [dim]--json[/dim] Host reachability, as a document. [cyan]ssh [/cyan] Open an interactive session on a lab host. @@ -431,6 +442,30 @@ standard output: [bold]--json[/bold] stays machine-readable, even in verbose mode.""", + "fullhelp_machine": """\ +[bold]Machine output[/bold] + + [bold]--json[/bold] turns a command into one document meant for a program: an editor + extension, a dashboard, a tracking script. Ten commands take it: [bold]list-labs[/bold], + [bold]show[/bold], [bold]progress[/bold], [bold]next[/bold], [bold]scores[/bold], [bold]check[/bold], [bold]status[/bold], [bold]doctor[/bold], + [bold]validate-structure[/bold] and [bold]support[/bold]. + + Standard output then carries the document and [bold]nothing else[/bold]. Notices, + tips and the update warning all go to standard error, so a pipe reads clean. + + Every document carries a [bold]schema[/bold] number. Adding a field keeps that number; + changing what a field means increments it. + + A verdict is read from a stable [bold]key[/bold] and a [bold]state[/bold] token, never from the + translated label beside them: no integration should have to parse English or + French to know whether something is green or red. + + [bold]--json[/bold] changes the shape of the output, never the verdict nor the exit + code. On a hard error (unknown lab, unreadable meta.yml) standard output stays + empty, the reason goes to standard error, and the code is unchanged. + + Field by field: [bold]docs/machine-output.md[/bold].""", + "fullhelp_runtimes": """\ [bold]Runtimes[/bold] @@ -748,6 +783,11 @@ # ── doctor — fix ────────────────────────────────────────────────────────── "fix_nothing": "No remediation needed.", + "doctor_json_sans_fix": + "--json and --fix cannot be combined: the remediation commands write to " + "standard output, which would leave the document unreadable. Run " + "`dsoxlab doctor --json` to read the diagnosis, then `dsoxlab doctor " + "--fix` to act on it.", "fix_count": "{count} component(s) to fix…", "fix_needs_tty": "At least one remediation requires sudo, but this shell is not " diff --git a/src/dsoxlab/i18n/strings/fr.py b/src/dsoxlab/i18n/strings/fr.py index 7caad42..41a0488 100644 --- a/src/dsoxlab/i18n/strings/fr.py +++ b/src/dsoxlab/i18n/strings/fr.py @@ -323,8 +323,10 @@ [dim]--level / -l[/dim] Filtre par niveau. [dim]--type / -t[/dim] Filtre par type : [bold]lab[/bold], [bold]challenge[/bold] ou [bold]capstone[/bold]. [dim]--bloc / -b[/dim] Filtre par numéro de bloc (1–8). + [dim]--json[/dim] Le catalogue, en document machine. [cyan]show [/cyan] Détail complet d'un lab (compétences, runtime, liens…). + [dim]--json[/dim] Le lab et l'état de son runtime, en document. [cyan]run [/cyan] Démarre l'environnement du lab (un shell, ou une vm provisionnée). @@ -352,6 +354,7 @@ [cyan]check[/cyan] [dim][][/dim] Joue les tests, calcule le score, sauvegarde dans l'historique. Score = 100 − (indices utilisés × coût par indice). + [dim]--json[/dim] Le résultat en document. Même code de retour. [dim][/dim] est optionnel si un lab est actif en session. [cyan]submit[/cyan] [dim][][/dim] Soumission finale : joue les tests, sauvegarde le score, puis tapez [bold]exit[/bold] pour terminer la session. @@ -360,13 +363,16 @@ [yellow]verdict reçu / recalé[/yellow] face à ce seuil. [dim][/dim] est optionnel si un lab est actif en session. [cyan]progress[/cyan] Résumé de progression par bloc (labs faits, score, challenge, capstone). + [dim]--json[/dim] La même progression, en document. [cyan]next[/cyan] Recommande le prochain lab à compléter dans le contexte actif. + [dim]--json[/dim] La suggestion et ce qui reste, en document. [cyan]scores[/cyan] Affiche l'historique des scores. Une colonne [bold]Verdict[/bold] apparaît si le catalogue porte au moins un lab à seuil d'examen. [dim]--section / -s[/dim] Filtre par section. [dim]--lab / -l[/dim] Filtre par lab. [dim]--top / -n[/dim] Limite le nombre de résultats. + [dim]--json[/dim] L'historique et chaque verdict d'examen, en document. [cyan]reset [/cyan] Nettoie et redémarre le lab depuis zéro. @@ -374,12 +380,16 @@ [dim]--yes / -y[/dim] Passe la confirmation. [cyan]validate-structure[/cyan] Vérifie tous les fichiers lab.yaml et l'arborescence. + [dim]--json[/dim] Chaque anomalie avec la clé de sa règle, en document. + Même code de retour : 1 dès qu'un lab échoue. [cyan]doctor[/cyan] Diagnostique l'environnement. Le tableau [bold]Requis[/bold] liste ce qui bloque ce dépôt-ci ; un hyperviseur inutile ici reste [bold]Informatif[/bold] et ne s'affiche jamais en erreur. [dim]--fix[/dim] Applique la remédiation des composants requis manquants. Les composants informatifs ne sont pas touchés. + [dim]--json[/dim] Le diagnostic en document, chaque contrôle portant + une clé et un état stables. Pas avec [bold]--fix[/bold]. [cyan]demo[/cyan] Installe un catalogue de démonstration et un premier lab jouable immédiatement, sans rien cloner ni provisionner. @@ -396,6 +406,7 @@ machines est interrogeable, l'hyperviseur est [bold]interrogé[/bold] : un domaine absent, un domaine arrêté et un domaine qui boote appellent trois gestes différents. + [dim]--json[/dim] La joignabilité des hôtes, en document. [cyan]ssh [/cyan] Ouvre une session interactive sur un hôte du lab. @@ -434,6 +445,31 @@ Il ne va jamais sur la sortie standard : [bold]--json[/bold] reste lisible par un programme, même en mode verbeux.""", + "fullhelp_machine": """\ +[bold]Sortie machine[/bold] + + [bold]--json[/bold] transforme une commande en un document destiné à un programme : + extension d'éditeur, tableau de bord, script de suivi. Dix commandes la prennent : + [bold]list-labs[/bold], [bold]show[/bold], [bold]progress[/bold], [bold]next[/bold], [bold]scores[/bold], [bold]check[/bold], [bold]status[/bold], + [bold]doctor[/bold], [bold]validate-structure[/bold] et [bold]support[/bold]. + + La sortie standard ne porte alors que le document, et [bold]rien d'autre[/bold]. Les + messages d'ambiance, les astuces et l'avis de mise à jour partent tous sur la + sortie d'erreur : un tube reçoit du JSON propre. + + Chaque document porte un numéro de [bold]schema[/bold]. Un champ ajouté le laisse tel + quel ; un champ qui change de sens l'incrémente. + + Un verdict se lit dans une [bold]clé[/bold] stable et un [bold]état[/bold] en jeton, jamais dans le + libellé traduit posé à côté : aucune intégration ne doit avoir à analyser du + français ou de l'anglais pour savoir si c'est vert ou rouge. + + [bold]--json[/bold] change la forme de la sortie, jamais le verdict ni le code de + retour. Sur une erreur dure (lab inconnu, meta.yml illisible), la sortie + standard reste vide, la cause part sur la sortie d'erreur, et le code ne bouge pas. + + Champ par champ : [bold]docs/machine-output.fr.md[/bold].""", + "fullhelp_runtimes": """\ [bold]Runtimes[/bold] @@ -757,6 +793,11 @@ # ── doctor — remédiation ────────────────────────────────────────────────── "fix_nothing": "Aucune remédiation nécessaire.", + "doctor_json_sans_fix": + "--json et --fix ne vont pas ensemble : les commandes de remédiation " + "écrivent sur la sortie standard, et le document en deviendrait " + "illisible. Lancez `dsoxlab doctor --json` pour lire le diagnostic, " + "puis `dsoxlab doctor --fix` pour agir dessus.", "fix_count": "{count} composant(s) à corriger…", "fix_needs_tty": "Au moins une remédiation exige sudo, mais ce shell n'est pas " diff --git a/src/dsoxlab/reporting/console.py b/src/dsoxlab/reporting/console.py index 2b42ea9..7c3d0c0 100644 --- a/src/dsoxlab/reporting/console.py +++ b/src/dsoxlab/reporting/console.py @@ -21,7 +21,7 @@ from ..i18n import _ from ..models.course import CourseManifest, CourseSection from ..models.lab import LabDefinition -from ..services.doctor import Check, DoctorReport +from ..services.doctor import STATE_CHOICE_REQUIRED, Check, DoctorReport from ..services.progress_service import build_progress, exam_verdict from ..validators.structure import StructureReport @@ -280,6 +280,20 @@ def print_structure_reports(reports: list[StructureReport]) -> None: # ── doctor ──────────────────────────────────────────────────────────────────── +def _libelle_statut(check: Check, *, blocking: bool) -> str: + """Le mot qui rend l'état d'un contrôle, dans la langue du lecteur. + + L'état, lui, est un jeton stable porté par le contrôle. Un composant + informatif absent n'est pas un échec, et le vocabulaire change donc de + tableau ; ce qu'une intégration lit, elle, ne change jamais. + """ + if check.state == STATE_CHOICE_REQUIRED: + return _("status_choose") + if blocking: + return _("status_ok") if check.ok else _("status_ko") + return _("status_present") if check.ok else _("status_absent") + + def _doctor_table(title: str, checks: list[Check], *, blocking: bool) -> Table: """Un tableau de checks. @@ -294,12 +308,7 @@ def _doctor_table(title: str, checks: list[Check], *, blocking: bool) -> Table: table.add_column(_('col_remediation')) for check in checks: - if check.status_key: - status = _(check.status_key) - elif blocking: - status = _('status_ok') if check.ok else _('status_ko') - else: - status = _('status_present') if check.ok else _('status_absent') + status = _libelle_statut(check, blocking=blocking) remediation = "" if check.ok else f"[dim]{check.remediation}[/dim]" table.add_row(check.label, status, check.detail, remediation) return table @@ -737,6 +746,7 @@ def print_fullhelp() -> None: ("fullhelp_concept", None), ("fullhelp_workflow", None), ("fullhelp_commands", None), + ("fullhelp_machine", None), ("fullhelp_runtimes", None), ("fullhelp_language", None), ("fullhelp_scoring", None), diff --git a/src/dsoxlab/reporting/machine.py b/src/dsoxlab/reporting/machine.py index 78b4052..df67454 100644 --- a/src/dsoxlab/reporting/machine.py +++ b/src/dsoxlab/reporting/machine.py @@ -6,23 +6,38 @@ terminal, des couleurs, des retours à la ligne. Le moindre ajustement d'affichage casserait l'intégration, et l'affichage est fait pour bouger. -Deux règles tiennent ce contrat : +Trois règles tiennent ce contrat : 1. **Rien d'autre que du JSON sur la sortie standard.** En mode machine, les messages d'ambiance (contexte actif, astuces) sont tus : un « ℹ » en tête de flux rendrait le document illisible pour l'appelant. 2. **Le format est versionné.** Chaque document porte un ``schema``, pour qu'un consommateur sache s'il parle la même langue avant de lire le reste. +3. **Un verdict se lit sans traduire.** Un contrôle porte une **clé** stable et + un **état** en jeton (``ok``, ``failed``…), et seulement *ensuite* un libellé + traduit. Recopier le texte affiché dans un champ rendrait l'interface + inutilisable tout en paraissant complète : personne ne peut savoir si c'est + vert ou rouge sans analyser du français ou de l'anglais. + +Le mode machine ne change **jamais** le verdict ni le code de retour d'une +commande : il en change la forme. Un ``check`` en échec sort en 1 avec ou sans +``--json``, et une erreur dure (lab inconnu, ``meta.yml`` illisible) laisse la +sortie standard vide, dit pourquoi sur la sortie d'erreur, et garde son code. """ from __future__ import annotations import json import sys -from typing import Any +from pathlib import Path +from typing import TYPE_CHECKING, Any +from ..i18n import _ from ..models.lab import LabDefinition +if TYPE_CHECKING: # pragma: no cover - imports de typage seulement + from ..services.doctor import Check, DoctorReport + #: Version du format. À incrémenter dès qu'un champ change de sens ou disparaît #: — un ajout de champ, lui, reste compatible. SCHEMA = 1 @@ -76,3 +91,107 @@ def lab_dict( # présentation, et un lab jamais tenté se distingue d'un lab à zéro. "best_score": None if score is None else {"points": score[0], "max": score[1]}, } + + +def check_dict(check: Check) -> dict[str, Any]: + """Un contrôle de ``doctor``, tel qu'un tableau de bord doit le lire. + + ``key`` et ``state`` sont les deux champs qui décident : ils ne bougent ni + avec la langue, ni avec le tableau où le contrôle est rangé. ``label`` et + ``detail`` sont là pour être affichés à un humain, jamais comparés. + """ + return { + "key": check.key, + "state": check.state, + "ok": check.ok, + "label": check.label, + "detail": check.detail, + # Deux remédiations, deux natures : ``fix`` est une commande que + # ``--fix`` exécute telle quelle, ``hint`` une consigne que seul un + # humain pose. Les fondre en un champ ferait exécuter une URL. + "fix": check.fix, + "hint": check.hint, + } + + +def doctor_dict(report: DoctorReport) -> dict[str, Any]: + """Le diagnostic entier, verdict d'abord. + + ``ok`` porte sur le **requis** seul, comme le classement de ``doctor`` + l'affirme déjà : un hyperviseur que ce dépôt n'utilise pas n'a pas à peindre + en rouge un poste qui va très bien. + """ + return { + "ok": not report.failing(), + "required": [check_dict(c) for c in report.required], + "informational": [check_dict(c) for c in report.optional], + # Traduites, et c'est assumé : une note explique *pourquoi* un composant + # est informatif ici. Rien ne s'y décide, donc rien n'a à s'y comparer. + "notes": list(report.notes), + } + + +def _valeur(brute: Any) -> Any: + """Une valeur de paramètre, ramenée à ce que JSON sait porter. + + Les validators mettent dans ``params`` ce qu'ils ont sous la main, y compris + un ``Path`` ou l'exception qui a fait échouer une requête HTTP. Les rendre + tels quels lèverait au ``json.dump``, c'est-à-dire après que la commande a + fait tout son travail. + """ + if brute is None or isinstance(brute, bool | int | float | str): + return brute + return str(brute) + + +def issue_dict( + kind: str, + key: str, + params: dict[str, Any], + *, + lab: str | None = None, + path: Path | None = None, + field: str | None = None, +) -> dict[str, Any]: + """Une anomalie de ``validate-structure``, identifiée par sa règle. + + ``kind`` dit quelle famille de contrôle a parlé, ``key`` **quelle règle** : + c'est l'identité stable de l'anomalie, celle sur laquelle une intégration + filtre, compte et compare. ``message`` est la même chose dite à un humain, + dans sa langue, et n'a pas à être analysé pour cela. + """ + return { + "kind": kind, + "key": key, + "params": {nom: _valeur(valeur) for nom, valeur in params.items()}, + "message": _(key, **params), + "lab": lab, + "path": None if path is None else str(path), + "field": field, + } + + +def score_dict(row: dict[str, Any], passing_score: int | None) -> dict[str, Any]: + """Une note de l'historique, et le verdict quand le lab est un examen. + + ``exam`` vaut ``null`` sur un lab ordinaire : il n'y a pas de seuil, donc + pas de verdict à rendre. Un ``false`` s'y lirait comme un échec. + """ + from ..services.progress_service import exam_percentage, exam_verdict + + verdict = exam_verdict(row["score"], row["max_score"], passing_score or 0) + return { + "lab_id": row["lab_id"], + "section": row["section"], + "score": row["score"], + "max_score": row["max_score"], + "passed_tests": row["passed_tests"], + "total_tests": row["total_tests"], + "hints_used": row["hints_used"], + "validated_at": row["validated_at"], + "exam": None if verdict is None else { + "passing_score": passing_score, + "percentage": exam_percentage(row["score"], row["max_score"]), + "passed": verdict, + }, + } diff --git a/src/dsoxlab/services/doctor.py b/src/dsoxlab/services/doctor.py index 7c9d248..da94165 100644 --- a/src/dsoxlab/services/doctor.py +++ b/src/dsoxlab/services/doctor.py @@ -50,6 +50,15 @@ _VM_RUNTIMES = frozenset({RuntimeType.VM, RuntimeType.KVM, RuntimeType.INCUS}) +#: Les trois états d'un contrôle, en **jetons stables**. Ce sont eux que lit un +#: programme : un tableau de bord qui devrait comparer « ok » à « présent » puis +#: à « installed » selon la langue et selon le tableau ne saurait jamais dire si +#: c'est vert ou rouge. Le libellé traduit accompagne, il ne décide pas. +STATE_OK = "ok" +STATE_FAILED = "failed" +STATE_CHOICE_REQUIRED = "choice_required" + + @dataclass(frozen=True) class Check: """Un composant diagnostiqué. @@ -60,22 +69,60 @@ class Check: poser lui-même. """ + key: str + """Identité stable du contrôle (``kvm``, ``pytest``, ``libvirt_pool``…). + + Elle ne change pas avec la langue, et c'est par elle qu'une intégration + désigne un contrôle. Le ``label`` en dérive : ``_("check_")``, une + seule source pour les deux, faute de quoi ils divergent.""" + label: str ok: bool detail: str fix: str | None = None hint: str | None = None - status_key: str | None = None - """Clé i18n du statut, quand « KO » serait faux. Un provider qui reste + forced_state: str | None = None + """État imposé, quand « en échec » serait faux. Un provider qui reste à choisir bloque bien le provisionnement, mais rien n'est cassé : le dire en rouge revient à traiter une décision comme une panne.""" + @property + def state(self) -> str: + """L'état du contrôle, en jeton stable.""" + return self.forced_state or (STATE_OK if self.ok else STATE_FAILED) + @property def remediation(self) -> str: """Ce qu'affiche la colonne « Remédiation ».""" return self.fix or self.hint or "" +def _check( + key: str, + ok: bool, + detail: str, + *, + fix: str | None = None, + hint: str | None = None, + forced_state: str | None = None, +) -> Check: + """Un contrôle, dont l'identité stable engendre le libellé traduit. + + Le libellé n'est pas passé : il se déduit de ``key``. Les deux étaient + écrits côte à côte à chaque appel, et rien n'aurait empêché un contrôle + d'annoncer ``kvm`` à un programme et « Terraform » à un humain. + """ + return Check( + key=key, + label=_(f"check_{key}"), + ok=ok, + detail=detail, + fix=fix, + hint=hint, + forced_state=forced_state, + ) + + @dataclass class DoctorReport: """Le diagnostic, séparé en ce qui bloque et ce qui informe.""" @@ -106,24 +153,46 @@ def fixable(self) -> list[Check]: # ── checks unitaires ────────────────────────────────────────────────────────── def _check_python() -> Check: - return Check(_("check_python"), True, sys.version.split()[0]) + return _check("python", True, sys.version.split()[0]) def _check_pytest(root: Path) -> Check: """Diagnostique pytest par la résolution qu'utilise réellement ``check``.""" cmd = resolve_pytest_cmd(root) if cmd is None: - return Check( - _("check_pytest"), False, _("detail_pytest_missing"), + return _check( + "pytest", False, _("detail_pytest_missing"), hint="uv tool install --force dsoxlab", ) if cmd[0] == sys.executable: - return Check(_("check_pytest"), True, _("detail_pytest_bundled")) - return Check(_("check_pytest"), True, _("detail_pytest_via", cmd=" ".join(cmd))) + return _check("pytest", True, _("detail_pytest_bundled")) + return _check("pytest", True, _("detail_pytest_via", cmd=" ".join(cmd))) def _check_shell() -> Check: - return Check(_("check_shell"), True, _("detail_shell_always")) + return _check("shell", True, _("detail_shell_always")) + + +def _sonder( + commande: list[str], *, delai: float = 5 +) -> subprocess.CompletedProcess[str] | None: + """Joue une sonde, ou rend ``None`` si elle ne répond pas. + + Un diagnostic qui plante en diagnostiquant emporte toute la commande, et + depuis que ``doctor --json`` est une interface, il emporte aussi le document + de l'appelant : une ``TimeoutExpired`` remonte alors en trace Python là où + l'appelant attendait du JSON. ``virsh version`` pend jusqu'au délai sur un + hôte dont libvirt ne répond plus, et c'est un cas courant, pas un cas d'école. + + ``check=False`` : un code retour non nul EST le diagnostic. C'est l'absence + de réponse, elle, qui vaut ``None``. + """ + try: + return subprocess.run( + commande, capture_output=True, text=True, timeout=delai, check=False, + ) + except (OSError, subprocess.SubprocessError): + return None def _check_incus() -> Check: @@ -134,74 +203,72 @@ def _check_incus() -> Check: (« permissions to talk to the incus daemon »). """ if not shutil.which("incus"): - return Check( - _("check_incus"), False, _("detail_incus_missing"), + return _check( + "incus", False, _("detail_incus_missing"), fix="sudo apt install incus", ) - # check=False : le numéro de version n'est qu'un ornement du diagnostic. - # Le verdict vient de la sonde suivante, qui, elle, lit son code retour. - ver = subprocess.run( - ["incus", "--version"], capture_output=True, text=True, timeout=5, check=False, - ) - version = ver.stdout.strip() or "?" - - # check=False : un code retour non nul EST le diagnostic, pas un incident. - probe = subprocess.run( - ["incus", "list"], capture_output=True, text=True, timeout=5, check=False, - ) + # Le numéro de version n'est qu'un ornement du diagnostic. Le verdict vient + # de la sonde suivante, qui, elle, lit son code retour. + ver = _sonder(["incus", "--version"]) + version = (ver.stdout.strip() if ver else "") or "?" + + probe = _sonder(["incus", "list"]) + # Muette, elle ne prouve rien sinon que le daemon ne répond pas : c'est le + # même geste de réparation que sur un daemon arrêté. + if probe is None: + return _check( + "incus", False, _("detail_incus_daemon_down", version=version), + fix="sudo systemctl enable --now incus.service", + ) if probe.returncode == 0: - return Check(_("check_incus"), True, _("detail_incus_ok", version=version)) + return _check("incus", True, _("detail_incus_ok", version=version)) err = (probe.stderr or "").lower() if "permission" in err or "socket" in err: # Soit daemon inactif, soit user hors du groupe : deux causes, # deux remédiations, que l'erreur seule ne distingue pas. - # check=False : `is-active` répond par son code retour, c'est sa façon - # de dire non. Lever ici transformerait une réponse en panne. - daemon_active = subprocess.run( - ["systemctl", "is-active", "--quiet", "incus.service"], check=False, - ).returncode == 0 + # `is-active` répond par son code retour, c'est sa façon de dire non. + etat = _sonder(["systemctl", "is-active", "--quiet", "incus.service"]) + daemon_active = etat is not None and etat.returncode == 0 if not daemon_active: - return Check( - _("check_incus"), False, + return _check( + "incus", False, _("detail_incus_daemon_down", version=version), fix="sudo systemctl enable --now incus.service", ) - return Check( - _("check_incus"), False, + return _check( + "incus", False, _("detail_incus_no_group", version=version), fix=f"sudo usermod -aG incus,incus-admin {os.environ.get('USER', '$USER')}", ) if "no storage pools" in err or "init" in err: - return Check( - _("check_incus"), False, + return _check( + "incus", False, _("detail_incus_no_init", version=version), fix="sudo incus admin init --auto", ) tail = (probe.stderr or probe.stdout).strip().splitlines() - return Check(_("check_incus"), False, tail[-1] if tail else _("detail_unknown_error")) + return _check("incus", False, tail[-1] if tail else _("detail_unknown_error")) def _check_kvm() -> Check: if not shutil.which("virsh"): - return Check( - _("check_kvm"), False, _("detail_kvm_missing"), + return _check( + "kvm", False, _("detail_kvm_missing"), fix="sudo apt install libvirt-clients libvirt-daemon-system qemu-kvm", ) - # check=False : un virsh qui sort en erreur est justement ce que ce - # contrôle cherche à rapporter, le code retour est lu juste en dessous. - result = subprocess.run( - ["virsh", "version"], capture_output=True, text=True, timeout=5, check=False, - ) - if result.returncode != 0: - return Check( - _("check_kvm"), False, _("detail_kvm_daemon_err"), + # Un virsh qui sort en erreur est justement ce que ce contrôle cherche à + # rapporter ; un virsh qui ne répond pas dit la même chose plus fort. + result = _sonder(["virsh", "version"]) + if result is None or result.returncode != 0: + return _check( + "kvm", False, _("detail_kvm_daemon_err"), fix="sudo systemctl start libvirtd", ) first_line = result.stdout.splitlines()[0] if result.stdout else "ok" - return Check(_("check_kvm"), True, first_line) + return _check("kvm", True, first_line) def _check_terraform() -> Check: @@ -213,22 +280,17 @@ def _check_terraform() -> Check: tournait en rond entre deux commandes qui lui disaient la même chose. """ if shutil.which("terraform") is None: - return Check( - _("check_terraform"), False, _("detail_terraform_missing"), + return _check( + "terraform", False, _("detail_terraform_missing"), hint="https://developer.hashicorp.com/terraform/install", ) # Le binaire peut être dans le PATH sans être exécutable, ou disparaître # entre les deux appels. Un diagnostic qui plante en cherchant à # diagnostiquer est le pire des cas : il emporte toute la commande. - # - # check=False : on veut le code retour pour le RAPPORTER, pas pour lever. - try: - result = subprocess.run( - ["terraform", "version"], capture_output=True, text=True, timeout=5, check=False, - ) - except (OSError, subprocess.SubprocessError): - return Check( - _("check_terraform"), False, _("detail_terraform_missing"), + result = _sonder(["terraform", "version"]) + if result is None: + return _check( + "terraform", False, _("detail_terraform_missing"), hint="https://developer.hashicorp.com/terraform/install", ) # Un terraform présent mais qui sort en erreur passait pour vert : le code @@ -236,13 +298,13 @@ def _check_terraform() -> Check: # échouait ensuite sur une machine que `doctor` venait de déclarer prête. if result.returncode != 0: detail = (result.stderr or result.stdout).strip().splitlines() - return Check( - _("check_terraform"), False, + return _check( + "terraform", False, detail[-1] if detail else _("detail_terraform_broken"), hint="https://developer.hashicorp.com/terraform/install", ) first = result.stdout.splitlines()[0] if result.stdout else "ok" - return Check(_("check_terraform"), True, first) + return _check("terraform", True, first) def _check_ansible() -> Check: @@ -254,11 +316,11 @@ def _check_ansible() -> Check: ne relie les deux. """ if not ansible_infra.has_ansible_playbook(): - return Check( - _("check_ansible"), False, _("detail_ansible_missing"), + return _check( + "ansible", False, _("detail_ansible_missing"), fix="uv tool install ansible-core", ) - return Check(_("check_ansible"), True, _("detail_ansible_ok")) + return _check("ansible", True, _("detail_ansible_ok")) def creer_pool_command(pool: str) -> str: @@ -290,14 +352,8 @@ def _pools_libvirt(*, definis: bool) -> list[str] | None: if definis: cmd.append("--all") cmd.append("--name") - # check=False : le code retour est lu juste en dessous pour décider. - try: - probe = subprocess.run( - cmd, capture_output=True, text=True, timeout=5, check=False, - ) - except (OSError, subprocess.SubprocessError): - return None - if probe.returncode != 0: + probe = _sonder(cmd) + if probe is None or probe.returncode != 0: return None return probe.stdout.split() @@ -323,18 +379,18 @@ def _check_libvirt_pool(pool: str) -> Check: """ actifs = _pools_libvirt(definis=False) if actifs is None: - return Check(_("check_libvirt_pool"), True, _("detail_pool_unknown")) + return _check("libvirt_pool", True, _("detail_pool_unknown")) if pool in actifs: - return Check(_("check_libvirt_pool"), True, pool) + return _check("libvirt_pool", True, pool) definis = _pools_libvirt(definis=True) if definis is not None and pool in definis: - return Check( - _("check_libvirt_pool"), False, _("detail_pool_inactive", pool=pool), + return _check( + "libvirt_pool", False, _("detail_pool_inactive", pool=pool), fix=demarrer_pool_command(pool), ) - return Check( - _("check_libvirt_pool"), False, _("detail_pool_missing", pool=pool), + return _check( + "libvirt_pool", False, _("detail_pool_missing", pool=pool), fix=creer_pool_command(pool), ) @@ -471,9 +527,9 @@ def _check_iso_tool() -> Check: """ for outil in ("genisoimage", "mkisofs", "xorrisofs"): if shutil.which(outil): - return Check(_("check_iso_tool"), True, outil) - return Check( - _("check_iso_tool"), False, _("detail_iso_tool_missing"), + return _check("iso_tool", True, outil) + return _check( + "iso_tool", False, _("detail_iso_tool_missing"), fix="sudo apt install genisoimage", ) @@ -495,11 +551,11 @@ def _check_labs(root: Path, labs: list[LabDefinition], vus: int) -> Check: # Seul un écart POSITIF dit quelque chose : des fichiers présents que le # moteur n'a pas su charger. L'inverse ne peut pas venir d'un catalogue réel, # et vaut zéro information. - return Check(_("check_labs"), len(labs) > 0 and ecart <= 0, detail) + return _check("labs", len(labs) > 0 and ecart <= 0, detail) def _check_lab_home(root: Path) -> Check: - return Check(_("check_lab_home"), True, str(root)) + return _check("lab_home", True, str(root)) # ── assemblage ──────────────────────────────────────────────────────────────── @@ -540,7 +596,7 @@ def collect_checks(root: Path, repo_meta: RepoMetadata | None) -> DoctorReport: report.optional.extend(_sort_hypervisors(list(_LOCAL_HYPERVISORS))) report.notes.append(_("doctor_note_no_vm")) elif active in hypervisors: - report.required.append(Check(_("check_provider"), True, active)) + report.required.append(_check("provider", True, active)) report.required.append(hypervisors[active]) report.optional.extend( _sort_hypervisors([n for n in _LOCAL_HYPERVISORS if n != active]) @@ -548,7 +604,7 @@ def collect_checks(root: Path, repo_meta: RepoMetadata | None) -> DoctorReport: report.notes.append(_("doctor_note_other_providers", provider=active)) elif active: # Provider distant (outscale…) : rien à vérifier localement. - report.required.append(Check(_("check_provider"), True, active)) + report.required.append(_check("provider", True, active)) report.optional.extend(_sort_hypervisors(list(_LOCAL_HYPERVISORS))) report.notes.append(_("doctor_note_remote_provider", provider=active)) else: @@ -562,11 +618,11 @@ def collect_checks(root: Path, repo_meta: RepoMetadata | None) -> DoctorReport: # qui mentait, en annonçant « non requis ici » et « ces composants ne # bloquent rien » au-dessus du composant qui bloque 64 labs sur 84. first = candidates[0] if candidates else "kvm" - report.required.append(Check( - _("check_provider"), False, + report.required.append(_check( + "provider", False, _("detail_provider_unresolved", candidates=", ".join(candidates) or "—"), hint=f"dsoxlab use --provider {first}", - status_key="status_choose", + forced_state=STATE_CHOICE_REQUIRED, )) report.optional.extend(_sort_hypervisors(list(_LOCAL_HYPERVISORS))) report.optional_title_key = "doctor_choose_title" diff --git a/tests/test_doctor.py b/tests/test_doctor.py index a0426a7..0cb5c05 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -68,8 +68,8 @@ def stub_hypervisors(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( doctor, "_hypervisor_checks", lambda: { - "kvm": doctor.Check(_("check_kvm"), False, "not found", fix="apt install"), - "incus": doctor.Check(_("check_incus"), True, "daemon ok"), + "kvm": doctor._check("kvm", False, "not found", fix="apt install"), + "incus": doctor._check("incus", True, "daemon ok"), }, ) @@ -86,11 +86,11 @@ def _outillage_present(monkeypatch: pytest.MonkeyPatch) -> None: """ monkeypatch.setattr( doctor, "_check_terraform", - lambda: doctor.Check(_("check_terraform"), True, "Terraform v1.0.0"), + lambda: doctor._check("terraform", True, "Terraform v1.0.0"), ) monkeypatch.setattr( doctor, "_check_ansible", - lambda: doctor.Check(_("check_ansible"), True, "ok"), + lambda: doctor._check("ansible", True, "ok"), ) @@ -190,9 +190,9 @@ def test_unresolved_provider_is_a_decision_not_a_failure( _labs(monkeypatch, [_lab("a", RuntimeType.VM)]) report = doctor.collect_checks(tmp_path, _repo(candidates=["kvm", "incus"])) - provider = next(c for c in report.required if c.label == _("check_provider")) + provider = next(c for c in report.required if c.key == "provider") assert not provider.ok - assert provider.status_key == "status_choose" + assert provider.state == doctor.STATE_CHOICE_REQUIRED assert provider.fix is None assert "use --provider kvm" in (provider.hint or "") assert not report.fixable() diff --git a/tests/test_doctor_installation.py b/tests/test_doctor_installation.py index 0403762..489b29b 100644 --- a/tests/test_doctor_installation.py +++ b/tests/test_doctor_installation.py @@ -75,8 +75,8 @@ def hyperviseur_ok(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( doctor, "_hypervisor_checks", lambda: { - "kvm": doctor.Check(_("check_kvm"), True, "libvirt 10.0.0"), - "incus": doctor.Check(_("check_incus"), True, "daemon ok"), + "kvm": doctor._check("kvm", True, "libvirt 10.0.0"), + "incus": doctor._check("incus", True, "daemon ok"), }, ) @@ -91,11 +91,11 @@ def outillage_present(monkeypatch: pytest.MonkeyPatch) -> None: """ monkeypatch.setattr( doctor, "_check_terraform", - lambda: doctor.Check(_("check_terraform"), True, "Terraform v1.0.0"), + lambda: doctor._check("terraform", True, "Terraform v1.0.0"), ) monkeypatch.setattr( doctor, "_check_ansible", - lambda: doctor.Check(_("check_ansible"), True, "ok"), + lambda: doctor._check("ansible", True, "ok"), ) @@ -227,8 +227,8 @@ def test_les_controles_kvm_se_taisent_si_virsh_manque( monkeypatch.setattr( doctor, "_hypervisor_checks", lambda: { - "kvm": doctor.Check(_("check_kvm"), False, "not found", fix="apt install"), - "incus": doctor.Check(_("check_incus"), True, "ok"), + "kvm": doctor._check("kvm", False, "not found", fix="apt install"), + "incus": doctor._check("incus", True, "ok"), }, ) _labs(monkeypatch, [_lab("a", RuntimeType.VM)]) @@ -277,7 +277,7 @@ def test_le_pool_verifie_est_celui_que_le_depot_declare( def _faux_check(pool: str) -> doctor.Check: vus.append(pool) - return doctor.Check(_("check_libvirt_pool"), True, pool) + return doctor._check("libvirt_pool", True, pool) monkeypatch.setattr(doctor, "_check_libvirt_pool", _faux_check) _labs(monkeypatch, [_lab("a", RuntimeType.VM)]) @@ -295,7 +295,7 @@ def test_sans_declaration_le_pool_verifie_est_default( vus: list[str] = [] monkeypatch.setattr( doctor, "_check_libvirt_pool", - lambda pool: (vus.append(pool), doctor.Check("p", True, pool))[1], + lambda pool: (vus.append(pool), doctor._check("p", True, pool))[1], ) _labs(monkeypatch, [_lab("a", RuntimeType.VM)]) doctor.collect_checks(tmp_path, _repo(provider="kvm")) @@ -569,3 +569,29 @@ def test_les_cles_du_pool_inactif_sont_bilingues(cle: str) -> None: assert cle in EN and cle in FR assert EN[cle] != FR[cle], "une traduction identique est probablement oubliée" + + +# ── une sonde muette ne doit pas emporter le diagnostic ────────────────────── + +def test_une_sonde_qui_pend_ne_leve_pas(monkeypatch: pytest.MonkeyPatch) -> None: + """`virsh version` peut pendre : le contrôle le rapporte, il ne plante pas. + + Mesuré en environnement contraint, où la socket libvirt ne répond jamais : + la `TimeoutExpired` remontait jusqu'au bout et emportait toute la commande. + Depuis que `doctor --json` est une interface, elle emporte aussi le document + que l'appelant attendait, et lui rend une trace Python à la place. + """ + import subprocess + + monkeypatch.setattr(doctor.shutil, "which", lambda _nom: "/usr/bin/virsh") + + def _pend(*_a: object, **_k: object) -> object: + raise subprocess.TimeoutExpired(cmd=["virsh", "version"], timeout=5) + + monkeypatch.setattr(doctor.subprocess, "run", _pend) + + controle = doctor._check_kvm() + + assert controle.key == "kvm" + assert not controle.ok + assert controle.fix, "un échec doit nommer le geste qui le corrige" diff --git a/tests/test_documentation_synchrone.py b/tests/test_documentation_synchrone.py index 6507d29..9d81c4a 100644 --- a/tests/test_documentation_synchrone.py +++ b/tests/test_documentation_synchrone.py @@ -57,6 +57,8 @@ "docs/trainer.fr.md", "docs/files.md", "docs/files.fr.md", + "docs/machine-output.md", + "docs/machine-output.fr.md", "docs/commands.md", "docs/commands.fr.md", "docs/contract-v1.md", diff --git a/tests/test_json_output.py b/tests/test_json_output.py index 02435bf..45305e1 100644 --- a/tests/test_json_output.py +++ b/tests/test_json_output.py @@ -7,16 +7,30 @@ contienne QUE du JSON. Un message d'ambiance en tête de flux suffit à rendre le document illisible pour l'appelant, et c'est arrivé trois fois en l'écrivant : « ℹ Validation de… », la barre de progression pytest, puis le contexte actif. + +D'où la forme des contrôles de commande, plus bas : ``json.loads(stdout)`` +**sans strip**, ce qui refuse tout résidu, et jamais une recherche de sous-chaîne +qui laisserait passer un flux déjà cassé. Chaque commande y est jouée par +``CliRunner``, qui sépare la sortie standard de la sortie d'erreur : c'est la +seule façon de prouver que les avis partent bien du bon côté. """ from __future__ import annotations import json from pathlib import Path +from typing import Any + +import pytest +from typer.testing import CliRunner +from dsoxlab.cli import app from dsoxlab.models.lab import LabDefinition, ValidationConfig from dsoxlab.models.runtime import RuntimeConfig, RuntimeType, Target from dsoxlab.reporting import machine +from dsoxlab.services import doctor as service_doctor + +runner = CliRunner() def _lab() -> LabDefinition: @@ -104,3 +118,297 @@ def test_a_failing_check_still_prints_only_json(monkeypatch, capsys, tmp_path) - cli._run_check(Path("/repo"), _lab(), None, quiet=True) assert capsys.readouterr().out == "", "le mode machine doit rester muet" + + +# ── les documents rendus par les commandes ─────────────────────────────────── + +_META = """\ +repo: + id: catalogue-essai + category: domaine +sections: + - id: domaine + title: Domaine + labs: + - domaine/premier + - domaine/second +""" + +_LAB = """\ +id: {ident} +title: {titre} +level: l1 +skills: [une-competence] +distros: [alma10] +doc_url: https://exemple.test/guide +runtime: + type: shell + workdir: challenge/work +""" + + +def _poser_lab(racine: Path, ident: str, titre: str) -> Path: + """Un lab conforme au contrat, réduit à ce que les validators exigent.""" + lab = racine / "labs" / "domaine" / ident + (lab / "challenge" / "tests").mkdir(parents=True) + (lab / "lab.yaml").write_text( + _LAB.format(ident=ident, titre=titre), encoding="utf-8" + ) + (lab / "README.md").write_text(f"# {titre}\n", encoding="utf-8") + (lab / "scenario.md").write_text("Faites la chose.\n", encoding="utf-8") + (lab / "challenge" / "tests" / "test_functional.py").write_text( + "def test_la_chose_est_faite() -> None:\n assert True\n", encoding="utf-8" + ) + return lab + + +@pytest.fixture +def catalogue(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Un catalogue minimal, conforme, et sans le moindre chemin personnel. + + ``LAB_HOME`` le désigne : c'est aussi ce que lit ``--lab-home``, donc les + commandes le trouvent sans qu'aucun test ne dépende du répertoire courant. + Les répertoires XDG partent dans le ``tmp_path``, faute de quoi cette suite + écrirait un journal dans le répertoire personnel de qui la lance. + """ + racine = tmp_path / "catalogue" + racine.mkdir() + (racine / "meta.yml").write_text(_META, encoding="utf-8") + _poser_lab(racine, "premier", "Le premier") + _poser_lab(racine, "second", "Le second") + + monkeypatch.setenv("LAB_HOME", str(racine)) + monkeypatch.setenv("DSOXLAB_LANG", "en") + for variable in ("XDG_STATE_HOME", "XDG_CACHE_HOME", "XDG_DATA_HOME"): + monkeypatch.setenv(variable, str(tmp_path / variable.lower())) + return racine + + +def _noter(racine: Path, ident: str, score: int) -> None: + """Une note dans la base du catalogue, sans passer par un lab joué.""" + from dsoxlab.sessions.store import record_result + + record_result( + racine, lab_id=ident, section="domaine", score=score, max_score=100, + passed_tests=score // 20, total_tests=5, hints_used=0, + ) + + +def _document(*args: str) -> Any: + """Joue une commande et rend son document, refusé s'il traîne quoi que ce soit. + + Pas de ``strip`` : c'est tout l'intérêt. Un « ℹ contexte actif » en tête ou + une barre de progression en queue font lever ``json.loads``, ce qu'aucune + recherche de sous-chaîne n'attraperait. + """ + resultat = runner.invoke(app, [*args, "--json"]) + assert resultat.exit_code == 0, resultat.stderr + return json.loads(resultat.stdout) + + +@pytest.fixture +def sans_hyperviseur(monkeypatch: pytest.MonkeyPatch) -> None: + """Neutralise les sondes système : un diagnostic n'a pas à sortir du test. + + Sans cela, ``doctor`` lancerait ``virsh`` et ``incus`` sur la machine qui + joue la suite, et le document dépendrait de ce qui y est installé. + """ + monkeypatch.setattr( + service_doctor, + "_hypervisor_checks", + lambda: { + "kvm": service_doctor._check("kvm", False, "absent", fix="apt install"), + "incus": service_doctor._check("incus", True, "ok"), + }, + ) + + +def test_show_rend_le_lab_et_son_statut(catalogue: Path) -> None: + document = _document("show", "premier") + + assert document["schema"] == machine.SCHEMA + assert document["lab"]["id"] == "premier" + assert document["lab"]["path"] == str(catalogue / "labs" / "domaine" / "premier") + # Un lab jamais joué : le répertoire de travail n'existe pas encore. + assert document["status"] == "stopped" + assert document["lab"]["best_score"] is None + + +def test_scores_rend_un_historique_vide(catalogue: Path) -> None: + """Aucune note enregistrée reste un document, pas une phrase. + + C'est le cas du premier lancement, celui qu'une intégration rencontre en + premier : sans document, elle n'aurait rien à lire pour le dire. + """ + document = _document("scores") + + assert document == {"schema": machine.SCHEMA, "results": [], "count": 0} + + +def test_scores_rend_le_verdict_dun_examen(catalogue: Path) -> None: + """Le seuil vit dans le catalogue, la note dans la base : `exam` joint les deux.""" + lab = catalogue / "labs" / "domaine" / "premier" / "lab.yaml" + lab.write_text( + lab.read_text(encoding="utf-8") + "exam_passing_score: 70\n", encoding="utf-8" + ) + _noter(catalogue, "premier", 60) + + (note,) = _document("scores")["results"] + + assert note["lab_id"] == "premier" + assert note["exam"] == {"passing_score": 70, "percentage": 60, "passed": False} + + +def test_scores_sans_seuil_ne_rend_aucun_verdict(catalogue: Path) -> None: + """`null` et non `false` : un lab ordinaire n'est pas un examen recalé.""" + _noter(catalogue, "premier", 60) + + assert _document("scores")["results"][0]["exam"] is None + + +def test_next_designe_le_prochain_lab(catalogue: Path) -> None: + runner.invoke(app, ["use", "domaine"]) + document = _document("next") + + assert document["next"]["id"] == "premier" + assert document["all_done"] is False + assert document["remaining"] == 2 + assert document["context"]["section"] == "domaine" + + +def test_next_dit_quand_tout_est_fait(catalogue: Path) -> None: + """`all_done` et `next: null` ne disent pas la même chose. + + Une section vide rendrait aussi `next: null`, et l'appelant fêterait un + parcours terminé qui n'a jamais commencé. + """ + runner.invoke(app, ["use", "domaine"]) + for ident in ("premier", "second"): + _noter(catalogue, ident, 100) + + document = _document("next") + + assert document["next"] is None + assert document["all_done"] is True + assert document["remaining"] == 0 + + +def test_next_sans_contexte_ne_rend_rien(catalogue: Path) -> None: + """Erreur dure : rien sur la sortie standard, la cause sur l'autre, code 1. + + C'est la règle du mode machine, et elle vaut mieux qu'un document inventé + pour l'occasion : l'appelant lit le code de retour d'abord. + """ + resultat = runner.invoke(app, ["next", "--json"]) + + assert resultat.exit_code == 1 + assert resultat.stdout == "" + assert resultat.stderr.strip() + + +def test_doctor_rend_des_cles_stables(catalogue: Path, sans_hyperviseur: None) -> None: + document = _document("doctor") + + requis = {c["key"]: c for c in document["required"]} + assert {"python", "pytest", "shell", "labs", "lab_home"} <= set(requis) + assert requis["labs"]["state"] == "ok" + assert document["ok"] is True + # Un catalogue 100 % shell : les hyperviseurs sont informatifs, et le kvm + # en échec ne doit donc pas peindre le verdict en rouge. + assert [c["key"] for c in document["informational"]] == ["kvm", "incus"] + + +def test_un_verdict_se_lit_sans_traduire( + catalogue: Path, sans_hyperviseur: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """Le libellé change de langue, l'identité et l'état ne bougent pas. + + C'est tout l'objet de l'interface : une intégration qui devrait comparer + « Labs detected » puis « Labs détectés » pour savoir si c'est vert ne + saurait jamais si c'est vert. + """ + anglais = _document("doctor") + monkeypatch.setenv("DSOXLAB_LANG", "fr") + francais = _document("doctor") + + def _extraire(document: Any, champ: str) -> list[Any]: + return [c[champ] for c in document["required"]] + + assert _extraire(anglais, "key") == _extraire(francais, "key") + assert _extraire(anglais, "state") == _extraire(francais, "state") + assert _extraire(anglais, "label") != _extraire(francais, "label") + + +def test_doctor_ne_repare_pas_en_mode_machine( + catalogue: Path, sans_hyperviseur: None +) -> None: + """`--fix` lance apt, dont la sortie irait sur le flux du document. + + Le refus est net et dit quoi faire ; rendre un JSON précédé de la sortie + d'apt serait le pire des deux. + """ + resultat = runner.invoke(app, ["doctor", "--json", "--fix"]) + + assert resultat.exit_code == 1 + assert resultat.stdout == "" + assert "--fix" in resultat.stderr + + +def test_validate_structure_rend_ok(catalogue: Path) -> None: + document = _document("validate-structure") + + assert document["ok"] is True + assert document["labs_checked"] == 2 + assert document["issues"] == [] + # Toutes les familles présentes, à zéro : sans quoi l'appelant ne peut pas + # distinguer une famille saine d'une famille que cette version ignore. + assert set(document["counts"]) == { + "contract", "unknown_key", "structure", "content", "doc_url", "metadata", + } + assert document["doc_urls_checked"] is False + + +def test_une_anomalie_porte_sa_regle(catalogue: Path) -> None: + """`key` identifie la règle, `message` ne fait que la dire à un humain.""" + (catalogue / "labs" / "domaine" / "premier" / "challenge" / "tests" + / "test_functional.py").unlink() + + resultat = runner.invoke(app, ["validate-structure", "--json"]) + document = json.loads(resultat.stdout) + + assert resultat.exit_code == 1, "le verdict ne change pas avec la forme" + (anomalie,) = [i for i in document["issues"] if i["kind"] == "structure"] + assert anomalie["key"] == "struct_missing_file" + assert anomalie["params"] == {"name": "test_functional.py"} + assert anomalie["lab"] == "premier" + assert anomalie["path"].endswith("test_functional.py") + assert document["counts"]["structure"] == 1 + assert document["ok"] is False + + +def test_le_code_de_retour_ne_bouge_pas(catalogue: Path) -> None: + """`--json` change la forme de la sortie, jamais le verdict ni le code. + + Les deux modes sont joués sur le même catalogue cassé, et comparés. + """ + (catalogue / "labs" / "domaine" / "second" / "README.md").unlink() + + terminal = runner.invoke(app, ["validate-structure"]) + machine_ = runner.invoke(app, ["validate-structure", "--json"]) + + assert terminal.exit_code == machine_.exit_code == 1 + assert json.loads(machine_.stdout)["ok"] is False + + +def test_un_parametre_illisible_ne_casse_rien(catalogue: Path) -> None: + """Un `params` porte ce que le validator avait sous la main, pas du JSON. + + Un `Path` ou l'exception d'une requête HTTP y atterrissent, et les rendre + tels quels ferait lever le `json.dump` après tout le travail de la commande. + """ + rendu = machine.issue_dict( + "content", "content_doc_url_unreachable", {"error": OSError("réseau coupé")}, + ) + + json.dumps(rendu) # lève si un objet Python a survécu + assert rendu["params"]["error"] == "réseau coupé" diff --git a/tests_e2e/test_parcours.py b/tests_e2e/test_parcours.py index fa68516..5e52ec5 100644 --- a/tests_e2e/test_parcours.py +++ b/tests_e2e/test_parcours.py @@ -183,3 +183,53 @@ def test_le_catalogue_package_passe_son_validateur(poste: Poste, catalogue: Path resultat = poste.lance("validate-structure", cwd=catalogue) assert resultat.returncode == 0, resultat.stdout[-2000:] + + +# ── toute l'interface machine, par le binaire installé ─────────────────────── + +def test_chaque_document_se_lit_sans_reste(poste: Poste, catalogue: Path) -> None: + """Les cinq documents ajoutés, lus par un vrai sous-processus. + + La suite unitaire capture des flux dans le même interpréteur ; celle-ci lit + ce qu'un tube reçoit vraiment. C'est la seule qui verrait un avis parti sur + la sortie standard depuis une bibliothèque, ou un `print` de dépendance. + """ + poste.lance("use", "demo", cwd=catalogue) + + for commande in ( + ["show", LAB_DEMO], + ["scores"], + ["next"], + ["doctor"], + ["validate-structure"], + ["list-labs"], + ["progress"], + ): + resultat = poste.lance(*commande, "--json", cwd=catalogue) + assert resultat.returncode == 0, resultat.stderr[-2000:] + document = _document(resultat.stdout) + assert document["schema"] >= 1, commande + + +def test_le_verdict_ne_depend_pas_de_la_forme(poste: Poste, catalogue: Path) -> None: + """`--json` change la forme de la sortie, jamais le code de retour. + + Éprouvé sur `validate-structure`, la seule commande de lecture qui rende un + verdict et puisse sortir en 1. Le catalogue est cassé pour l'occasion, puis + remis en état, pour que la comparaison porte sur un cas non trivial. + """ + lab = catalogue / "labs" / "demo" / LAB_DEMO + scenario = lab / "scenario.md" + garde = scenario.read_text(encoding="utf-8") + scenario.unlink() + try: + terminal = poste.lance("validate-structure", cwd=catalogue) + machine = poste.lance("validate-structure", "--json", cwd=catalogue) + finally: + scenario.write_text(garde, encoding="utf-8") + + assert terminal.returncode == 1, terminal.stdout[-2000:] + assert machine.returncode == 1, machine.stdout[-2000:] + document = _document(machine.stdout) + assert document["ok"] is False + assert document["counts"]["structure"] >= 1, document diff --git a/uv.lock b/uv.lock index c1f1a3d..e7b0b0a 100644 --- a/uv.lock +++ b/uv.lock @@ -313,7 +313,7 @@ wheels = [ [[package]] name = "dsoxlab" -version = "0.1.64" +version = "0.1.65" source = { editable = "." } dependencies = [ { name = "ansible-core", version = "2.19.12", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },