diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee991af..4dff415 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -418,17 +418,30 @@ jobs: - name: Run every hook (push stage) run: uv run pre-commit run --all-files --hook-stage pre-push --show-diff-on-failure --color always - # Short fuzzing regression over the untrusted-YAML contract. lab.yaml and - # meta.yml come from lab-provider repositories, so they are this engine's - # main untrusted input; discovery/scanner.py only rattrape (KeyError, - # ValueError, YAMLError), and anything else crashes the CLI on an unrelated - # command. These harnesses assert that contract. + # Short fuzzing regression over every input the engine does NOT produce + # itself. Each one is untrusted for a different reason, and the reason is what + # decides the contract each harness asserts: + # + # lab.yaml, meta.yml written by a lab-provider repository. + # discovery/scanner.py only catches KeyError, + # ValueError and YAMLError; anything else crashes + # the CLI on an unrelated command. + # .dsoxlab-context.json written by `use`, but living on the learner's + # disk: hand-edited, truncated by a laptop closed + # mid-write, left over by an older version. + # read_context must NEVER raise, so that harness + # has no contract exception at all. + # terraform outputs produced by an external binary whose version, + # providers and output schema all move without + # dsoxlab knowing. The harness targets what + # build_inventory does with the decoded document, + # not the json.loads that precedes it. # # This is a regression gate, not a fuzzing campaign: a short seeded run keeps # the harnesses alive and catches the obvious breakages. Deep runs are done # locally with a persistent corpus. fuzz: - name: Fuzz the untrusted-YAML contract (short run) + name: Fuzz every untrusted input (short run) needs: [zizmor, actionlint, poutine] runs-on: ubuntu-24.04 timeout-minutes: 15 @@ -472,6 +485,23 @@ jobs: -dict=fuzz/dict/yaml_contract.dict \ -atheris_runs=20000 -max_len=4096 + # Pas de -dict ici : yaml_contract.dict porte des mots-clés du contrat + # YAML, sans rapport avec un document JSON. Un dictionnaire hors sujet + # coûte du budget sans guider la mutation. + - name: Fuzz the local session context + run: | + mkdir -p "${RUNNER_TEMP}/fuzz-context" + uv run --group fuzz python fuzz/fuzz_context_json.py \ + "${RUNNER_TEMP}/fuzz-context" fuzz/corpus/context_json/ \ + -atheris_runs=20000 -max_len=4096 + + - name: Fuzz the Terraform outputs the inventory is built from + run: | + mkdir -p "${RUNNER_TEMP}/fuzz-tfout" + uv run --group fuzz python fuzz/fuzz_terraform_outputs.py \ + "${RUNNER_TEMP}/fuzz-tfout" fuzz/corpus/terraform_outputs/ \ + -atheris_runs=20000 -max_len=4096 + # A crash writes a `crash-*` reproducer next to the harness. Keep it: it # is the exact input needed to replay the failure locally. - name: Upload crash reproducers diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5e56de6..684d651 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,10 +20,18 @@ repos: hooks: - id: trailing-whitespace exclude: '(^LICENSE$|\.md$)' + # `fuzz/corpus/` est hors du périmètre de l'hygiène, et pas par + # commodité : ce sont des graines DÉLIBÉRÉMENT malformées, qui existent + # pour être données à un fuzzer. Un JSON tronqué au milieu d'une chaîne + # reproduit un portable refermé pendant l'écriture ; le « réparer » ou le + # refuser retire au corpus le cas qu'il porte. Le contrat des documents, + # lui, est tenu par les harnais eux-mêmes, pas par ces hooks. - id: end-of-file-fixer - exclude: '^LICENSE$' + exclude: '(^LICENSE$|^fuzz/corpus/)' - id: check-yaml + exclude: '^fuzz/corpus/' - id: check-json + exclude: '^fuzz/corpus/' - id: check-toml - id: check-merge-conflict - id: check-case-conflict diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index e4e8c8e..541f741 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -9,6 +9,40 @@ et le projet suit le [versionnage sémantique](https://semver.org/lang/fr/). ## [Non publié] +## [0.1.64] - 2026-08-24 + +### Corrigé + +- **Un output Terraform mal formé faisait planter la construction de + l'inventaire.** `{"hosts": {"value": "10.99.0.11"}}` suffisait : le code + prenait la valeur pour un objet et appelait `.items()` dessus, ce qui rendait + un `AttributeError` au moment de jouer un lab, sans jamais dire que la cause + était un state Terraform périmé. Ce document fait trente-quatre octets et + personne ne l'avait écrit à la main : c'est le nouveau harnais de fuzzing qui + l'a trouvé, en moins de trente mille exécutions. + +### Ajouté + +- **Le fuzzing couvre désormais toutes les entrées que le moteur ne produit pas + lui-même.** Deux harnais s'ajoutent aux deux existants, et chacun assère un + contrat différent, parce que chaque entrée est non fiable pour une raison + différente : + + - **`.dsoxlab-context.json`** vit sur le disque de l'apprenant : édité à la + main par curiosité, tronqué par un portable refermé au mauvais moment, + laissé par une version ancienne. Son harnais n'a **aucune exception de + contrat**, et c'est tout son propos : `read_context` promet de rendre un + contexte vide plutôt que de lever, parce que perdre le contexte coûte un + `dsoxlab use` alors qu'une exception coûte la CLI entière. + - **Les outputs Terraform** viennent d'un binaire externe dont la version, + les providers et le schéma de sortie bougent sans que dsoxlab le sache. Le + harnais vise ce que `build_inventory` **fait** du document, et non le + `json.loads` qui le précède : celui-là est déjà protégé, et le fuzzer n'y + mesurerait que la bibliothèque standard. + + Le commentaire du job de CI énumère les entrées couvertes et dit, pour + chacune, pourquoi elle n'est pas fiable. + ## [0.1.63] - 2026-08-24 - **La documentation décrivait un produit qui n'existe pas.** Trois diff --git a/CHANGELOG.md b/CHANGELOG.md index e50c4c0..15595f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.64] - 2026-08-24 + +### Fixed + +- **A malformed Terraform output crashed the inventory build.** + `{"hosts": {"value": "10.99.0.11"}}` was enough: the code took the value for a + mapping and called `.items()` on it, producing an `AttributeError` at the very + moment someone runs a lab, and never saying that the cause was a stale + Terraform state. That document is thirty-four bytes and nobody had written it + by hand: the new fuzz harness found it in under thirty thousand runs. + +### Added + +- **Fuzzing now covers every input the engine does not produce itself.** Two + harnesses join the two existing ones, and each asserts a different contract, + because each input is untrusted for a different reason: + + - **`.dsoxlab-context.json`** lives on the learner's disk: hand-edited out of + curiosity, truncated by a laptop closed mid-write, left behind by an older + version. Its harness has **no contract exception at all**, and that is the + whole point: `read_context` promises to return an empty context rather than + raise, because losing the context costs one `dsoxlab use` where an exception + costs the entire CLI. + - **Terraform outputs** come from an external binary whose version, providers + and output schema all move without dsoxlab knowing. The harness targets what + `build_inventory` **does** with the document, not the `json.loads` before it: + that one is already guarded, and fuzzing it would only measure the standard + library. + + The CI job's header comment now lists the covered inputs and says, for each, + why it is considered untrusted. + ## [0.1.63] - 2026-08-24 - **The documentation described a product that does not exist.** Three claims diff --git a/fuzz/corpus/context_json/champ-inconnu.json b/fuzz/corpus/context_json/champ-inconnu.json new file mode 100644 index 0000000..f74e9d6 --- /dev/null +++ b/fuzz/corpus/context_json/champ-inconnu.json @@ -0,0 +1 @@ +{"section": "linux", "futur": {"a": 1}} diff --git a/fuzz/corpus/context_json/complet.json b/fuzz/corpus/context_json/complet.json new file mode 100644 index 0000000..bfd7365 --- /dev/null +++ b/fuzz/corpus/context_json/complet.json @@ -0,0 +1 @@ +{"section": "linux", "level": "l1", "lang": "fr", "active_lab": "l1-permissions", "active_target": "rhel", "active_provider": "kvm", "course_pos": 3} diff --git a/fuzz/corpus/context_json/position-negative.json b/fuzz/corpus/context_json/position-negative.json new file mode 100644 index 0000000..6c49dc9 --- /dev/null +++ b/fuzz/corpus/context_json/position-negative.json @@ -0,0 +1 @@ +{"course_pos": -5} diff --git a/fuzz/corpus/context_json/position-non-numerique.json b/fuzz/corpus/context_json/position-non-numerique.json new file mode 100644 index 0000000..3b0ca56 --- /dev/null +++ b/fuzz/corpus/context_json/position-non-numerique.json @@ -0,0 +1 @@ +{"course_pos": "trois"} diff --git a/fuzz/corpus/context_json/racine-chaine.json b/fuzz/corpus/context_json/racine-chaine.json new file mode 100644 index 0000000..c67b31e --- /dev/null +++ b/fuzz/corpus/context_json/racine-chaine.json @@ -0,0 +1 @@ +"texte" diff --git a/fuzz/corpus/context_json/racine-liste.json b/fuzz/corpus/context_json/racine-liste.json new file mode 100644 index 0000000..b5d8bb5 --- /dev/null +++ b/fuzz/corpus/context_json/racine-liste.json @@ -0,0 +1 @@ +[1, 2, 3] diff --git a/fuzz/corpus/context_json/racine-nulle.json b/fuzz/corpus/context_json/racine-nulle.json new file mode 100644 index 0000000..19765bd --- /dev/null +++ b/fuzz/corpus/context_json/racine-nulle.json @@ -0,0 +1 @@ +null diff --git a/fuzz/corpus/context_json/section-liste.json b/fuzz/corpus/context_json/section-liste.json new file mode 100644 index 0000000..2b97841 --- /dev/null +++ b/fuzz/corpus/context_json/section-liste.json @@ -0,0 +1 @@ +{"section": ["linux"]} diff --git a/fuzz/corpus/context_json/tronque.json b/fuzz/corpus/context_json/tronque.json new file mode 100644 index 0000000..4529999 --- /dev/null +++ b/fuzz/corpus/context_json/tronque.json @@ -0,0 +1 @@ +{"section": "lin \ No newline at end of file diff --git a/fuzz/corpus/context_json/vide.json b/fuzz/corpus/context_json/vide.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/fuzz/corpus/context_json/vide.json @@ -0,0 +1 @@ +{} diff --git a/fuzz/corpus/terraform_outputs/avec-bastion.json b/fuzz/corpus/terraform_outputs/avec-bastion.json new file mode 100644 index 0000000..608fcc0 --- /dev/null +++ b/fuzz/corpus/terraform_outputs/avec-bastion.json @@ -0,0 +1 @@ +{"hosts": {"value": {"un.lab": "10.99.0.11"}}, "bastion": {"value": {"public_ip": "203.0.113.7", "fqdn": "bastion.exemple", "user": "outscale"}}} diff --git a/fuzz/corpus/terraform_outputs/bastion-sans-ip.json b/fuzz/corpus/terraform_outputs/bastion-sans-ip.json new file mode 100644 index 0000000..2796fbe --- /dev/null +++ b/fuzz/corpus/terraform_outputs/bastion-sans-ip.json @@ -0,0 +1 @@ +{"bastion": {"value": {"fqdn": "b.exemple"}}} diff --git a/fuzz/corpus/terraform_outputs/hosts-liste.json b/fuzz/corpus/terraform_outputs/hosts-liste.json new file mode 100644 index 0000000..a1f0540 --- /dev/null +++ b/fuzz/corpus/terraform_outputs/hosts-liste.json @@ -0,0 +1 @@ +{"hosts": {"value": ["10.99.0.11"]}} diff --git a/fuzz/corpus/terraform_outputs/ip-numerique.json b/fuzz/corpus/terraform_outputs/ip-numerique.json new file mode 100644 index 0000000..62a6185 --- /dev/null +++ b/fuzz/corpus/terraform_outputs/ip-numerique.json @@ -0,0 +1 @@ +{"hosts": {"value": {"un.lab": 167837707}}} diff --git a/fuzz/corpus/terraform_outputs/outputs-aplatis.json b/fuzz/corpus/terraform_outputs/outputs-aplatis.json new file mode 100644 index 0000000..567a7ed --- /dev/null +++ b/fuzz/corpus/terraform_outputs/outputs-aplatis.json @@ -0,0 +1 @@ +{"hosts": {"un.lab": "10.99.0.11"}} diff --git a/fuzz/corpus/terraform_outputs/outputs-encapsules.json b/fuzz/corpus/terraform_outputs/outputs-encapsules.json new file mode 100644 index 0000000..b408215 --- /dev/null +++ b/fuzz/corpus/terraform_outputs/outputs-encapsules.json @@ -0,0 +1 @@ +{"hosts": {"value": {"un.lab": "10.99.0.11", "deux.lab": "10.99.0.12"}, "type": ["object", {"un.lab": "string", "deux.lab": "string"}]}} diff --git a/fuzz/corpus/terraform_outputs/sans-hosts.json b/fuzz/corpus/terraform_outputs/sans-hosts.json new file mode 100644 index 0000000..0b175b2 --- /dev/null +++ b/fuzz/corpus/terraform_outputs/sans-hosts.json @@ -0,0 +1 @@ +{"autre_chose": {"value": 42}} diff --git a/fuzz/corpus/terraform_outputs/value-non-objet.json b/fuzz/corpus/terraform_outputs/value-non-objet.json new file mode 100644 index 0000000..281b538 --- /dev/null +++ b/fuzz/corpus/terraform_outputs/value-non-objet.json @@ -0,0 +1 @@ +{"hosts": {"value": "10.99.0.11"}} diff --git a/fuzz/corpus/terraform_outputs/vide.json b/fuzz/corpus/terraform_outputs/vide.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/fuzz/corpus/terraform_outputs/vide.json @@ -0,0 +1 @@ +{} diff --git a/fuzz/fuzz_context_json.py b/fuzz/fuzz_context_json.py new file mode 100755 index 0000000..836322e --- /dev/null +++ b/fuzz/fuzz_context_json.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Fuzz harness for the local session context. + +`.dsoxlab-context.json` is untrusted for a different reason than `lab.yaml` and +`meta.yml`: it does not come from a lab-provider repository, it comes from the +learner's own disk. It is written by `dsoxlab use`, edited by hand when someone +is curious, truncated when a laptop is closed mid-write, and left behind by an +older version of the tool. + +**This harness has no contract exception, and that is the point.** +`read_context` promises to return an empty context whenever the file is absent, +unreadable or malformed: losing the context costs the learner one `dsoxlab use`, +where an exception costs the whole CLI, without even naming the file to delete. +So any exception at all is a failure here, unlike the YAML harnesses where +`ValueError` is the documented rejection signal. + +Run it — scratch dir FIRST, seed corpus second (see fuzz_lab_yaml.py): + mkdir -p /tmp/fuzz-context + uv run --group fuzz python fuzz/fuzz_context_json.py \ + /tmp/fuzz-context fuzz/corpus/context_json/ -atheris_runs=50000 + +See fuzz/fuzz_lab_yaml.py for why instrumentation is scoped to dsoxlab only and +why the seed corpus is what makes this harness effective. +""" + +from __future__ import annotations + +import sys +import tempfile +from pathlib import Path + +import atheris + +with atheris.instrument_imports(include=["dsoxlab"]): + from dsoxlab.config import read_context + +_TMPDIR = Path(tempfile.mkdtemp(prefix="dsoxlab-fuzz-context-")) + + +def test_one_input(data: bytes) -> None: + """Feed one fuzzer-generated document to read_context.""" + # Bytes are written raw, without decoding first: a context file of arbitrary + # bytes is exactly the case that used to slip through, because + # UnicodeDecodeError descends from ValueError and not from OSError. + try: + (_TMPDIR / ".dsoxlab-context.json").write_bytes(data) + except OSError: + return + + # No `except` for a contract: read_context must not raise, ever. + read_context(_TMPDIR) + + +def main() -> None: + atheris.Setup(sys.argv, test_one_input) + atheris.Fuzz() + + +if __name__ == "__main__": + main() diff --git a/fuzz/fuzz_terraform_outputs.py b/fuzz/fuzz_terraform_outputs.py new file mode 100755 index 0000000..54f0bab --- /dev/null +++ b/fuzz/fuzz_terraform_outputs.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Fuzz harness for the Terraform outputs the inventory is built from. + +Terraform outputs are untrusted for a third reason again: they are produced by +an **external binary** whose version, providers and output schema all move +without dsoxlab knowing. `terraform output -json` wraps every value in +`{"name": {"value": …, "type": …}}`, but a state written by another version, a +provider that renamed an output, or a hand-edited state all reach the same +reader. + +**The target is the consumption, not the parsing.** `read_terraform_outputs` +does I/O and one `json.loads` whose failure is already caught; fuzzing it would +mostly measure `json.loads`. What reaches the user as a traceback is what +`build_inventory` does with the decoded document, so that is what is fuzzed +here, with a `meta.yml` describing two hosts and a target, i.e. the shape a +`runtime: vm` lab actually goes through. + +Contract: `build_inventory` may reject a document it cannot honour, but only by +the exceptions listed below. Anything else is a crash for the learner, at the +moment they run a lab, with no clue that the cause is a stale Terraform state. + +Run it — scratch dir FIRST, seed corpus second (see fuzz_lab_yaml.py): + mkdir -p /tmp/fuzz-tfout + uv run --group fuzz python fuzz/fuzz_terraform_outputs.py \ + /tmp/fuzz-tfout fuzz/corpus/terraform_outputs/ -atheris_runs=50000 + +See fuzz/fuzz_lab_yaml.py for why instrumentation is scoped to dsoxlab only and +why the seed corpus is what makes this harness effective. +""" + +from __future__ import annotations + +import json +import sys +import tempfile +from pathlib import Path + +import atheris + +with atheris.instrument_imports(include=["dsoxlab"]): + from dsoxlab.infra.inventory import InfraNotProvisioned, build_inventory + from dsoxlab.models.repo import RepoMetadata + +#: Rejeter un document qu'on ne peut pas honorer est légitime ; le faire par une +#: exception que personne n'attend ne l'est pas. +#: +#: `InfraNotProvisioned` en fait partie, et le harnais l'a appris à sa première +#: exécution, sur la graine `{}` : un document sans adresse est le cas NORMAL du +#: premier lancement ou de l'après-`destroy`, et la CLI le rend en une phrase. +#: Une exception dédiée pour un état attendu, c'est exactement le patron que ce +#: dépôt applique, et un harnais qui l'aurait comptée comme un crash aurait +#: réclamé de le défaire. +CONTRACT_EXCEPTIONS = (KeyError, ValueError, InfraNotProvisioned) + +_TMPDIR = Path(tempfile.mkdtemp(prefix="dsoxlab-fuzz-tfout-")) +_META_YML = _TMPDIR / "meta.yml" +_META_YML.write_text( + """\ +repo: + id: fuzz + category: demo +infra: + provider: kvm + network: fuzz-net + cidr: 10.99.0.0/24 + hosts: + - name: un.lab + distro: alma10 + - name: deux.lab + distro: debian13 +""", + encoding="utf-8", +) +_REPO_META = RepoMetadata.from_yaml(_META_YML) + + +def test_one_input(data: bytes) -> None: + """Feed one fuzzer-generated outputs document to build_inventory.""" + # Raw decode, not FuzzedDataProvider — see fuzz_lab_yaml.py: the provider + # would reinterpret the seed files and make the corpus inert. + try: + document = data.decode("utf-8") + except UnicodeDecodeError: + return + + try: + outputs = json.loads(document) + except (json.JSONDecodeError, RecursionError): + return + if not isinstance(outputs, dict): + # `read_terraform_outputs` annotates its result as a mapping, and every + # caller obtains it from there. A non-mapping is out of scope for this + # harness, not a defect it should report. + return + + try: + build_inventory( + _REPO_META, + terraform_outputs=outputs, + target_fqdn="un.lab", + roles={"server": "deux.lab"}, + ) + except CONTRACT_EXCEPTIONS: + return + except RecursionError: + raise + + +def main() -> None: + atheris.Setup(sys.argv, test_one_input) + atheris.Fuzz() + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 11c481c..5369603 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "dsoxlab" -version = "0.1.63" +version = "0.1.64" description = "Turn declarative exercises into reproducible, runnable and verifiable lab environments" readme = "README.md" requires-python = ">=3.11" diff --git a/src/dsoxlab/infra/inventory.py b/src/dsoxlab/infra/inventory.py index 6d2ac8d..f80b216 100644 --- a/src/dsoxlab/infra/inventory.py +++ b/src/dsoxlab/infra/inventory.py @@ -85,9 +85,16 @@ def build_inventory( # ou directement {"fqdn1": "ip1"} selon que c'est `terraform output -json`. raw = terraform_outputs.get("hosts") if isinstance(raw, dict): - tf_hosts = { - k: str(v) for k, v in (raw.get("value", raw)).items() - } + # `raw.get("value", raw)` peut rendre autre chose qu'un mapping : + # un output d'une autre version de Terraform, un provider qui a + # renommé sa sortie, un state édité à la main. `.items()` levait + # alors un AttributeError, c'est-à-dire un traceback au moment de + # jouer un lab, sans dire que la cause est un state périmé. + # Trouvé par fuzz/fuzz_terraform_outputs.py sur + # `{"hosts": {"value": "10.99.0.11"}}`. + valeurs = raw.get("value", raw) + if isinstance(valeurs, dict): + tf_hosts = {k: str(v) for k, v in valeurs.items()} # Bastion : extrait via bastion_info() pour bénéficier de la # priorité meta.yml > output Terraform sur le user (cf. diff --git a/tests/test_outputs_terraform.py b/tests/test_outputs_terraform.py new file mode 100644 index 0000000..5c128e2 --- /dev/null +++ b/tests/test_outputs_terraform.py @@ -0,0 +1,97 @@ +"""Les outputs de Terraform sont une entrée que dsoxlab ne produit pas (#71). + +Ils viennent d'un binaire externe dont la version, les providers et le schéma de +sortie bougent sans que l'outil le sache. `terraform output -json` encapsule +chaque valeur dans ``{"nom": {"value": …, "type": …}}``, mais un state écrit par +une autre version, un provider qui a renommé sa sortie, ou un state édité à la +main arrivent chez le même lecteur. + +Le défaut ci-dessous a été trouvé par `fuzz/fuzz_terraform_outputs.py`, sur un +document de trente-quatre octets. Ce test le fige : le fuzzing découvre, un test +empêche le retour. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from dsoxlab.infra.inventory import InfraNotProvisioned, build_inventory +from dsoxlab.models.repo import RepoMetadata + +META = """\ +repo: + id: essai + category: demo +infra: + provider: kvm + network: essai-net + cidr: 10.99.0.0/24 + hosts: + - name: un.lab + distro: alma10 +""" + + +@pytest.fixture +def repo_meta(tmp_path: Path) -> RepoMetadata: + chemin = tmp_path / "meta.yml" + chemin.write_text(META, encoding="utf-8") + return RepoMetadata.from_yaml(chemin) + + +def test_un_output_hosts_qui_n_est_pas_un_objet_ne_leve_pas(repo_meta: RepoMetadata) -> None: + """`{"hosts": {"value": "10.99.0.11"}}` faisait `AttributeError`. + + Le code prenait `raw.get("value", raw)` pour un mapping et appelait + `.items()` dessus. Le résultat était un traceback au moment de jouer un lab, + sans jamais dire que la cause était un state Terraform périmé. + + Ce qui est attendu à la place n'est pas « ça marche » : c'est la phrase que + la CLI sait déjà rendre quand aucun hôte n'a d'adresse. + """ + with pytest.raises(InfraNotProvisioned): + build_inventory(repo_meta, terraform_outputs={"hosts": {"value": "10.99.0.11"}}) + + +@pytest.mark.parametrize( + ("nom", "outputs"), + [ + ("value en liste", {"hosts": {"value": ["10.99.0.11"]}}), + ("value nulle", {"hosts": {"value": None}}), + ("value numérique", {"hosts": {"value": 167837707}}), + ("hosts en liste", {"hosts": ["10.99.0.11"]}), + ("hosts en chaîne", {"hosts": "10.99.0.11"}), + ("sortie d'une autre version", {"autre_chose": {"value": 42}}), + ], + ids=lambda v: v if isinstance(v, str) else "", +) +def test_aucune_forme_d_output_ne_rend_un_traceback( + repo_meta: RepoMetadata, nom: str, outputs: dict[str, object] +) -> None: + """Toutes ces formes sont plausibles, aucune ne doit surprendre l'appelant. + + Le contrat n'est pas que dsoxlab les honore : c'est qu'il les refuse par une + exception que la CLI sait rendre en une phrase. + """ + with pytest.raises(InfraNotProvisioned): + build_inventory(repo_meta, terraform_outputs=outputs) + + +def test_la_forme_normale_reste_lue(repo_meta: RepoMetadata) -> None: + """Le contre-test : sans lui, refuser TOUT passerait pour une correction.""" + inventaire = build_inventory( + repo_meta, terraform_outputs={"hosts": {"value": {"un.lab": "10.99.0.11"}}} + ) + + hotes = inventaire["all"]["children"]["labenv"]["hosts"] + assert hotes["un.lab"]["ansible_host"] == "10.99.0.11" + + +def test_la_forme_aplatie_reste_lue(repo_meta: RepoMetadata) -> None: + """L'autre forme acceptée, selon d'où viennent les outputs.""" + inventaire = build_inventory(repo_meta, terraform_outputs={"hosts": {"un.lab": "10.99.0.11"}}) + + hotes = inventaire["all"]["children"]["labenv"]["hosts"] + assert hotes["un.lab"]["ansible_host"] == "10.99.0.11" diff --git a/uv.lock b/uv.lock index 702bbfb..c1f1a3d 100644 --- a/uv.lock +++ b/uv.lock @@ -313,7 +313,7 @@ wheels = [ [[package]] name = "dsoxlab" -version = "0.1.63" +version = "0.1.64" source = { editable = "." } dependencies = [ { name = "ansible-core", version = "2.19.12", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },