Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion CHANGELOG.fr.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/).

## [0.3.5] - 2026-10-12

### 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é
Expand Down Expand Up @@ -4613,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
Expand Down
21 changes: 20 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

## [0.3.5] - 2026-10-12

### 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
Expand Down Expand Up @@ -4361,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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 6 additions & 2 deletions src/dsoxlab/i18n/strings/en.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down
8 changes: 6 additions & 2 deletions src/dsoxlab/i18n/strings/fr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down
183 changes: 176 additions & 7 deletions src/dsoxlab/infra/ansible.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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."""
Expand All @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion src/dsoxlab/runtimes/vm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading