Files
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

109 lines
4.0 KiB
Python

"""Ansible --list inventory generation from DB records."""
from __future__ import annotations
def generate_inventory(targets: list) -> dict:
"""Build an Ansible --list JSON inventory from a list of AnsibleTarget objects.
Accepts any objects with .name, .address, .ansible_vars (dict), .groups (list
of objects with .name and .ansible_vars). Uses duck typing so unit tests can
pass SimpleNamespace objects.
Var precedence (lowest → highest, matching Ansible default):
group vars (sorted alphabetically by group name) → target ansible_vars → ansible_host
"""
inv: dict = {"all": {"hosts": []}, "_meta": {"hostvars": {}}}
for target in targets:
inv["all"]["hosts"].append(target.name)
# Merge vars: group vars alphabetically, then host vars, then force ansible_host.
merged: dict = {}
for group in sorted(target.groups, key=lambda g: g.name):
merged.update(group.ansible_vars or {})
merged.update(target.ansible_vars or {})
merged["ansible_host"] = target.address
inv["_meta"]["hostvars"][target.name] = merged
for group in target.groups:
if group.name not in inv:
inv[group.name] = {
"hosts": [],
"vars": dict(group.ansible_vars or {}),
}
inv[group.name]["hosts"].append(target.name)
return inv
def inventory_to_yaml(inv: dict) -> str:
"""Convert the ``--list`` dict from generate_inventory into a STATIC YAML
inventory string.
The ``--list`` shape (``all.hosts`` is a *list*, vars under ``_meta``) is only
valid as the stdout of an executable dynamic-inventory script. When written to
a plain file and passed via ``-i``, Ansible's yaml plugin requires
``all.hosts`` to be a *dict* keyed by hostname — otherwise it fails to parse
and silently falls back to implicit localhost. This emits that valid form.
"""
import yaml
hostvars = (inv.get("_meta") or {}).get("hostvars") or {}
all_hosts = (inv.get("all") or {}).get("hosts") or []
root: dict = {"hosts": {h: (hostvars.get(h) or {}) for h in all_hosts}}
children: dict = {}
for key, val in inv.items():
if key in ("all", "_meta") or not isinstance(val, dict):
continue
grp: dict = {"hosts": {h: {} for h in (val.get("hosts") or [])}}
if val.get("vars"):
grp["vars"] = val["vars"]
children[key] = grp
if children:
root["children"] = children
return yaml.safe_dump({"all": root}, default_flow_style=False, sort_keys=True)
async def fetch_scope_targets(db, scope: str) -> list:
"""Query AnsibleTarget objects for a given inventory scope string.
Scopes:
steward:all — every target
steward:group:<uuid> — targets in that group
steward:target:<uuid> — single target by ID
Returns a list with groups pre-loaded (selectinload). Returns [] for
repo:* scopes (caller handles those via legacy path).
"""
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from steward.models.ansible_inventory import AnsibleTarget, AnsibleGroup
if scope == "steward:all":
stmt = (
select(AnsibleTarget)
.options(selectinload(AnsibleTarget.groups))
.order_by(AnsibleTarget.name)
)
elif scope.startswith("steward:group:"):
group_id = scope[len("steward:group:"):]
stmt = (
select(AnsibleTarget)
.join(AnsibleTarget.groups)
.where(AnsibleGroup.id == group_id)
.options(selectinload(AnsibleTarget.groups))
.order_by(AnsibleTarget.name)
)
elif scope.startswith("steward:target:"):
target_id = scope[len("steward:target:"):]
stmt = (
select(AnsibleTarget)
.where(AnsibleTarget.id == target_id)
.options(selectinload(AnsibleTarget.groups))
)
else:
return []
result = await db.execute(stmt)
return list(result.scalars().all())