From 961fda2bb04d1f6415a6f3dd10850f4c5cdd2a8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20ROBERT?= Date: Mon, 12 Oct 2026 08:27:04 +0200 Subject: [PATCH 1/3] =?UTF-8?q?fix(infra/ansible):=20un=20playbook=20en=20?= =?UTF-8?q?=C3=A9chec=20dit=20enfin=20QUELLE=20t=C3=A2che,=20et=20pourquoi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux utilisateurs externes ont ouvert kubernetes#94 le 10 octobre avec ceci, et rien d'autre : ✘ setup.yaml a échoué pour cka-troubleshoot-apiserver sur la target « cp » (rc=4, status=failed). Stats : {} Un rapport exemplaire, deux environnements, et deux jours sans la moindre piste — pour eux comme pour nous. CE QUE DSOXLAB SAVAIT ET JETAIT ansible-runner lui envoie un événement `runner_on_failed` qui porte le nom de la tâche, l'hôte et le message. `PlaybookResult` capturait déjà la sortie complète dans son champ `stdout`. Les deux partaient à la poubelle : `runtimes/vm.py` ne levait qu'avec rc, status et stats, et le `finally` supprimait le répertoire d'artefacts. C'est exactement le défaut corrigé dans l'appliance en 0.3.4 — la cause existait, dans un endroit que personne ne regarde — mais cette fois dans le moteur, et il touche tous les labs `vm` de tous les catalogues. TROIS NIVEAUX, PARCE QU'AUCUN N'EST TOUJOURS DISPONIBLE 1. les tâches qu'ansible déclare en échec : nom, hôte, message ; 2. à défaut, les dernières lignes parlantes de sa sortie. C'est le cas de `Stats: {}` — aucun événement émis, donc rien à afficher si l'on s'en tenait au premier niveau, ce qui est précisément le défaut d'origine ; 3. et toujours, le chemin d'une trace conservée sous `~/.local/state/dsoxlab/ansible/`, horodatée pour qu'un second essai n'écrase pas le premier — c'est souvent la comparaison des deux qui parle. Le collecteur tourne AVANT le callback de l'appelant et dans son propre `try` : perdre le diagnostic parce que la barre de progression a planté serait le défaut qu'on corrige. CE QUE LE CORRECTIF A DONNÉ DÈS LE PREMIER ESSAI ✘ setup.yaml a échoué pour cka-troubleshoot-apiserver … (rc=2) ✘ Installer le CNI, Calico — k8s-cp.lab (échec) : The command exited with a non-zero return code. Sortie complète d'ansible : ~/.local/state/dsoxlab/ansible/…log et la trace donnait la cause en une ligne : `dial tcp 10.10.50.11:6443: connect: connection refused`. CE QUE L'ENQUÊTE A ÉCARTÉ, PAR LA MESURE Le cluster kubeadm a été monté ici, et le lab de l'issue joué en entier : ansible-core 2.19.14, cluster neuf réussit de bout en bout ansible-core 2.21.5, cluster neuf réussit de bout en bout les 5 fichiers du socle se parsent dans les deux versions include_tasks ../../shared/ résout (éprouvé via run_playbook) Donc ni le catalogue, ni le motif d'inclusion, ni la version d'ansible. La cause de #94 est dans l'environnement des rapporteurs, et c'est maintenant ce correctif qui permettra de la nommer. DEUX GARDE-FOUS DU DÉPÔT M'ONT REPRIS, ET ILS AVAIENT RAISON `test_journal_en_anglais` a attrapé un message de journal que j'avais écrit en français. Et mon propre test a montré que `TacheEnEchec.resume()` rendait une ligne s'arrêtant sur deux-points quand le message est vide : le repli vivait chez l'appelant, pas dans la méthode. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.fr.md | 19 +++ CHANGELOG.md | 18 +++ src/dsoxlab/i18n/strings/en.py | 8 +- src/dsoxlab/i18n/strings/fr.py | 8 +- src/dsoxlab/infra/ansible.py | 183 ++++++++++++++++++++++++++-- src/dsoxlab/runtimes/vm.py | 2 +- tests/test_ansible_dire_la_cause.py | 157 ++++++++++++++++++++++++ 7 files changed, 383 insertions(+), 12 deletions(-) create mode 100644 tests/test_ansible_dire_la_cause.py diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 56b9a65..f94f918 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -7,6 +7,25 @@ Toutes les modifications notables du projet sont documentées dans ce fichier. Le format s'appuie sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et le projet suit le [versionnage sémantique](https://semver.org/lang/fr/). +## [Non publié] + +### Corrigé + +- **Un lab `vm` en échec ne disait rien de la cause.** `setup.yaml a échoué … + (rc=4, status=failed). Stats : {}` : c'est tout ce qu'un apprenant recevait, + et tout ce que le mainteneur recevait aussi — ce qui a rendu + [kubernetes#94](https://github.com/stephrobert/kubernetes-dsoxlab-training/issues/94) + indiagnosticable pendant deux jours. dsoxlab savait pourtant : + ansible-runner lui envoie un événement `runner_on_failed` qui porte le nom + de la tâche, l'hôte et le message, et `PlaybookResult` capturait déjà la + sortie complète. Les deux étaient jetés, et le répertoire d'artefacts + supprimé au retour. L'échec rapporte maintenant **la tâche fautive, l'hôte + et le message d'ansible** ; quand aucun événement n'a été émis — ce que + `Stats : {}` signifie précisément — il retombe sur les dernières lignes + parlantes de la sortie ; et la trace complète est conservée sous + `~/.local/state/dsoxlab/ansible/`, horodatée pour qu'un second essai + n'écrase pas le premier. + ## [0.3.4] - 2026-10-10 ### Ajouté diff --git a/CHANGELOG.md b/CHANGELOG.md index 15100f7..f2c8717 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- **A failed `vm` lab said nothing about why.** `setup.yaml failed … (rc=4, + status=failed). Stats: {}` was all a learner got — and all the maintainer + got too, which left + [kubernetes#94](https://github.com/stephrobert/kubernetes-dsoxlab-training/issues/94) + undiagnosable for two days. dsoxlab knew: ansible-runner sends it a + `runner_on_failed` event carrying the task name, the host and the message, + and `PlaybookResult` already captured the whole output. Both were dropped, + and the artifact directory was deleted on return. The failure now reports + **the failing task, the host and ansible's own message**; when no event was + emitted at all — which is exactly what `Stats: {}` means — it falls back to + the last meaningful lines of ansible's output; and the full trace is kept + under `~/.local/state/dsoxlab/ansible/`, timestamped so a second attempt + does not overwrite the first. + ## [0.3.4] - 2026-10-10 ### Added diff --git a/src/dsoxlab/i18n/strings/en.py b/src/dsoxlab/i18n/strings/en.py index 72498e4..09dfa9a 100644 --- a/src/dsoxlab/i18n/strings/en.py +++ b/src/dsoxlab/i18n/strings/en.py @@ -1595,8 +1595,12 @@ "Lab {lab_id} must ship setup.yaml at its root (dsoxlab contract for " "runtime: vm). Expected file: {path}", "err_vm_setup_failed": - "setup.yaml failed for {lab_id} on target '{target}' " - "(rc={rc}, status={status}). Stats: {stats}", + "setup.yaml failed for {lab_id} on target '{target}' (rc={rc}, status={status}).{detail}", + "ansible_sans_message": "(no message)", + "ansible_autres_echecs": + " … and {count} more failed task(s), see the trace", + "ansible_trace_conservee": + " Full ansible output: {path}", "err_vm_cleanup_missing": "Lab {lab_id} must ship cleanup.yaml at its root (dsoxlab contract " "for runtime: vm). Expected file: {path}", diff --git a/src/dsoxlab/i18n/strings/fr.py b/src/dsoxlab/i18n/strings/fr.py index caae11e..04355ea 100644 --- a/src/dsoxlab/i18n/strings/fr.py +++ b/src/dsoxlab/i18n/strings/fr.py @@ -1633,8 +1633,12 @@ "Le lab {lab_id} doit fournir setup.yaml à la racine (contrat dsoxlab " "pour runtime: vm). Fichier attendu : {path}", "err_vm_setup_failed": - "setup.yaml a échoué pour {lab_id} sur la target « {target} » " - "(rc={rc}, status={status}). Stats : {stats}", + "setup.yaml a échoué pour {lab_id} sur la target « {target} » (rc={rc}, status={status}).{detail}", + "ansible_sans_message": "(aucun message)", + "ansible_autres_echecs": + " … et {count} autre(s) tâche(s) en échec, voir la trace", + "ansible_trace_conservee": + " Sortie complète d'ansible : {path}", "err_vm_cleanup_missing": "Le lab {lab_id} doit fournir cleanup.yaml à la racine (contrat " "dsoxlab pour runtime: vm). Fichier attendu : {path}", diff --git a/src/dsoxlab/infra/ansible.py b/src/dsoxlab/infra/ansible.py index db95d7f..45dbdc6 100644 --- a/src/dsoxlab/infra/ansible.py +++ b/src/dsoxlab/infra/ansible.py @@ -23,6 +23,7 @@ from collections.abc import Callable from contextlib import suppress from dataclasses import dataclass, field +from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -32,6 +33,42 @@ logger = logging.getLogger(__name__) + +@dataclass(frozen=True) +class TacheEnEchec: + """Ce qu'ansible a dit d'une tâche qui n'est pas passée. + + Reconstitué depuis l'événement ``runner_on_failed`` ou + ``runner_on_unreachable``, qui porte déjà tout : dsoxlab le recevait et le + laissait filer. + """ + + tache: str + """Le nom de la tâche, tel que le playbook l'écrit.""" + + hote: str + """L'hôte sur lequel elle a échoué.""" + + message: str + """Le message d'ansible — souvent la seule ligne qui explique.""" + + injoignable: bool = False + """``True`` pour un ``unreachable`` : la cause est le lien, pas la tâche.""" + + def resume(self, largeur: int = 400) -> str: + """Une ligne par fait, prête à être affichée. + + Le repli sur « (aucun message) » est ici et pas seulement chez + l'appelant : un `TacheEnEchec` construit à la main — par un test, ou + par un futur appelant — produisait sinon une ligne qui s'arrête sur + deux-points, ce qui se lit comme un bug d'affichage plutôt que comme + un module muet. + """ + quoi = "injoignable" if self.injoignable else "échec" + message = " ".join(self.message.split())[:largeur] or _("ansible_sans_message") + return f"{self.tache} — {self.hote} ({quoi}) : {message}" + + @dataclass class PlaybookResult: """Résultat structuré d'une exécution de playbook.""" @@ -54,6 +91,17 @@ class PlaybookResult: stdout: str = "" """Sortie agrégée (utile pour debug).""" + echecs: list[TacheEnEchec] = field(default_factory=list) + """Les tâches qui ont échoué, dans l'ordre où ansible les a signalées. + + Vide ne veut pas dire « tout va bien » : un playbook interrompu avant la + première tâche n'en produit aucune, et c'est précisément le cas qui + laissait l'utilisateur sans rien (``Stats: {}``). L'appelant doit donc + retomber sur ``stdout``, jamais conclure du vide.""" + + trace: Path | None = None + """Où la sortie complète a été conservée, quand elle l'a été.""" + @property def ok(self) -> bool: return self.rc == 0 and self.status == "successful" @@ -182,17 +230,53 @@ def run_playbook( if not project_link.exists(): project_link.symlink_to(project_dir, target_is_directory=True) + # Les échecs, captés au vol. L'événement porte déjà le nom de la + # tâche, l'hôte et le message : il suffisait de les garder. + echecs: list[TacheEnEchec] = [] + + def _collecter(event: dict[str, Any]) -> None: + nom = event.get("event") + if nom not in {"runner_on_failed", "runner_on_unreachable"}: + return + donnees = event.get("event_data") or {} + resultat = donnees.get("res") or {} + # `msg` est le plus courant ; `stderr` et `reason` couvrent les + # modules qui n'en posent pas. `module_stderr` sert aux échecs de + # connexion, où `msg` est souvent laconique. + message = "" + for cle in ("msg", "reason", "stderr", "module_stderr", "stdout"): + valeur = resultat.get(cle) + if isinstance(valeur, str) and valeur.strip(): + message = valeur.strip() + break + if not message: + message = str(resultat) if resultat else "(aucun message)" + echecs.append(TacheEnEchec( + tache=str(donnees.get("task") or "(tâche sans nom)"), + hote=str(donnees.get("host") or "?"), + message=message, + injoignable=nom == "runner_on_unreachable", + )) + kwargs: dict[str, Any] = {} - if on_event is not None: + if True: def _safe_handler(event: dict[str, Any]) -> bool: """ansible-runner event_handler : doit retourner True pour continuer (False arrête le run). On wrap le callback utilisateur pour ne pas tuer le playbook si la CLI plante.""" + # La collecte d'abord, et hors du try de l'appelant : perdre + # le diagnostic parce que la barre de progression a planté + # serait exactement le défaut qu'on corrige. try: - on_event(event) - except Exception: # le callback ne doit pas tuer le playbook - logger.exception("on_event callback failed") + _collecter(event) + except Exception: + logger.exception("could not collect the ansible failure event") + if on_event is not None: + try: + on_event(event) + except Exception: # le callback ne doit pas tuer le playbook + logger.exception("on_event callback failed") return True kwargs["event_handler"] = _safe_handler @@ -242,17 +326,102 @@ def _prevenir(rang: int) -> None: if relais.is_requested() or (runner.status or "") == "canceled": raise Interrupted(Stage.ANSIBLE, hard=relais.count >= 2) + sortie = _read_stdout(runner) + statut = runner.status or "unknown" + code = runner.rc if runner.rc is not None else -1 + + # La trace n'est écrite QUE sur échec, et elle survit au tmpdir : sans + # elle, la seule copie de ce qu'ansible a dit disparaît au `cleanup()` + # ci-dessous. C'est ce qui a laissé l'issue kubernetes#94 sans + # diagnostic pendant deux jours. + trace: Path | None = None + if (code != 0 or statut != "successful") and sortie: + trace = _conserver_la_trace(playbook_path, sortie) + return PlaybookResult( - rc=runner.rc if runner.rc is not None else -1, - status=runner.status or "unknown", + rc=code, + status=statut, stats=runner.stats or {}, - stdout=_read_stdout(runner), + stdout=sortie, + echecs=echecs, + trace=trace, ) finally: if tmpdir_obj is not None: tmpdir_obj.cleanup() +def detailler(result: PlaybookResult, *, taches: int = 3) -> str: + """Ce qu'il faut lire quand un playbook échoue, en quelques lignes. + + Trois niveaux, du plus utile au plus brut, parce qu'aucun n'est toujours + disponible : + + 1. les tâches qu'ansible a déclarées en échec — le cas courant ; + 2. à défaut, les dernières lignes parlantes de sa sortie — c'est ce qui + reste quand le playbook meurt avant la première tâche, cas où les + stats sont vides et où l'ancien message ne disait **rien** ; + 3. et toujours, le chemin de la trace complète. + + Le deuxième niveau est celui qui manquait : l'issue kubernetes#94 montre + « Stats: {} », donc aucun événement d'échec, donc rien à afficher si l'on + se contentait du premier. + """ + lignes: list[str] = [] + + for echec in result.echecs[:taches]: + lignes.append(f" ✘ {echec.resume()}") + reste = len(result.echecs) - taches + if reste > 0: + lignes.append(_("ansible_autres_echecs", count=reste)) + + if not lignes and result.stdout: + # Les lignes qui disent quelque chose : on écarte le décor d'ansible + # (titres de PLAY et de TASK, séparateurs) pour garder les messages. + bruit = ("PLAY [", "TASK [", "PLAY RECAP", "***", "ok:", "skipping:") + utiles = [ + ligne.rstrip() + for ligne in result.stdout.splitlines() + if ligne.strip() and not ligne.lstrip().startswith(bruit) + ] + lignes.extend(f" {ligne}" for ligne in utiles[-6:]) + + if result.trace is not None: + lignes.append(_("ansible_trace_conservee", path=str(result.trace))) + + if not lignes: + return "" + return "\n" + "\n".join(lignes) + + +def _conserver_la_trace(playbook_path: Path, sortie: str) -> Path | None: + """Écrit la sortie d'ansible là où on pourra la relire, et rend le chemin. + + Sous ``XDG_STATE_HOME`` plutôt que dans un tmpdir : un diagnostic qui + disparaît avec le processus qui l'a produit ne sert à personne. Le nom + porte le playbook et l'horodatage, pour qu'un second essai n'écrase pas le + premier — c'est souvent la comparaison des deux qui parle. + + Toute erreur est avalée : ne pas pouvoir écrire une trace ne doit jamais + remplacer l'échec qu'on cherchait à expliquer. + """ + try: + base = Path( + os.environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state" + ) / "dsoxlab" / "ansible" + base.mkdir(parents=True, exist_ok=True) + # UTC et non l'heure locale : une trace se compare entre machines, + # et `dsoxlab support` la collecte. + horodatage = datetime.now(tz=UTC).strftime("%Y%m%dT%H%M%S") + chemin = base / f"{playbook_path.parent.name}-{playbook_path.stem}-{horodatage}.log" + chemin.write_text(sortie, encoding="utf-8") + logger.info("ansible output kept at %s", chemin) + except OSError: + logger.exception("could not keep the ansible trace") + return None + return chemin + + def _env_overrides() -> dict[str, str]: """Variables d'env qu'on force pour cohérence des labs. diff --git a/src/dsoxlab/runtimes/vm.py b/src/dsoxlab/runtimes/vm.py index cf7365f..347c715 100644 --- a/src/dsoxlab/runtimes/vm.py +++ b/src/dsoxlab/runtimes/vm.py @@ -116,7 +116,7 @@ def start( raise RuntimeError(_( "err_vm_setup_failed", lab_id=lab.id, target=target.name, rc=result.rc, - status=result.status, stats=result.stats, + status=result.status, detail=ansible_infra.detailler(result), )) def stop(self, lab: LabDefinition, target_name: str | None = None) -> None: diff --git a/tests/test_ansible_dire_la_cause.py b/tests/test_ansible_dire_la_cause.py new file mode 100644 index 0000000..192a461 --- /dev/null +++ b/tests/test_ansible_dire_la_cause.py @@ -0,0 +1,157 @@ +"""Un playbook qui échoue doit dire QUELLE tâche, et POURQUOI. + +Écrit pour l'issue kubernetes#94, restée deux jours sans diagnostic possible +avec ce seul message : + + setup.yaml a échoué pour cka-troubleshoot-apiserver sur la target « cp » + (rc=4, status=failed). Stats : {} + +Trois faits se cachaient derrière : dsoxlab recevait les événements +`runner_on_failed` d'ansible et les laissait filer, il capturait la sortie +complète dans `PlaybookResult.stdout` sans jamais l'afficher, et il supprimait +le répertoire d'artefacts au retour. Ce qui est éprouvé ici est donc le +diagnostic lui-même — pas la mise en forme, mais ce qu'il contient. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from dsoxlab.infra.ansible import PlaybookResult, TacheEnEchec, detailler + + +def _echec(**surcharges: object) -> TacheEnEchec: + base: dict[str, object] = { + "tache": "Initialiser le plan de contrôle", + "hote": "k8s-cp.lab", + "message": "kubeadm init a rendu 1", + } + base.update(surcharges) + return TacheEnEchec(**base) # type: ignore[arg-type] + + +# ── Le cas courant : ansible a signalé des tâches ────────────────────────── + + +def test_la_tache_fautive_est_nommee() -> None: + """Sans son nom, on ne sait pas où chercher dans un socle de 230 lignes.""" + detail = detailler(PlaybookResult(rc=2, status="failed", echecs=[_echec()])) + assert "Initialiser le plan de contrôle" in detail + assert "k8s-cp.lab" in detail + assert "kubeadm init a rendu 1" in detail + + +def test_un_hote_injoignable_se_distingue_d_une_tache_en_echec() -> None: + """Les deux appellent des gestes opposés : réparer le lab, ou le lien.""" + detail = detailler( + PlaybookResult(rc=4, status="failed", echecs=[_echec(injoignable=True)]) + ) + assert "injoignable" in detail + + +def test_les_echecs_en_nombre_sont_bornes_et_le_reste_annonce() -> None: + """Vingt tâches en échec noieraient la première, qui est souvent la cause.""" + detail = detailler( + PlaybookResult( + rc=2, + status="failed", + echecs=[_echec(tache=f"tâche {n}") for n in range(10)], + ), + taches=3, + ) + assert "tâche 0" in detail + assert "tâche 9" not in detail + assert "7" in detail, "le nombre de tâches non montrées doit être annoncé" + + +# ── Le cas de l'issue #94 : aucun événement, des stats vides ─────────────── + + +def test_sans_aucun_echec_signale_la_sortie_brute_prend_le_relais() -> None: + """C'est exactement `Stats: {}` : le playbook meurt avant toute tâche. + + Se contenter des événements laisserait alors un message vide — le défaut + d'origine. Les dernières lignes parlantes de la sortie sont la seule chose + qui reste. + """ + sortie = ( + "PLAY [Préparer le lab] *********************************\n" + "\n" + "TASK [Gathering Facts] *********************************\n" + "ok: [k8s-cp.lab]\n" + "ERROR! couldn't resolve module/action 'kubernetes.core.k8s'\n" + ) + detail = detailler(PlaybookResult(rc=4, status="failed", stdout=sortie)) + assert "couldn't resolve module/action" in detail + + +def test_le_decor_d_ansible_est_ecarte_de_la_sortie_brute() -> None: + """Montrer « PLAY [...] » et « ok: [host] » n'apprend rien à personne.""" + sortie = "\n".join( + [ + "PLAY [x] ****", + "TASK [y] ****", + "ok: [h]", + "skipping: [h]", + "ERROR! la seule ligne qui compte", + ] + ) + detail = detailler(PlaybookResult(rc=4, status="failed", stdout=sortie)) + assert "la seule ligne qui compte" in detail + assert "PLAY [x]" not in detail + assert "skipping" not in detail + + +def test_les_evenements_priment_sur_la_sortie_brute() -> None: + """Quand ansible a nommé la tâche, inutile de relire son flux de texte.""" + detail = detailler( + PlaybookResult( + rc=2, + status="failed", + echecs=[_echec()], + stdout="ERROR! une ligne qui ne doit pas s'afficher", + ) + ) + assert "Initialiser le plan de contrôle" in detail + assert "ne doit pas s'afficher" not in detail + + +# ── La trace, qui survit au répertoire temporaire ────────────────────────── + + +def test_le_chemin_de_la_trace_est_donne(tmp_path: Path) -> None: + """Le détail tient en quelques lignes ; le reste doit rester joignable.""" + trace = tmp_path / "setup-20261012T120000.log" + detail = detailler( + PlaybookResult(rc=2, status="failed", echecs=[_echec()], trace=trace) + ) + assert str(trace) in detail + + +def test_un_succes_ne_produit_aucun_detail() -> None: + """Rien à dire quand tout va bien : le message ne doit pas s'alourdir.""" + assert detailler(PlaybookResult(rc=0, status="successful")) == "" + + +# ── Le résumé d'une tâche ────────────────────────────────────────────────── + + +def test_le_message_est_remis_sur_une_ligne_et_borne() -> None: + """Un `msg` d'ansible porte souvent une trace entière, sur dix lignes. + + Telle quelle, elle noierait le terminal et la barre de progression Rich + qui possède l'écran à cet instant. + """ + resume = _echec(message="première ligne\n deuxième\n\n\ttroisième" + "x" * 900).resume() + assert "\n" not in resume + assert "première ligne deuxième troisième" in resume + assert len(resume) < 600 + + +@pytest.mark.parametrize("vide", ["", " ", "\n"]) +def test_un_message_vide_ne_produit_pas_une_ligne_muette(vide: str) -> None: + """Mieux vaut « (aucun message) » qu'une ligne qui s'arrête sur deux-points.""" + resume = _echec(message=vide).resume() + assert resume.rstrip().endswith((":", ")")) is False or "aucun" in resume From 24c3f9a73e1c765be9ce08825cefd2578746d995 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20ROBERT?= Date: Mon, 12 Oct 2026 08:28:26 +0200 Subject: [PATCH 2/3] chore(release): 0.3.5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le diagnostic des playbooks en échec, publié pour que les deux rapporteurs de kubernetes#94 puissent enfin nommer leur cause. Sans cette version, la demande qu'on leur adresse n'a aucun moyen d'aboutir : leur 0.3.3 ne produit que « rc=4, Stats : {} ». Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.fr.md | 5 +++-- CHANGELOG.md | 5 +++-- pyproject.toml | 2 +- uv.lock | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index f94f918..976acb2 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -7,7 +7,7 @@ Toutes les modifications notables du projet sont documentées dans ce fichier. Le format s'appuie sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et le projet suit le [versionnage sémantique](https://semver.org/lang/fr/). -## [Non publié] +## [0.3.5] - 2026-10-12 ### Corrigé @@ -4632,7 +4632,8 @@ Première version publique. - Diagnostics de l'environnement (`dsoxlab doctor [--fix]`). - Interface utilisateur bilingue (anglais/français) pilotée par `DSOXLAB_LANG`. -[Unreleased]: https://github.com/stephrobert/dsoxlab/compare/v0.3.4...HEAD +[Unreleased]: https://github.com/stephrobert/dsoxlab/compare/v0.3.5...HEAD +[0.3.5]: https://github.com/stephrobert/dsoxlab/compare/v0.3.4...v0.3.5 [0.3.4]: https://github.com/stephrobert/dsoxlab/compare/v0.3.3...v0.3.4 [0.3.3]: https://github.com/stephrobert/dsoxlab/compare/v0.3.2...v0.3.3 [0.3.2]: https://github.com/stephrobert/dsoxlab/compare/v0.3.1...v0.3.2 diff --git a/CHANGELOG.md b/CHANGELOG.md index f2c8717..8b6fb4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.3.5] - 2026-10-12 ### Fixed @@ -4379,7 +4379,8 @@ Initial public release. - Environment diagnostics (`dsoxlab doctor [--fix]`). - Bilingual (English/French) user interface driven by `DSOXLAB_LANG`. -[Unreleased]: https://github.com/stephrobert/dsoxlab/compare/v0.3.4...HEAD +[Unreleased]: https://github.com/stephrobert/dsoxlab/compare/v0.3.5...HEAD +[0.3.5]: https://github.com/stephrobert/dsoxlab/compare/v0.3.4...v0.3.5 [0.3.4]: https://github.com/stephrobert/dsoxlab/compare/v0.3.3...v0.3.4 [0.3.3]: https://github.com/stephrobert/dsoxlab/compare/v0.3.2...v0.3.3 [0.3.2]: https://github.com/stephrobert/dsoxlab/compare/v0.3.1...v0.3.2 diff --git a/pyproject.toml b/pyproject.toml index 1e73cfd..9df8b03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "dsoxlab" -version = "0.3.4" +version = "0.3.5" description = "Turn declarative exercises into reproducible, runnable and verifiable lab environments" readme = "README.md" requires-python = ">=3.11" diff --git a/uv.lock b/uv.lock index e8dd1de..6c169f0 100644 --- a/uv.lock +++ b/uv.lock @@ -331,7 +331,7 @@ wheels = [ [[package]] name = "dsoxlab" -version = "0.3.4" +version = "0.3.5" source = { editable = "." } dependencies = [ { name = "ansible-core" }, From 826395b3d344bdaa26d56c4e1da6b4de48356574 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20ROBERT?= Date: Mon, 12 Oct 2026 08:34:44 +0200 Subject: [PATCH 3/3] test(ansible): le test du message vide mesurait la locale, pas le comportement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Il cherchait le mot « aucun », donc la traduction FRANÇAISE du repli. Il passait sur ma machine, où le contexte du dépôt fixe la langue à `fr`, et tombait sur les quatre versions de Python en CI, où la langue par défaut est l'anglais et où le repli s'écrit « (no message) ». L'invariant n'a jamais été le texte : c'est qu'une ligne ne s'arrête pas sur deux-points, ce qui se lirait comme un bug d'affichage plutôt que comme un module muet. C'est lui qui est testé maintenant. Toute la suite est désormais jouée dans les deux langues — 1317 verts sous `DSOXLAB_LANG=en` comme sous `DSOXLAB_LANG=fr`. C'est le contrôle qui manquait, et que la CI faisait à ma place sans que j'y pense. Co-Authored-By: Claude Opus 5 (1M context) --- tests/test_ansible_dire_la_cause.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/test_ansible_dire_la_cause.py b/tests/test_ansible_dire_la_cause.py index 192a461..f309394 100644 --- a/tests/test_ansible_dire_la_cause.py +++ b/tests/test_ansible_dire_la_cause.py @@ -152,6 +152,14 @@ def test_le_message_est_remis_sur_une_ligne_et_borne() -> None: @pytest.mark.parametrize("vide", ["", " ", "\n"]) def test_un_message_vide_ne_produit_pas_une_ligne_muette(vide: str) -> None: - """Mieux vaut « (aucun message) » qu'une ligne qui s'arrête sur deux-points.""" + """Une ligne qui s'arrête sur deux-points se lit comme un bug d'affichage. + + L'invariant est là, et **pas** dans le texte du repli : la première + version de ce test cherchait le mot « aucun », donc la traduction + française. Il passait sur une machine en français et tombait en CI, où la + langue par défaut est l'anglais — un test qui mesurait la locale de son + auteur plutôt que le comportement. + """ resume = _echec(message=vide).resume() - assert resume.rstrip().endswith((":", ")")) is False or "aucun" in resume + assert not resume.rstrip().endswith(":"), resume + assert resume.rstrip() != f"{_echec().tache} — k8s-cp.lab (échec) :"