diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 5606977..6f6543a 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -9,6 +9,21 @@ et le projet suit le [versionnage sémantique](https://semver.org/lang/fr/). ## [Non publié] +## [0.1.84] - 2026-08-24 + +### Ajouté + +- **`doctor` contrôle désormais le pool de stockage Incus** + (linux-dsoxlab-training#54). Un utilisateur a signalé que sur un + provisionnement Incus raté, « seules les résolutions liées à KVM sont + proposées ». Le symptôme avait raison et l'hypothèse la plus évidente avait + tort : `_check_incus` **propose bien** `incus admin init` — mais seulement + quand `incus list` échoue en le disant. Or `incus list` **réussit** sur une + installation jamais initialisée : elle rend simplement une liste vide. Le + contrôle passait au vert, et la branche Incus de `doctor` n'ajoutait que + l'outil ISO, là où la branche KVM contrôle son pool depuis longtemps. Le + template crée le réseau mais écrit `pool = "default"` en dur sans le créer. + ## [0.1.83] - 2026-08-24 ### Modifié diff --git a/CHANGELOG.md b/CHANGELOG.md index ff8f993..9c68801 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.84] - 2026-08-24 + +### Added + +- **`doctor` now checks the Incus storage pool** + (linux-dsoxlab-training#54). A user reported that on a failed Incus + provisioning, "only the KVM-related fixes are offered". The symptom was right + and the obvious hypothesis wrong: `_check_incus` *does* offer `incus admin + init` — but only when `incus list` fails saying so. `incus list` **succeeds** + on a never-initialised install: it simply returns an empty list. The check + passed green, and the Incus branch of `doctor` only added the ISO tool, where + the KVM branch has checked its storage pool for a long time. The template + creates the network but hard-codes `pool = "default"` without creating it. + ## [0.1.83] - 2026-08-24 ### Changed diff --git a/pyproject.toml b/pyproject.toml index 6fb14ce..b713e7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "dsoxlab" -version = "0.1.83" +version = "0.1.84" description = "Turn declarative exercises into reproducible, runnable and verifiable lab environments" readme = "README.md" requires-python = ">=3.11" diff --git a/src/dsoxlab/i18n/strings/en.py b/src/dsoxlab/i18n/strings/en.py index 91ea4fc..f310850 100644 --- a/src/dsoxlab/i18n/strings/en.py +++ b/src/dsoxlab/i18n/strings/en.py @@ -919,6 +919,12 @@ "check_resources": "RAM / disk resources", "detail_shell_always": "always available", + "check_incus_pool": "Incus pool", + "detail_incus_pool_absent": + "pool '{pool}' does not exist: incus was never initialised, and " + "'provision' will fail", + "detail_incus_pool_muet": + "cannot list incus pools: state unknown", "detail_incus_missing": "not found", "detail_incus_ok": "client {version}, daemon ok", "detail_incus_daemon_down": "client {version}, daemon inactive", diff --git a/src/dsoxlab/i18n/strings/fr.py b/src/dsoxlab/i18n/strings/fr.py index ba42619..236e603 100644 --- a/src/dsoxlab/i18n/strings/fr.py +++ b/src/dsoxlab/i18n/strings/fr.py @@ -929,6 +929,12 @@ "check_resources": "Ressources RAM / disque", "detail_shell_always": "toujours disponible", + "check_incus_pool": "Pool Incus", + "detail_incus_pool_absent": + "le pool « {pool} » n'existe pas : incus n'a jamais été initialisé, " + "et « provision » échouera", + "detail_incus_pool_muet": + "impossible de lister les pools incus : état inconnu", "detail_incus_missing": "introuvable", "detail_incus_ok": "client {version}, daemon ok", "detail_incus_daemon_down": "client {version}, daemon inactif", diff --git a/src/dsoxlab/services/doctor.py b/src/dsoxlab/services/doctor.py index 015678f..576c904 100644 --- a/src/dsoxlab/services/doctor.py +++ b/src/dsoxlab/services/doctor.py @@ -882,6 +882,43 @@ def explique_echec_provision(message: str) -> tuple[str, str] | None: return None +#: Le pool que le template Incus écrit en dur dans ses volumes. Il n'est +#: **pas** créé par Terraform, contrairement au réseau : `incus admin init` est +#: le seul geste qui le pose. +_POOL_INCUS = "default" + + +def _check_incus_pool() -> Check: + """Le pool de stockage d'Incus existe-t-il ? + + `incus list` réussit sur une installation jamais initialisée — elle rend + simplement une liste vide — si bien que `_check_incus` passait au vert et + que rien n'annonçait l'échec de `provision`. Le template crée le réseau + mais **suppose** le pool : il l'écrit en dur dans chaque volume. + + C'est le pendant exact de `_check_libvirt_pool`, qui existait déjà côté + KVM. Son absence côté Incus est ce qui faisait dire à un utilisateur que + « seules les résolutions liées à KVM sont proposées ». + """ + sonde = _sonder(["incus", "storage", "list", "--format", "csv"]) + if sonde is None or sonde.returncode != 0: + # Ne rien pouvoir mesurer n'est ni un pool présent ni un pool absent. + return _check("incus_pool", False, _("detail_incus_pool_muet"), + forced_state=STATE_UNKNOWN) + + pools = [ + ligne.split(",")[0] + for ligne in sonde.stdout.splitlines() if ligne.strip() + ] + if _POOL_INCUS in pools: + return _check("incus_pool", True, _POOL_INCUS) + return _check( + "incus_pool", False, + _("detail_incus_pool_absent", pool=_POOL_INCUS), + fix=_fix(["sudo", "incus", "admin", "init", "--auto"]), + ) + + def _check_iso_tool() -> Check: """Incus fabrique le CD-ROM ``agent:config`` sur l'hôte. @@ -1180,6 +1217,9 @@ def collect_checks(root: Path, repo_meta: RepoMetadata | None) -> DoctorReport: or "default") report.required.append(_check_libvirt_pool(pool)) elif active == "incus" and hypervisors["incus"].ok: + # Symétrie avec la branche kvm juste au-dessus : elle contrôle son + # pool de stockage, celle-ci ne contrôlait que l'outil ISO. + report.required.append(_check_incus_pool()) report.required.append(_check_iso_tool()) report.required.append(_check_labs(root, labs, compter_fichiers_labs(root))) diff --git a/tests/test_incus_pool.py b/tests/test_incus_pool.py new file mode 100644 index 0000000..f7bb72d --- /dev/null +++ b/tests/test_incus_pool.py @@ -0,0 +1,171 @@ +"""Le pool Incus est diagnostiqué comme celui de libvirt (linux-dsoxlab-training#54). + +Un utilisateur a signalé que sur un provisionnement Incus raté faute d'avoir +joué `incus admin init --auto`, « seules les résolutions liées à KVM sont +proposées ». La vérification a donné raison au symptôme et tort à l'hypothèse +la plus évidente : `_check_incus` **propose bien** `incus admin init` — mais +seulement quand `incus list` échoue en le disant. + +Or `incus list` **réussit** sur une installation jamais initialisée : elle rend +simplement une liste vide. Le contrôle passait donc au vert, et le seul contrôle +supplémentaire de la branche Incus était l'outil ISO — là où la branche KVM +contrôle son pool de stockage depuis longtemps. + +Le template le suppose sans le créer : il crée bien le réseau +(`resource "incus_network" "lab"`) mais écrit `pool = "default"` en dur dans +chaque volume. Sans ce pool, `provision` échoue. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest + +from dsoxlab.services import doctor + + +class _Sortie: + """Ce que `_sonder` rend : un CompletedProcess, ou None s'il n'a pas répondu.""" + + def __init__(self, stdout: str = "", returncode: int = 0) -> None: + self.stdout = stdout + self.stderr = "" + self.returncode = returncode + + +def _sonde(monkeypatch: pytest.MonkeyPatch, resultat: Any) -> None: + monkeypatch.setattr(doctor, "_sonder", lambda *a, **k: resultat) + + +# ── Les trois états, comme pour le pool libvirt ───────────────────────────── + +def test_un_pool_present_passe_au_vert(monkeypatch: pytest.MonkeyPatch) -> None: + _sonde(monkeypatch, _Sortie("default,zfs,,14,CREATED\n")) + + controle = doctor._check_incus_pool() + + assert controle.ok is True + assert "default" in controle.detail + + +def test_un_pool_absent_propose_l_initialisation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Le cas de l'issue : incus répond, mais n'a aucun pool.""" + _sonde(monkeypatch, _Sortie("")) + + controle = doctor._check_incus_pool() + + assert controle.ok is False + assert controle.fix is not None + assert ("sudo", "incus", "admin", "init", "--auto") in controle.fix.commands + + +def test_une_sonde_muette_ne_tranche_pas(monkeypatch: pytest.MonkeyPatch) -> None: + """Ne pas pouvoir lister n'est ni un pool présent ni un pool absent. + + Proposer `incus admin init` sur une machine dont on ignore l'état + réinitialiserait peut-être une installation qui fonctionne. + """ + _sonde(monkeypatch, None) + + controle = doctor._check_incus_pool() + + assert controle.state == doctor.STATE_UNKNOWN + assert controle.fix is None + + +def test_un_autre_pool_ne_suffit_pas(monkeypatch: pytest.MonkeyPatch) -> None: + """Le template écrit `default` en dur : c'est celui-là qu'il faut. + + Un pool nommé autrement existe sans que le provisionnement fonctionne. + """ + _sonde(monkeypatch, _Sortie("autre-pool,dir,,3,CREATED\n")) + + assert doctor._check_incus_pool().ok is False + + +# ── Le classement : Incus rattrape la symétrie qui lui manquait ───────────── + +def test_le_pool_incus_est_requis_quand_incus_est_actif( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """C'est l'absence de ce contrôle qui a produit l'issue.""" + monkeypatch.setattr(doctor, "_check_incus", + lambda: doctor._check("incus", True, "7.2")) + monkeypatch.setattr(doctor, "_check_kvm", + lambda: doctor._check("kvm", False, "absent")) + _sonde(monkeypatch, _Sortie("default,zfs,,14,CREATED\n")) + + (tmp_path / "meta.yml").write_text( + "repo:\n id: essai\n category: essai\n" + "infra:\n provider: incus\n hosts:\n - name: h.lab\n", + encoding="utf-8") + base = tmp_path / "labs" / "l1" + base.mkdir(parents=True) + (base / "lab.yaml").write_text( + "id: l1\ntitle: T\nlevel: l1\nskills: [s]\ndistros: [any]\n" + "doc_url: https://example.org/\n" + "runtime:\n type: vm\n targets:\n - name: c\n host: h.lab\n", + encoding="utf-8") + + from dsoxlab.discovery.scanner import read_repo_metadata + + rapport = doctor.collect_checks(tmp_path, read_repo_metadata(tmp_path)) + + assert "incus_pool" in [c.key for c in rapport.required] + + +def test_le_pool_incus_ne_gene_pas_un_depot_kvm( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """Un dépôt qui provisionne en KVM n'a pas à voir de contrôle Incus. + + C'est la règle du dépôt appliquée aux providers : jamais de rouge pour un + composant que ce catalogue n'utilise pas. + """ + monkeypatch.setattr(doctor, "_check_kvm", + lambda: doctor._check("kvm", True, "ok")) + monkeypatch.setattr(doctor, "_check_libvirt_pool", + lambda pool: doctor._check("libvirt_pool", True, pool)) + + (tmp_path / "meta.yml").write_text( + "repo:\n id: essai\n category: essai\n" + "infra:\n provider: kvm\n hosts:\n - name: h.lab\n", + encoding="utf-8") + base = tmp_path / "labs" / "l1" + base.mkdir(parents=True) + (base / "lab.yaml").write_text( + "id: l1\ntitle: T\nlevel: l1\nskills: [s]\ndistros: [any]\n" + "doc_url: https://example.org/\n" + "runtime:\n type: vm\n targets:\n - name: c\n host: h.lab\n", + encoding="utf-8") + + from dsoxlab.discovery.scanner import read_repo_metadata + + rapport = doctor.collect_checks(tmp_path, read_repo_metadata(tmp_path)) + + assert "incus_pool" not in [c.key for c in rapport.required] + + +# ── Ce que le template suppose, et qui justifie le contrôle ──────────────── + +def test_le_template_suppose_le_pool_sans_le_creer() -> None: + """Si un jour Terraform crée le pool, ce contrôle n'a plus lieu d'être. + + Le test le dira : il échouera quand la ressource apparaîtra, et personne + n'aura à se souvenir que ce diagnostic existait pour cette raison. + """ + import dsoxlab + + main = (Path(dsoxlab.__file__).resolve().parent / "templates" / "terraform" + / "incus" / "main.tf").read_text(encoding="utf-8") + + assert 'pool = "default"' in main or '"pool" = "default"' in main, ( + "le template ne référence plus le pool en dur" + ) + assert 'resource "incus_storage_pool"' not in main, ( + "le template crée désormais le pool : le contrôle doctor est caduc" + ) diff --git a/uv.lock b/uv.lock index c1bd419..ab93570 100644 --- a/uv.lock +++ b/uv.lock @@ -313,7 +313,7 @@ wheels = [ [[package]] name = "dsoxlab" -version = "0.1.83" +version = "0.1.84" source = { editable = "." } dependencies = [ { name = "ansible-core", version = "2.19.12", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },