Files
FabledSteward/tests/core/test_inventory_gen.py
T
bvandeusen a92d1995d5
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 2m20s
CI / publish (push) Successful in 1m6s
fix(ansible): write DB inventory as static YAML, not dynamic --list JSON
generate_inventory() emits Ansible's dynamic --list shape (all.hosts is a
LIST, vars under _meta) — valid only as an executable inventory script's
stdout. We were writing it to a static file and passing -i, so Ansible's yaml
plugin rejected it ("Invalid 'hosts' entry for 'all' group, requires a
dictionary, found ...list...") and fell back to implicit localhost → "no hosts
matched". Affected every steward:* scope run; surfaced on the first real
provision.

- New inventory_to_yaml(inv): convert the --list dict → a valid static YAML
  inventory (all.hosts dict keyed by host, groups under all.children, group
  vars preserved, injected per-host vars like steward_token retained).
- Wire it into runner.trigger_run, host_agent deploy + provision.
- executor writes the file as inventory.yml so the yaml plugin's extension
  check reliably claims it.
- generate_inventory unchanged (still the --list dict); conversion happens at
  write time. Unit tests added.

Scribe issue #885.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 09:28:31 -04:00

131 lines
5.1 KiB
Python

"""Unit tests for generate_inventory().
Uses SimpleNamespace duck-typed objects so no DB or SQLAlchemy is required.
"""
from types import SimpleNamespace
import yaml
from steward.ansible.inventory_gen import generate_inventory, inventory_to_yaml
def _group(name, ansible_vars=None):
g = SimpleNamespace()
g.name = name
g.ansible_vars = ansible_vars or {}
return g
def _target(name, address, ansible_vars=None, groups=None):
t = SimpleNamespace()
t.name = name
t.address = address
t.ansible_vars = ansible_vars or {}
t.groups = groups or []
return t
def test_empty_targets():
result = generate_inventory([])
assert result == {"all": {"hosts": []}, "_meta": {"hostvars": {}}}
def test_single_target_no_groups():
target = _target("webserver", "192.168.1.10", {"ansible_user": "ubuntu"})
result = generate_inventory([target])
assert result["all"]["hosts"] == ["webserver"]
assert result["_meta"]["hostvars"]["webserver"]["ansible_host"] == "192.168.1.10"
assert result["_meta"]["hostvars"]["webserver"]["ansible_user"] == "ubuntu"
assert set(result.keys()) == {"all", "_meta"}
def test_ansible_host_overrides_group_var():
"""ansible_host is always target.address, even if a group sets it to something else."""
grp = _group("servers", {"ansible_host": "10.0.0.1"})
target = _target("host1", "192.168.1.50", groups=[grp])
result = generate_inventory([target])
assert result["_meta"]["hostvars"]["host1"]["ansible_host"] == "192.168.1.50"
def test_group_vars_applied():
grp = _group("webservers", {"http_port": 80, "ansible_user": "deploy"})
target = _target("web1", "192.168.1.10", groups=[grp])
result = generate_inventory([target])
hostvars = result["_meta"]["hostvars"]["web1"]
assert hostvars["http_port"] == 80
assert hostvars["ansible_user"] == "deploy"
assert result["webservers"]["hosts"] == ["web1"]
assert result["webservers"]["vars"]["http_port"] == 80
def test_host_vars_override_group_vars():
"""Per-target ansible_vars beat group vars for the same key."""
grp = _group("g1", {"ansible_user": "group_user"})
target = _target("t1", "1.2.3.4", {"ansible_user": "host_user"}, groups=[grp])
result = generate_inventory([target])
assert result["_meta"]["hostvars"]["t1"]["ansible_user"] == "host_user"
def test_group_var_precedence_is_alphabetical():
"""When multiple groups set the same key, alphabetically-last group wins (Ansible default)."""
g_a = _group("aaa", {"timeout": 10})
g_b = _group("bbb", {"timeout": 20})
target = _target("t1", "1.1.1.1", groups=[g_b, g_a]) # deliberately reversed order
result = generate_inventory([target])
# Groups sorted alphabetically: aaa then bbb → bbb wins
assert result["_meta"]["hostvars"]["t1"]["timeout"] == 20
def test_multiple_targets_in_group():
grp = _group("db", {"db_port": 5432})
t1 = _target("db1", "10.0.0.1", groups=[grp])
t2 = _target("db2", "10.0.0.2", groups=[grp])
result = generate_inventory([t1, t2])
assert set(result["db"]["hosts"]) == {"db1", "db2"}
assert result["all"]["hosts"] == ["db1", "db2"]
def test_target_in_multiple_groups():
g1 = _group("web", {"role": "web"})
g2 = _group("prod", {"env": "production"})
target = _target("web-prod-01", "192.168.1.1", groups=[g1, g2])
result = generate_inventory([target])
assert "web-prod-01" in result["web"]["hosts"]
assert "web-prod-01" in result["prod"]["hosts"]
assert result["_meta"]["hostvars"]["web-prod-01"]["role"] == "web"
assert result["_meta"]["hostvars"]["web-prod-01"]["env"] == "production"
# ── inventory_to_yaml: STATIC inventory (hosts is a dict, not a list) ──────────
def test_yaml_hosts_is_dict_with_hostvars():
target = _target("web1", "192.168.1.10", {"ansible_user": "ubuntu"})
parsed = yaml.safe_load(inventory_to_yaml(generate_inventory([target])))
# The bug was a LIST here; a static YAML inventory requires a dict keyed by host.
assert isinstance(parsed["all"]["hosts"], dict)
assert parsed["all"]["hosts"]["web1"]["ansible_host"] == "192.168.1.10"
assert parsed["all"]["hosts"]["web1"]["ansible_user"] == "ubuntu"
def test_yaml_groups_under_children():
grp = _group("db", {"db_port": 5432})
t1 = _target("db1", "10.0.0.1", groups=[grp])
t2 = _target("db2", "10.0.0.2", groups=[grp])
parsed = yaml.safe_load(inventory_to_yaml(generate_inventory([t1, t2])))
assert set(parsed["all"]["hosts"]) == {"db1", "db2"}
assert set(parsed["all"]["children"]["db"]["hosts"]) == {"db1", "db2"}
assert parsed["all"]["children"]["db"]["vars"]["db_port"] == 5432
def test_yaml_injected_hostvars_survive():
"""Extra per-host vars injected after generate_inventory (e.g. steward_token)."""
inv = generate_inventory([_target("h1", "1.2.3.4")])
inv["_meta"]["hostvars"]["h1"]["steward_token"] = "tok with space"
parsed = yaml.safe_load(inventory_to_yaml(inv))
assert parsed["all"]["hosts"]["h1"]["steward_token"] == "tok with space"
def test_yaml_empty_targets():
parsed = yaml.safe_load(inventory_to_yaml(generate_inventory([])))
assert parsed == {"all": {"hosts": {}}}