fix(ansible): group names Ansible accepts, and interpreter discovery without the warning (#5334)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 57s
CI / unit (push) Successful in 1m24s
CI / publish (push) Successful in 1m26s

Every run warned twice because of Steward's own inventory:
- 'Invalid characters were found in group names': the group 'Docker
  Swarm' went into the inventory as typed. ansible_group_name() cleans it
  to docker_swarm (lower case, [a-z0-9_], never a leading digit); two
  names that clean alike get _2, _3 so neither folds into the other. The
  group page says what plays call it. The display name is unchanged.
- Python interpreter discovery: ansible_env sets
  ANSIBLE_PYTHON_INTERPRETER=auto_silent unless the environment already
  sets one; a target or group's ansible_python_interpreter still wins.

Runs are scoped by target set (steward:group:<id>), not by group name,
and --limit is free text, so neither path depends on the name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 00:01:54 -04:00
co-authored by Claude Opus 5.5
parent 7c346354f2
commit c678379646
8 changed files with 80 additions and 7 deletions
+5 -1
View File
@@ -316,9 +316,13 @@ def build_extra_vars_file(merged: dict | None, tmpdir: str) -> tuple[list[str],
def ansible_env(creds: dict | None, base_env) -> dict:
"""Return a copy of base_env with ANSIBLE_HOST_KEY_CHECKING set from creds."""
"""Return a copy of base_env with ANSIBLE_HOST_KEY_CHECKING set from creds,
and the interpreter found quietly (#5334): Ansible still discovers each
host's Python, but no longer warns about it on every run. A target or group
that sets ansible_python_interpreter still wins."""
env = dict(base_env)
env["ANSIBLE_HOST_KEY_CHECKING"] = "True" if (creds or {}).get("host_key_checking") else "False"
env.setdefault("ANSIBLE_PYTHON_INTERPRETER", "auto_silent")
return env
+35 -3
View File
@@ -1,6 +1,36 @@
"""Ansible --list inventory generation from DB records."""
from __future__ import annotations
import re
_UNSAFE = re.compile(r"[^a-z0-9_]+")
def ansible_group_name(name: str) -> str:
"""A Steward group's name as Ansible accepts it (#5334): lower case, each
run of anything but letters, digits and _ made one _, never starting with
a digit. "Docker Swarm" is docker_swarm, which a play can name in
`hosts:`; the display name stays as typed everywhere else."""
safe = _UNSAFE.sub("_", name.lower()).strip("_") or "group"
return f"g_{safe}" if safe[0].isdigit() else safe
def _group_names(targets: list) -> dict[str, str]:
"""{display name: Ansible name} for every group these targets are in. Two
names that clean to the same thing ("Web servers", "web-servers") get _2,
_3 in name order, so neither group's hosts or vars fold into the other's."""
out: dict[str, str] = {}
taken: set[str] = set()
for name in sorted({g.name for t in targets for g in t.groups}):
base = safe = ansible_group_name(name)
n = 1
while safe in taken or safe in ("all", "ungrouped", "_meta"):
n += 1
safe = f"{base}_{n}"
taken.add(safe)
out[name] = safe
return out
def generate_inventory(targets: list) -> dict:
"""Build an Ansible --list JSON inventory from a list of AnsibleTarget objects.
@@ -13,6 +43,7 @@ def generate_inventory(targets: list) -> dict:
group vars (sorted alphabetically by group name) → target ansible_vars → ansible_host
"""
inv: dict = {"all": {"hosts": []}, "_meta": {"hostvars": {}}}
names = _group_names(targets)
for target in targets:
inv["all"]["hosts"].append(target.name)
@@ -27,12 +58,13 @@ def generate_inventory(targets: list) -> dict:
inv["_meta"]["hostvars"][target.name] = merged
for group in target.groups:
if group.name not in inv:
inv[group.name] = {
key = names[group.name]
if key not in inv:
inv[key] = {
"hosts": [],
"vars": dict(group.ansible_vars or {}),
}
inv[group.name]["hosts"].append(target.name)
inv[key]["hosts"].append(target.name)
return inv
+2
View File
@@ -8,6 +8,7 @@ from sqlalchemy import select
from sqlalchemy.orm import selectinload
from steward.ansible import guards
from steward.ansible.inventory_gen import ansible_group_name
from steward.auth.middleware import require_role
from steward.core.audit import audit_request as _audit
from steward.models.ansible_inventory import AnsibleGroup, AnsibleTarget
@@ -149,6 +150,7 @@ async def group_detail(group_id: str):
return await render_template(
"ansible/inventory/group_detail.html",
group=group,
ansible_name=ansible_group_name(group.name),
used=used,
all_targets=all_targets,
member_ids=member_ids,
@@ -10,12 +10,14 @@
{% set can_edit = session.user_role in ('operator', 'admin') %}
{{ ansible_tabs("inventory") }}
{{ page_header(group.name) }}
{#- The name a play uses for it (#5334): Ansible takes only letters, digits and _. #}
<p class="page-verdict">
{%- set members = group.targets|sort(attribute='name') %}
{%- if members %}{{ members|length }} member{{ 's' if members|length != 1 }}:
{% for t in members %}<a href="/ansible/inventory/targets/{{ t.id }}">{{ t.name }}</a>{{ ", " if not loop.last }}{% endfor %}
{%- else %}<span class="tone-warn">no members</span>{% endif %}
· plays call it <code>{{ ansible_name }}</code>
{%- if used %} · scheduled by {% for s in used %}<a href="/ansible/schedules/{{ s.id }}">{{ s.name }}</a>{{ ", " if not loop.last }}{% endfor %}{% endif %}
</p>