diff --git a/docs/superpowers/plans/2026-06-05-ansible-inventory.md b/docs/superpowers/plans/2026-06-05-ansible-inventory.md new file mode 100644 index 0000000..e0aed90 --- /dev/null +++ b/docs/superpowers/plans/2026-06-05-ansible-inventory.md @@ -0,0 +1,2071 @@ +# 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).