Files
computer-playbook/cli/administration/inventory/provision/ruamel_io.py
kevinveenbirkenbach f4398e1c58 feat(inventory): pin a password on every user the roles declare
A user definition that declares no password falls back to
{{ 42 | strong_password }}. That is a template, not a value: it is re-rendered
at every use site, so the task that creates an account and the task that later
authenticates as it read two different secrets. The account exists and the
login fails. Inventory creation now decides the value once, after the roles are
resolved and next to the role credentials that were already generated there.
The scope follows the deployment: only roles resolved into this inventory
contribute users, an existing password is never overwritten, and a second run
writes nothing.

Building it duplicated what the package already carried three to seven times
over: an ensure-map helper defined three times, the same fifteen-line
round-trip load repeated seven times, and the vault encrypt-and-reparse dance
twice. They now live in one module, the round-trip counterpart to the existing
read-only yaml_io. host_vars drops from 213 to 130 lines, and its two override
entry points share the merge they used to copy. Behaviour is unchanged, and
that is measured rather than asserted: the committed versions and the new ones
were driven against the same host_vars and produced byte-identical output for
all six writers, comment placement and vault block layout included.

The adapter probes move into their own folder because a shared file-name prefix
reads as a folder in disguise, and the lint that enforces it scans the tracked
file list, so the repository was failing on it. A test that was defined at
module level instead of inside its class, and therefore never ran, is indented
back in.
2026-08-06 19:54:19 +02:00

96 lines
2.9 KiB
Python

"""Round-trip YAML access for the inventory files this package rewrites.
The counterpart to :mod:`yaml_io`, which reads values. Everything here keeps
comments, quoting and vault tags intact, because host_vars is hand-edited as
often as it is generated.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from ruamel.yaml import YAML
from ruamel.yaml.comments import CommentedMap
from utils.handler.vault import VaultHandler
if TYPE_CHECKING:
from pathlib import Path
def round_trip_yaml() -> YAML:
"""Return a YAML handler configured the way every writer here needs it."""
handler = YAML(typ="rt")
handler.preserve_quotes = True
return handler
def as_commented_map(data: Any) -> CommentedMap:
"""Return ``data`` as a CommentedMap, empty when it holds no mapping.
Args:
data: whatever a round-trip load returned.
"""
if isinstance(data, CommentedMap):
return data
if data is None:
return CommentedMap()
migrated = CommentedMap()
for key, value in dict(data).items():
migrated[key] = value
return migrated
def load_document(path: Path) -> CommentedMap:
"""Return the round-trip document at ``path``, empty when it does not exist.
Args:
path: the YAML file to read.
"""
if not path.exists():
return CommentedMap()
with path.open("r", encoding="utf-8") as handle:
return as_commented_map(round_trip_yaml().load(handle))
def dump_document(path: Path, document: CommentedMap) -> None:
"""Write ``document`` to ``path``, creating the parent directory.
Args:
path: the YAML file to write.
document: the round-trip document to serialise.
"""
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as handle:
round_trip_yaml().dump(document, handle)
def ensure_map(node: CommentedMap, key: str) -> CommentedMap:
"""Return ``node[key]`` as a CommentedMap, creating it when absent.
Args:
node: the parent mapping.
key: the child key to resolve.
"""
if key not in node or not isinstance(node.get(key), CommentedMap):
node[key] = CommentedMap()
return node[key]
def vault_value(vault_password_file: Path | str, plain: str, name: str) -> Any:
"""Return ``plain`` as a parsed ``!vault`` node ready to assign.
Args:
vault_password_file: vault password used to encrypt.
plain: the secret to encrypt.
name: the key ansible-vault names the snippet after.
ansible-vault emits a YAML snippet rather than a value, so the encrypted
node has to be read back out of it before it can be assigned anywhere.
"""
snippet = VaultHandler(str(vault_password_file)).encrypt_string(plain, name)
encrypted = as_commented_map(round_trip_yaml().load(snippet)).get(name)
if encrypted is None:
raise SystemExit(f"Failed to parse {name!r} from ansible-vault output.")
return encrypted