# Ansible Inventory Infrastructure Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add AnsibleTarget + AnsibleGroup models with M2M membership, DB-generated `--list` inventory, per-source PAT auth via GIT_ASKPASS, and a full CRUD UI so Ansible runs are driven by Steward's own inventory records rather than git-repo inventory files. **Architecture:** Three new DB tables (`ansible_targets`, `ansible_groups`, `ansible_target_groups`) plus one new column (`ansible_runs.inventory_scope`) added via Alembic migrations. A pure `generate_inventory()` function in `inventory_gen.py` converts ORM objects to the Ansible `--list` JSON format. The existing `browse.html` run form gains a scope picker that replaces the repo-inventory select; `create_run()` fetches scope targets and passes the generated JSON as `inventory_content` to the existing executor. A new `inventory_routes.py` blueprint serves `/ansible/inventory/` CRUD pages for groups and targets. **Tech Stack:** Python 3.11+, SQLAlchemy (async/asyncpg), Alembic migrations, Quart, Jinja2 templates (no JS framework beyond existing HTMX), PyYAML (already in project via Ansible dependency). --- ## File Map | Path | Action | Responsibility | |------|--------|----------------| | `steward/migrations/versions/0015_ansible_inventory_targets.py` | Create | Alembic migration: `ansible_targets` table | | `steward/migrations/versions/0016_ansible_inventory_groups.py` | Create | Alembic migration: `ansible_groups` + `ansible_target_groups` tables | | `steward/migrations/versions/0017_ansible_run_scope.py` | Create | Alembic migration: `inventory_scope` column + make `inventory_path` nullable | | `steward/models/ansible_inventory.py` | Create | `AnsibleTarget`, `AnsibleGroup`, `ansible_target_groups` association table | | `steward/models/__init__.py` | Modify | Import/export new models so Alembic discovers them | | `steward/models/ansible.py` | Modify | Add `inventory_scope` field + make `inventory_path` optional on `AnsibleRun` | | `steward/ansible/inventory_gen.py` | Create | Pure `generate_inventory(targets)` + async `fetch_scope_targets(db, scope)` | | `steward/ansible/sources.py` | Modify | Add `http_token` to returned source dict + GIT_ASKPASS in `git_pull()` | | `steward/ansible/routes.py` | Modify | Update `create_run()` scope handling; update `index()` context | | `steward/ansible/inventory_routes.py` | Create | Blueprint: CRUD for `/ansible/inventory/groups` + `/ansible/inventory/targets` | | `steward/app.py` | Modify | Register `inventory_bp` | | `steward/settings/routes.py` | Modify | Include `http_token` in `ansible_save_source()` + `ansible_add_source()` | | `steward/templates/ansible/browse.html` | Modify | Replace inventory select with scope picker ` ``` Inside the `ansible-add-git-fields` div, after the `pull_interval_seconds` form-group, add: ```html
``` - [ ] **Step 5.8: Commit** ```bash git add steward/ansible/sources.py \ steward/settings/routes.py \ steward/templates/settings/_ansible_sources.html \ tests/core/test_git_askpass.py git commit -m "feat(ansible): GIT_ASKPASS per-source HTTP token auth + settings UI" ``` --- ## Task 6: Wire Inventory Generation into create_run() **Files:** - Modify: `steward/ansible/routes.py` - Modify: `steward/templates/ansible/browse.html` - Modify: `steward/templates/ansible/run_detail.html` - [ ] **Step 6.1: Update `index()` in `steward/ansible/routes.py`** The `index()` route needs to pass `inventory_scope` label context to the run history table (for display). No test needed — it's a display change. Change the `index()` route to also fetch target/group counts for displaying in the nav: ```python @ansible_bp.get("/") @require_role(UserRole.viewer) async def index(): from sqlalchemy import func from steward.models.ansible_inventory import AnsibleTarget, AnsibleGroup async with current_app.db_sessionmaker() as db: result = await db.execute( select(AnsibleRun).order_by(AnsibleRun.started_at.desc()).limit(50) ) runs = result.scalars().all() target_count = (await db.execute(select(func.count()).select_from(AnsibleTarget))).scalar() return await render_template("ansible/index.html", runs=runs, target_count=target_count) ``` - [ ] **Step 6.2: Update `create_run()` to use inventory_scope** Replace the existing `create_run()` implementation. The key changes are: - Accept `inventory_scope` from the form instead of `inventory_path` - Generate `inventory_content` from DB for `steward:*` scopes - Store `inventory_scope` on the `AnsibleRun` row Full updated function (replaces the existing one at line ~108): ```python @ansible_bp.post("/runs") @require_role(UserRole.operator) async def create_run(): import json as _json from steward.ansible.inventory_gen import fetch_scope_targets, generate_inventory form = await request.form playbook_path = form.get("playbook_path", "").strip() source_name = form.get("source_name", "").strip() inventory_scope = form.get("inventory_scope", "steward:all").strip() if not playbook_path: return "playbook_path is required", 400 all_sources = _get_sources() source = next((s for s in all_sources if s["name"] == source_name), None) if source is None: return "Source not found", 404 # Resolve inventory content for DB-backed scopes. inventory_content: str | None = None inventory_path: str | None = None if inventory_scope.startswith("steward:"): async with current_app.db_sessionmaker() as db: targets = await fetch_scope_targets(db, inventory_scope) inventory_content = _json.dumps(generate_inventory(targets)) elif inventory_scope.startswith("repo:"): # repo:: parts = inventory_scope.split(":", 2) inventory_path = parts[2] if len(parts) == 3 else "" else: return "Invalid inventory_scope", 400 # Optional run parameters. extra_vars: list[str] = [] for line in (form.get("extra_vars", "") or "").splitlines(): line = line.strip() if not line: continue if "=" not in line: return f"Invalid extra var (expected key=value): {line!r}", 400 extra_vars.append(line) limit = (form.get("limit", "") or "").strip() tags = (form.get("tags", "") or "").strip() check = "check" in form params: dict = {} if extra_vars: params["extra_vars"] = extra_vars if limit: params["limit"] = limit if tags: params["tags"] = tags if check: params["check"] = True params_or_none = params or None run_id = str(uuid.uuid4()) now = datetime.now(timezone.utc) run = AnsibleRun( id=run_id, playbook_path=playbook_path, inventory_path=inventory_path, inventory_scope=inventory_scope, source_name=source_name, triggered_by=session["user_id"], status=AnsibleRunStatus.running, started_at=now, params=params_or_none, ) async with current_app.db_sessionmaker() as db: async with db.begin(): db.add(run) asyncio.ensure_future( executor.start_run( current_app._get_current_object(), # type: ignore[attr-defined] run_id, playbook_path=playbook_path, inventory_path=inventory_path or "", source_path=source["path"], params=params_or_none, inventory_content=inventory_content, ) ) return await render_template("ansible/run_started.html", run_id=run_id) ``` - [ ] **Step 6.3: Update `browse()` route to pass scope context** The `browse()` route needs to pass targets and groups for the scope picker. Update it: ```python @ansible_bp.get("/browse") @require_role(UserRole.viewer) async def browse(): from steward.models.ansible_inventory import AnsibleTarget, AnsibleGroup all_sources = _get_sources() source_data = [] for source in all_sources: playbooks = src_module.discover_playbooks(source["path"]) inventories = src_module.discover_inventories(source["path"]) source_data.append({ "source": source, "playbooks": playbooks, "inventories": inventories, }) async with current_app.db_sessionmaker() as db: targets = (await db.execute( select(AnsibleTarget).order_by(AnsibleTarget.name) )).scalars().all() groups = (await db.execute( select(AnsibleGroup).order_by(AnsibleGroup.name) )).scalars().all() return await render_template( "ansible/browse.html", source_data=source_data, targets=targets, groups=groups, ) ``` - [ ] **Step 6.4: Update `steward/templates/ansible/browse.html` run form** Replace the existing "Inventory" form-group block (lines 73–85) with the scope picker. The existing block is: ```html
``` Replace with: ```html
``` Also update the "Execute" button's onclick — remove the `manual-inv` logic since there's no manual field anymore: ```html ``` (The form can submit directly now.) - [ ] **Step 6.5: Update `run_detail.html` to show scope label** In `steward/templates/ansible/run_detail.html`, find where `run.inventory_path` is displayed and add `inventory_scope` display. After the playbook path line, add: ```html Scope {{ run.inventory_scope }} ``` (Check the existing table structure in `run_detail.html` first — look for `run.playbook_path` and follow the same `` pattern.) - [ ] **Step 6.6: Commit** ```bash git add steward/ansible/routes.py \ steward/templates/ansible/browse.html \ steward/templates/ansible/run_detail.html git commit -m "feat(ansible): scope picker in run form + create_run() DB inventory generation" ``` --- ## Task 7: Inventory Blueprint — Groups CRUD **Files:** - Create: `steward/ansible/inventory_routes.py` - Create: `steward/templates/ansible/inventory/groups.html` - Create: `steward/templates/ansible/inventory/group_detail.html` - Modify: `steward/app.py` - [ ] **Step 7.1: Create `steward/ansible/inventory_routes.py`** ```python """CRUD routes for Ansible inventory: groups and targets.""" from __future__ import annotations import uuid import yaml from quart import Blueprint, current_app, redirect, render_template, request, url_for from sqlalchemy import select from sqlalchemy.orm import selectinload from steward.auth.middleware import require_role from steward.models.ansible_inventory import AnsibleGroup, AnsibleTarget from steward.models.users import UserRole inventory_bp = Blueprint("ansible_inventory", __name__, url_prefix="/ansible/inventory") def _parse_ansible_vars(raw: str) -> dict: """Parse a YAML/JSON string into a dict. Raises ValueError on invalid input.""" raw = raw.strip() if not raw: return {} try: parsed = yaml.safe_load(raw) except yaml.YAMLError as exc: raise ValueError(f"Invalid YAML: {exc}") from exc if parsed is None: return {} if not isinstance(parsed, dict): raise ValueError("ansible_vars must be a YAML mapping (key: value pairs)") return parsed @inventory_bp.get("/groups") @require_role(UserRole.viewer) async def groups_list(): async with current_app.db_sessionmaker() as db: result = await db.execute( select(AnsibleGroup) .options(selectinload(AnsibleGroup.targets)) .order_by(AnsibleGroup.name) ) groups = result.scalars().all() return await render_template("ansible/inventory/groups.html", groups=groups) @inventory_bp.post("/groups") @require_role(UserRole.operator) async def groups_create(): form = await request.form name = form.get("name", "").strip() if not name: return "name is required", 400 try: ansible_vars = _parse_ansible_vars(form.get("ansible_vars", "")) except ValueError as exc: return str(exc), 400 group = AnsibleGroup(id=str(uuid.uuid4()), name=name, ansible_vars=ansible_vars) async with current_app.db_sessionmaker() as db: async with db.begin(): db.add(group) return redirect(url_for("ansible_inventory.groups_list")) @inventory_bp.get("/groups/") @require_role(UserRole.viewer) async def group_detail(group_id: str): async with current_app.db_sessionmaker() as db: result = await db.execute( select(AnsibleGroup) .where(AnsibleGroup.id == group_id) .options(selectinload(AnsibleGroup.targets)) ) group = result.scalar_one_or_none() if group is None: return "Group not found", 404 all_targets = ( await db.execute(select(AnsibleTarget).order_by(AnsibleTarget.name)) ).scalars().all() member_ids = {t.id for t in group.targets} ansible_vars_yaml = yaml.dump(group.ansible_vars) if group.ansible_vars else "" return await render_template( "ansible/inventory/group_detail.html", group=group, all_targets=all_targets, member_ids=member_ids, ansible_vars_yaml=ansible_vars_yaml, ) @inventory_bp.post("/groups/") @require_role(UserRole.operator) async def group_update(group_id: str): form = await request.form try: ansible_vars = _parse_ansible_vars(form.get("ansible_vars", "")) except ValueError as exc: return str(exc), 400 async with current_app.db_sessionmaker() as db: async with db.begin(): result = await db.execute( select(AnsibleGroup) .where(AnsibleGroup.id == group_id) .options(selectinload(AnsibleGroup.targets)) ) group = result.scalar_one_or_none() if group is None: return "Group not found", 404 name = form.get("name", group.name).strip() if name: group.name = name group.ansible_vars = ansible_vars # Update member list — form sends target_ids[] checkboxes. new_ids = set(form.getlist("target_ids")) all_targets = ( await db.execute(select(AnsibleTarget)) ).scalars().all() target_map = {t.id: t for t in all_targets} group.targets = [target_map[tid] for tid in new_ids if tid in target_map] return redirect(url_for("ansible_inventory.group_detail", group_id=group_id)) @inventory_bp.post("/groups//delete") @require_role(UserRole.operator) async def group_delete(group_id: str): async with current_app.db_sessionmaker() as db: async with db.begin(): result = await db.execute( select(AnsibleGroup).where(AnsibleGroup.id == group_id) ) group = result.scalar_one_or_none() if group is not None: await db.delete(group) return redirect(url_for("ansible_inventory.groups_list")) ``` - [ ] **Step 7.2: Create `steward/templates/ansible/inventory/groups.html`** ```html {% extends "base.html" %} {% block title %}Inventory Groups — Steward{% endblock %} {% block content %}

Inventory Groups

Targets

New Group

{% if not groups %}

No groups yet. Create one above.

{% else %}
{% for grp in groups %} {% endfor %}
NameMembers
{{ grp.name }} {{ grp.targets | length }} Edit
{% endif %} {% endblock %} ``` - [ ] **Step 7.3: Create `steward/templates/ansible/inventory/group_detail.html`** ```html {% extends "base.html" %} {% block title %}Group: {{ group.name }} — Steward{% endblock %} {% block content %}

Group: {{ group.name }}

← Groups

Settings

Members

{% if not all_targets %}

No targets exist yet. Add targets first.

{% else %}
{% for tgt in all_targets %} {% endfor %}
{% endif %}
{% endblock %} ``` - [ ] **Step 7.4: Register blueprint in `steward/app.py`** After the existing `from .ansible.routes import ansible_bp` and `app.register_blueprint(ansible_bp)` lines, add: ```python from .ansible.inventory_routes import inventory_bp app.register_blueprint(inventory_bp) ``` (These are inside the `create_app()` function, alongside the other blueprint registrations.) - [ ] **Step 7.5: Run the app and verify `/ansible/inventory/groups` loads** ```bash cd /home/bvandeusen/Nextcloud/Projects/FabledSteward docker compose up -d # or start local dev server # Navigate to http://localhost:5000/ansible/inventory/groups in browser ``` Expected: Groups list page renders with "No groups yet" message. No 500 errors. - [ ] **Step 7.6: Commit** ```bash git add steward/ansible/inventory_routes.py \ steward/app.py \ steward/templates/ansible/inventory/groups.html \ steward/templates/ansible/inventory/group_detail.html git commit -m "feat(ansible): inventory groups CRUD routes + templates" ``` --- ## Task 8: Inventory Blueprint — Targets CRUD **Files:** - Modify: `steward/ansible/inventory_routes.py` - Create: `steward/templates/ansible/inventory/targets.html` - Create: `steward/templates/ansible/inventory/target_detail.html` - [ ] **Step 8.1: Add targets routes to `steward/ansible/inventory_routes.py`** Append to the existing `inventory_routes.py` file (after the group routes): ```python @inventory_bp.get("/targets") @require_role(UserRole.viewer) async def targets_list(): async with current_app.db_sessionmaker() as db: result = await db.execute( select(AnsibleTarget) .options(selectinload(AnsibleTarget.groups)) .order_by(AnsibleTarget.name) ) targets = result.scalars().all() return await render_template("ansible/inventory/targets.html", targets=targets) @inventory_bp.post("/targets") @require_role(UserRole.operator) async def targets_create(): form = await request.form name = form.get("name", "").strip() address = form.get("address", "").strip() if not name or not address: return "name and address are required", 400 try: ansible_vars = _parse_ansible_vars(form.get("ansible_vars", "")) except ValueError as exc: return str(exc), 400 target = AnsibleTarget( id=str(uuid.uuid4()), name=name, address=address, ansible_vars=ansible_vars ) async with current_app.db_sessionmaker() as db: async with db.begin(): db.add(target) return redirect(url_for("ansible_inventory.targets_list")) @inventory_bp.get("/targets/") @require_role(UserRole.viewer) async def target_detail(target_id: str): async with current_app.db_sessionmaker() as db: result = await db.execute( select(AnsibleTarget) .where(AnsibleTarget.id == target_id) .options(selectinload(AnsibleTarget.groups)) ) target = result.scalar_one_or_none() if target is None: return "Target not found", 404 all_groups = ( await db.execute(select(AnsibleGroup).order_by(AnsibleGroup.name)) ).scalars().all() member_group_ids = {g.id for g in target.groups} ansible_vars_yaml = yaml.dump(target.ansible_vars) if target.ansible_vars else "" return await render_template( "ansible/inventory/target_detail.html", target=target, all_groups=all_groups, member_group_ids=member_group_ids, ansible_vars_yaml=ansible_vars_yaml, ) @inventory_bp.post("/targets/") @require_role(UserRole.operator) async def target_update(target_id: str): form = await request.form try: ansible_vars = _parse_ansible_vars(form.get("ansible_vars", "")) except ValueError as exc: return str(exc), 400 async with current_app.db_sessionmaker() as db: async with db.begin(): result = await db.execute( select(AnsibleTarget) .where(AnsibleTarget.id == target_id) .options(selectinload(AnsibleTarget.groups)) ) target = result.scalar_one_or_none() if target is None: return "Target not found", 404 name = form.get("name", target.name).strip() address = form.get("address", target.address).strip() if name: target.name = name if address: target.address = address target.ansible_vars = ansible_vars new_group_ids = set(form.getlist("group_ids")) all_groups_result = await db.execute(select(AnsibleGroup)) group_map = {g.id: g for g in all_groups_result.scalars().all()} target.groups = [group_map[gid] for gid in new_group_ids if gid in group_map] return redirect(url_for("ansible_inventory.target_detail", target_id=target_id)) @inventory_bp.post("/targets//delete") @require_role(UserRole.operator) async def target_delete(target_id: str): async with current_app.db_sessionmaker() as db: async with db.begin(): result = await db.execute( select(AnsibleTarget).where(AnsibleTarget.id == target_id) ) target = result.scalar_one_or_none() if target is not None: await db.delete(target) return redirect(url_for("ansible_inventory.targets_list")) ``` - [ ] **Step 8.2: Create `steward/templates/ansible/inventory/targets.html`** ```html {% extends "base.html" %} {% block title %}Inventory Targets — Steward{% endblock %} {% block content %}

Inventory Targets

Groups

New Target

{% if not targets %}

No targets yet. Add one above.

{% else %}
{% for tgt in targets %} {% endfor %}
NameAddressGroups
{{ tgt.name }} {{ tgt.address }} {% for grp in tgt.groups %} {{ grp.name }} {% endfor %} Edit
{% endif %} {% endblock %} ``` - [ ] **Step 8.3: Create `steward/templates/ansible/inventory/target_detail.html`** ```html {% extends "base.html" %} {% block title %}Target: {{ target.name }} — Steward{% endblock %} {% block content %}

Target: {{ target.name }}

← Targets

Connection

Groups

{% if not all_groups %}

No groups exist yet. Create a group first.

{% else %}
{% for grp in all_groups %} {% endfor %}
{% endif %}
{% endblock %} ``` - [ ] **Step 8.4: Verify targets CRUD works** Navigate to `http://localhost:5000/ansible/inventory/targets` in browser. Create a target, edit it, assign it to a group, verify it appears in the groups list as a member. - [ ] **Step 8.5: Commit** ```bash git add steward/ansible/inventory_routes.py \ steward/templates/ansible/inventory/targets.html \ steward/templates/ansible/inventory/target_detail.html git commit -m "feat(ansible): inventory targets CRUD routes + templates" ``` --- ## Task 9: Host Edit Form — Ansible Target Link Section **Files:** - Modify: `steward/templates/hosts/form.html` - Modify: `steward/hosts/routes.py` - [ ] **Step 9.1: Update `edit_host()` route in `steward/hosts/routes.py`** The `edit_host()` function currently (around line ~149) renders `hosts/form.html` with just the host. Extend it to also pass the linked Ansible target and all available targets: ```python @hosts_bp.get("//edit") @require_role(UserRole.operator) async def edit_host(host_id: str): from steward.models.ansible_inventory import AnsibleTarget from sqlalchemy.orm import selectinload async with current_app.db_sessionmaker() as db: result = await db.execute( select(Host).where(Host.id == host_id) ) host = result.scalar_one_or_none() if host is None: return "Host not found", 404 linked_target = None linked_result = await db.execute( select(AnsibleTarget) .where(AnsibleTarget.host_id == host_id) .options(selectinload(AnsibleTarget.groups)) ) linked_target = linked_result.scalar_one_or_none() all_targets = ( await db.execute( select(AnsibleTarget) .where(AnsibleTarget.host_id.is_(None)) .order_by(AnsibleTarget.name) ) ).scalars().all() return await render_template( "hosts/form.html", host=host, probe_types=list(ProbeType), linked_target=linked_target, linkable_targets=all_targets, ) ``` Note: `all_targets` shows only unlinked targets (those without a `host_id`) plus the currently linked target if any — so the user can re-select it. The form template handles this. Also add a new route `POST //ansible-link` to handle the link action: ```python @hosts_bp.post("//ansible-link") @require_role(UserRole.operator) async def ansible_link(host_id: str): from steward.models.ansible_inventory import AnsibleTarget form = await request.form action = form.get("action", "") async with current_app.db_sessionmaker() as db: async with db.begin(): if action == "unlink": # Clear host_id from the currently linked target. linked_result = await db.execute( select(AnsibleTarget).where(AnsibleTarget.host_id == host_id) ) linked = linked_result.scalar_one_or_none() if linked: linked.host_id = None elif action == "link": target_id = form.get("target_id", "").strip() if target_id: result = await db.execute( select(AnsibleTarget).where(AnsibleTarget.id == target_id) ) target = result.scalar_one_or_none() if target: # Clear any previous link for this host. old_result = await db.execute( select(AnsibleTarget).where(AnsibleTarget.host_id == host_id) ) old = old_result.scalar_one_or_none() if old and old.id != target_id: old.host_id = None target.host_id = host_id elif action == "create": # Create a new target pre-filled from host data. host_result = await db.execute( select(Host).where(Host.id == host_id) ) host = host_result.scalar_one_or_none() if host: new_target = AnsibleTarget( id=str(uuid.uuid4()), name=host.name, address=host.address, host_id=host_id, ) db.add(new_target) return redirect(f"/hosts/{host_id}/edit") ``` Add `import uuid` to the top of `hosts/routes.py` if not already present. Also add `from sqlalchemy import select` and `from steward.models.hosts import Host, ProbeType` imports (check what's already imported). - [ ] **Step 9.2: Add Ansible section to `steward/templates/hosts/form.html`** At the end of the file, before `{% endblock %}`, add: ```html {% if host %}

Ansible Target

{% if linked_target %}
{{ linked_target.name }} {{ linked_target.address }} {% if linked_target.groups %}
{% for grp in linked_target.groups %} {{ grp.name }} {% endfor %}
{% endif %}
Edit Target
{% else %}

No Ansible target linked. Link an existing target or create a new one from this host's data.

{% if linkable_targets %}
{% endif %}
{% endif %}
{% endif %} ``` - [ ] **Step 9.3: Verify the Ansible section appears on host edit** Navigate to `http://localhost:5000/hosts//edit`. Verify the Ansible Target section appears at the bottom. Create a target from a host, verify it links and the target appears. Unlink and verify it clears. - [ ] **Step 9.4: Commit** ```bash git add steward/hosts/routes.py steward/templates/hosts/form.html git commit -m "feat(ansible): host edit page Ansible target link/create/unlink section" ``` --- ## Task 10: Integration Test — Migrations + fetch_scope_targets **Files:** - Create: `tests/integration/test_ansible_inventory.py` - [ ] **Step 10.1: Write integration tests** Create `tests/integration/test_ansible_inventory.py`: ```python """Integration tests: ansible_targets / ansible_groups tables + fetch_scope_targets. Requires a live Postgres DB. Run with: pytest tests/integration/test_ansible_inventory.py -m integration -v """ import pytest import pytest_asyncio from sqlalchemy import select, text @pytest.mark.integration @pytest.mark.asyncio async def test_ansible_targets_table_exists(app): """Migration 0015 created the ansible_targets table.""" async with app.db_sessionmaker() as db: result = await db.execute( text("SELECT column_name FROM information_schema.columns WHERE table_name = 'ansible_targets'") ) columns = {row[0] for row in result.fetchall()} assert "id" in columns assert "name" in columns assert "address" in columns assert "ansible_vars" in columns assert "host_id" in columns @pytest.mark.integration @pytest.mark.asyncio async def test_ansible_groups_table_exists(app): """Migration 0016 created ansible_groups and ansible_target_groups.""" async with app.db_sessionmaker() as db: result = await db.execute( text("SELECT column_name FROM information_schema.columns WHERE table_name = 'ansible_groups'") ) columns = {row[0] for row in result.fetchall()} assert "id" in columns assert "name" in columns assert "ansible_vars" in columns async with app.db_sessionmaker() as db: result = await db.execute( text("SELECT column_name FROM information_schema.columns WHERE table_name = 'ansible_target_groups'") ) join_columns = {row[0] for row in result.fetchall()} assert "target_id" in join_columns assert "group_id" in join_columns @pytest.mark.integration @pytest.mark.asyncio async def test_ansible_run_scope_column_exists(app): """Migration 0017 added inventory_scope to ansible_runs.""" async with app.db_sessionmaker() as db: result = await db.execute( text("SELECT column_name FROM information_schema.columns WHERE table_name = 'ansible_runs'") ) columns = {row[0] for row in result.fetchall()} assert "inventory_scope" in columns assert "inventory_path" in columns # still present, now nullable @pytest.mark.integration @pytest.mark.asyncio async def test_create_target_and_group_and_fetch(app): """Can create a target+group, assign membership, and fetch via fetch_scope_targets.""" import uuid from steward.models.ansible_inventory import AnsibleTarget, AnsibleGroup from steward.ansible.inventory_gen import fetch_scope_targets, generate_inventory target_id = str(uuid.uuid4()) group_id = str(uuid.uuid4()) async with app.db_sessionmaker() as db: async with db.begin(): grp = AnsibleGroup( id=group_id, name=f"test-group-{group_id[:8]}", ansible_vars={"env": "test"}, ) tgt = AnsibleTarget( id=target_id, name=f"test-target-{target_id[:8]}", address="10.0.99.1", ansible_vars={"ansible_user": "ubuntu"}, ) tgt.groups.append(grp) db.add(grp) db.add(tgt) # Fetch all async with app.db_sessionmaker() as db: all_targets = await fetch_scope_targets(db, "steward:all") names = [t.name for t in all_targets] assert f"test-target-{target_id[:8]}" in names # Fetch by group async with app.db_sessionmaker() as db: group_targets = await fetch_scope_targets(db, f"steward:group:{group_id}") assert any(t.id == target_id for t in group_targets) # Fetch single target async with app.db_sessionmaker() as db: single = await fetch_scope_targets(db, f"steward:target:{target_id}") assert len(single) == 1 assert single[0].id == target_id # Generate inventory async with app.db_sessionmaker() as db: targets = await fetch_scope_targets(db, f"steward:group:{group_id}") inv = generate_inventory(targets) target_name = f"test-target-{target_id[:8]}" group_name = f"test-group-{group_id[:8]}" assert target_name in inv["all"]["hosts"] assert target_name in inv[group_name]["hosts"] assert inv["_meta"]["hostvars"][target_name]["ansible_host"] == "10.0.99.1" assert inv["_meta"]["hostvars"][target_name]["ansible_user"] == "ubuntu" assert inv["_meta"]["hostvars"][target_name]["env"] == "test" # Cleanup async with app.db_sessionmaker() as db: async with db.begin(): result = await db.execute(select(AnsibleTarget).where(AnsibleTarget.id == target_id)) t = result.scalar_one_or_none() if t: await db.delete(t) result = await db.execute(select(AnsibleGroup).where(AnsibleGroup.id == group_id)) g = result.scalar_one_or_none() if g: await db.delete(g) ``` - [ ] **Step 10.2: Run integration tests** ```bash pytest tests/integration/test_ansible_inventory.py -m integration -v ``` Expected: 4 PASSED (requires running Postgres with migrations applied). - [ ] **Step 10.3: Commit** ```bash git add tests/integration/test_ansible_inventory.py git commit -m "test(ansible): integration tests for inventory tables + fetch_scope_targets" ``` --- ## Self-Review Checklist - [x] **Spec coverage**: All 8 spec sections are covered: - §1 Data Model → Tasks 1–3 - §2 PAT/HTTP Token → Task 5 - §3 Inventory Generation → Task 4 - §4 Run Form → Task 6 - §5 UI Surfaces → Tasks 7–9 - §6 Repo integration → Task 6 (scope fallback preserved) - §7 Migrations → Tasks 1–3 - §8 Out of scope → not implemented ✓ - [x] **No placeholders**: All code blocks are complete. - [x] **Type consistency**: `AnsibleTarget`, `AnsibleGroup`, `ansible_target_groups` defined in Task 1 and referenced consistently throughout. `fetch_scope_targets(db, scope)` / `generate_inventory(targets)` defined in Task 4 and called in Task 6. - [x] **Migration chain**: `0015 → 0016 → 0017`, each `down_revision` points to the previous. - [x] **Session pattern**: All routes use `async with current_app.db_sessionmaker() as db:` matching existing code. - [x] **Import pattern**: New models added to `steward/models/__init__.py` for Alembic discovery. - [x] **`get_sources()` http_token**: Added for both git and local sources (empty string for local).