Files
FabledSteward/docs/superpowers/plans/2026-06-05-ansible-inventory.md

2072 lines
73 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<select>` |
| `steward/templates/ansible/run_detail.html` | Modify | Show `inventory_scope` label |
| `steward/templates/ansible/inventory/groups.html` | Create | Groups list page |
| `steward/templates/ansible/inventory/group_detail.html` | Create | Group detail + member management |
| `steward/templates/ansible/inventory/targets.html` | Create | Targets list page |
| `steward/templates/ansible/inventory/target_detail.html` | Create | Target detail + group assignment |
| `steward/templates/hosts/form.html` | Modify | Ansible section (edit-only) to link/show AnsibleTarget |
| `steward/templates/settings/_ansible_sources.html` | Modify | `http_token` password field in edit + add forms |
| `tests/core/test_inventory_gen.py` | Create | Unit tests for `generate_inventory()` |
| `tests/integration/test_ansible_inventory.py` | Create | Integration test: migration tables + fetch_scope_targets |
---
## Task 1: AnsibleTarget Model + Migration
**Files:**
- Create: `steward/migrations/versions/0015_ansible_inventory_targets.py`
- Create: `steward/models/ansible_inventory.py`
- Modify: `steward/models/__init__.py`
- [ ] **Step 1.1: Write the migration**
Create `steward/migrations/versions/0015_ansible_inventory_targets.py`:
```python
"""Add ansible_targets table
Revision ID: 0015_ansible_inventory_targets
Revises: 0014_alert_ansible_action
Create Date: 2026-06-05
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0015_ansible_inventory_targets"
down_revision: Union[str, None] = "0014_alert_ansible_action"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"ansible_targets",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("name", sa.String(128), nullable=False),
sa.Column("address", sa.String(255), nullable=False),
sa.Column("ansible_vars", sa.JSON(), nullable=False, server_default="{}"),
sa.Column(
"host_id",
sa.String(36),
sa.ForeignKey("hosts.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.UniqueConstraint("name", name="uq_ansible_targets_name"),
)
def downgrade() -> None:
op.drop_table("ansible_targets")
```
- [ ] **Step 1.2: Create `steward/models/ansible_inventory.py`**
```python
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from sqlalchemy import Column, DateTime, ForeignKey, JSON, String, Table
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .base import Base
ansible_target_groups = Table(
"ansible_target_groups",
Base.metadata,
Column(
"target_id",
String(36),
ForeignKey("ansible_targets.id", ondelete="CASCADE"),
primary_key=True,
),
Column(
"group_id",
String(36),
ForeignKey("ansible_groups.id", ondelete="CASCADE"),
primary_key=True,
),
)
class AnsibleTarget(Base):
__tablename__ = "ansible_targets"
id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
)
name: Mapped[str] = mapped_column(String(128), nullable=False, unique=True)
address: Mapped[str] = mapped_column(String(255), nullable=False)
ansible_vars: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
host_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("hosts.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
)
groups: Mapped[list["AnsibleGroup"]] = relationship(
"AnsibleGroup",
secondary=ansible_target_groups,
back_populates="targets",
lazy="select",
)
class AnsibleGroup(Base):
__tablename__ = "ansible_groups"
id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
)
name: Mapped[str] = mapped_column(String(128), nullable=False, unique=True)
ansible_vars: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
)
targets: Mapped[list["AnsibleTarget"]] = relationship(
"AnsibleTarget",
secondary=ansible_target_groups,
back_populates="groups",
lazy="select",
)
```
- [ ] **Step 1.3: Register in `steward/models/__init__.py`**
Add after the `from .ansible import ...` line:
```python
from .ansible_inventory import AnsibleTarget, AnsibleGroup, ansible_target_groups
```
Add to `__all__`:
```python
"AnsibleTarget", "AnsibleGroup", "ansible_target_groups",
```
- [ ] **Step 1.4: Run the migration to verify it applies cleanly**
```bash
cd /home/bvandeusen/Nextcloud/Projects/FabledSteward
# Requires a running Postgres — if testing, use the integration test DB or a local instance
python -c "
import asyncio
from steward.app import create_app
app = asyncio.run(create_app.__wrapped__()) if hasattr(create_app, '__wrapped__') else None
print('model import OK')
"
```
Expected: `model import OK` with no ImportError.
- [ ] **Step 1.5: Commit**
```bash
git add steward/migrations/versions/0015_ansible_inventory_targets.py \
steward/models/ansible_inventory.py \
steward/models/__init__.py
git commit -m "feat(ansible): AnsibleTarget model + 0015_ansible_inventory_targets migration"
```
---
## Task 2: AnsibleGroup + Join Table Migration
**Files:**
- Create: `steward/migrations/versions/0016_ansible_inventory_groups.py`
- (model already created in Task 1)
- [ ] **Step 2.1: Write the migration**
Create `steward/migrations/versions/0016_ansible_inventory_groups.py`:
```python
"""Add ansible_groups and ansible_target_groups tables
Revision ID: 0016_ansible_inventory_groups
Revises: 0015_ansible_inventory_targets
Create Date: 2026-06-05
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0016_ansible_inventory_groups"
down_revision: Union[str, None] = "0015_ansible_inventory_targets"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"ansible_groups",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("name", sa.String(128), nullable=False),
sa.Column("ansible_vars", sa.JSON(), nullable=False, server_default="{}"),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.UniqueConstraint("name", name="uq_ansible_groups_name"),
)
op.create_table(
"ansible_target_groups",
sa.Column(
"target_id",
sa.String(36),
sa.ForeignKey("ansible_targets.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"group_id",
sa.String(36),
sa.ForeignKey("ansible_groups.id", ondelete="CASCADE"),
nullable=False,
),
sa.PrimaryKeyConstraint("target_id", "group_id"),
)
def downgrade() -> None:
op.drop_table("ansible_target_groups")
op.drop_table("ansible_groups")
```
- [ ] **Step 2.2: Verify the migration chain is unbroken**
```bash
grep "down_revision" steward/migrations/versions/0015_ansible_inventory_targets.py
grep "down_revision" steward/migrations/versions/0016_ansible_inventory_groups.py
```
Expected:
```
down_revision: Union[str, None] = "0014_alert_ansible_action"
down_revision: Union[str, None] = "0015_ansible_inventory_targets"
```
- [ ] **Step 2.3: Commit**
```bash
git add steward/migrations/versions/0016_ansible_inventory_groups.py
git commit -m "feat(ansible): AnsibleGroup + join table — 0016_ansible_inventory_groups migration"
```
---
## Task 3: inventory_scope Column on AnsibleRun
**Files:**
- Create: `steward/migrations/versions/0017_ansible_run_scope.py`
- Modify: `steward/models/ansible.py`
- [ ] **Step 3.1: Write the migration**
Create `steward/migrations/versions/0017_ansible_run_scope.py`:
```python
"""Add inventory_scope to ansible_runs; make inventory_path nullable; backfill scope
Revision ID: 0017_ansible_run_scope
Revises: 0016_ansible_inventory_groups
Create Date: 2026-06-05
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0017_ansible_run_scope"
down_revision: Union[str, None] = "0016_ansible_inventory_groups"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add inventory_scope — default 'steward:all' so existing rows get a value.
op.add_column(
"ansible_runs",
sa.Column(
"inventory_scope",
sa.String(255),
nullable=True,
),
)
# Backfill: existing rows used repo inventory files — reconstruct the legacy scope string.
op.execute(
"""
UPDATE ansible_runs
SET inventory_scope = 'repo:' || source_name || ':' || inventory_path
WHERE inventory_path IS NOT NULL AND inventory_path != ''
"""
)
# Rows with empty inventory_path get 'steward:all'
op.execute(
"UPDATE ansible_runs SET inventory_scope = 'steward:all' WHERE inventory_scope IS NULL"
)
# Now make it non-nullable.
op.alter_column("ansible_runs", "inventory_scope", nullable=False)
# Make inventory_path nullable — new steward:* runs won't have a file path.
op.alter_column("ansible_runs", "inventory_path", nullable=True)
def downgrade() -> None:
op.drop_column("ansible_runs", "inventory_scope")
op.alter_column("ansible_runs", "inventory_path", nullable=False)
```
- [ ] **Step 3.2: Update `steward/models/ansible.py`**
Add `inventory_scope` field and make `inventory_path` optional. In the `AnsibleRun` class, change:
```python
# Before:
inventory_path: Mapped[str] = mapped_column(String(512), nullable=False)
```
to:
```python
# After:
inventory_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
inventory_scope: Mapped[str] = mapped_column(
String(255), nullable=False, default="steward:all"
)
```
The `inventory_scope` column goes after `inventory_path` in the class body (after line ~24, before `source_name`).
- [ ] **Step 3.3: Commit**
```bash
git add steward/migrations/versions/0017_ansible_run_scope.py steward/models/ansible.py
git commit -m "feat(ansible): add inventory_scope to AnsibleRun — 0017_ansible_run_scope migration"
```
---
## Task 4: Inventory Generation — Pure Function + Unit Tests
**Files:**
- Create: `steward/ansible/inventory_gen.py`
- Create: `tests/core/test_inventory_gen.py`
- [ ] **Step 4.1: Write failing unit tests**
Create `tests/core/test_inventory_gen.py`:
```python
"""Unit tests for generate_inventory().
Uses SimpleNamespace duck-typed objects so no DB or SQLAlchemy is required.
"""
from types import SimpleNamespace
import pytest
from steward.ansible.inventory_gen import generate_inventory
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"
# No group keys other than 'all' and '_meta'
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"
```
- [ ] **Step 4.2: Run tests to confirm they all fail with ImportError**
```bash
cd /home/bvandeusen/Nextcloud/Projects/FabledSteward
pytest tests/core/test_inventory_gen.py -v 2>&1 | head -20
```
Expected: `ModuleNotFoundError: No module named 'steward.ansible.inventory_gen'`
- [ ] **Step 4.3: Create `steward/ansible/inventory_gen.py`**
```python
"""Ansible --list inventory generation from DB records."""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from steward.models.ansible_inventory import AnsibleTarget
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
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())
```
- [ ] **Step 4.4: Run tests to confirm they pass**
```bash
pytest tests/core/test_inventory_gen.py -v
```
Expected: 8 PASSED.
- [ ] **Step 4.5: Commit**
```bash
git add steward/ansible/inventory_gen.py tests/core/test_inventory_gen.py
git commit -m "feat(ansible): generate_inventory() + fetch_scope_targets() + unit tests"
```
---
## Task 5: GIT_ASKPASS — Per-Source HTTP Token Auth
**Files:**
- Modify: `steward/ansible/sources.py`
- Modify: `steward/settings/routes.py`
- Modify: `steward/templates/settings/_ansible_sources.html`
- [ ] **Step 5.1: Write a failing unit test**
Create `tests/core/test_git_askpass.py`:
```python
"""Unit tests for GIT_ASKPASS injection in git_pull()."""
import asyncio
import os
import stat
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
def test_get_sources_includes_http_token(tmp_path):
"""get_sources() must pass http_token through from config."""
from steward.ansible.sources import get_sources
cfg = {
"cache_dir": str(tmp_path),
"sources": [
{
"name": "repo",
"type": "git",
"url": "https://git.example.com/org/repo.git",
"http_token": "mytoken123",
}
],
}
sources = get_sources(cfg)
assert sources[0]["http_token"] == "mytoken123"
def test_get_sources_missing_http_token_defaults_empty(tmp_path):
"""http_token defaults to empty string when not in source config."""
from steward.ansible.sources import get_sources
cfg = {
"cache_dir": str(tmp_path),
"sources": [
{"name": "r", "type": "git", "url": "https://example.com/r.git"}
],
}
sources = get_sources(cfg)
assert sources[0]["http_token"] == ""
@pytest.mark.asyncio
async def test_git_pull_creates_askpass_when_token_set(tmp_path):
"""git_pull() sets GIT_ASKPASS env when source has http_token."""
from steward.ansible.sources import git_pull
# Simulate repo already cloned (has .git dir) so we hit the pull path.
repo_path = tmp_path / "repo"
(repo_path / ".git").mkdir(parents=True)
source = {
"name": "repo",
"path": str(repo_path),
"url": "https://git.example.com/org/repo.git",
"branch": "main",
"http_token": "secret-token",
}
captured_env = {}
async def fake_subprocess(*args, **kwargs):
captured_env.update(kwargs.get("env", {}))
proc = MagicMock()
proc.returncode = 0
proc.communicate = AsyncMock(return_value=(b"", b""))
return proc
with patch("asyncio.create_subprocess_exec", side_effect=fake_subprocess):
await git_pull(source)
assert "GIT_ASKPASS" in captured_env
askpass_path = captured_env["GIT_ASKPASS"]
assert os.path.exists(askpass_path)
mode = stat.S_IMODE(os.stat(askpass_path).st_mode)
assert mode & stat.S_IXUSR # executable
content = Path(askpass_path).read_text()
assert "secret-token" in content
assert "oauth2" in content
@pytest.mark.asyncio
async def test_git_pull_no_askpass_without_token(tmp_path):
"""git_pull() does NOT set GIT_ASKPASS when http_token is empty."""
from steward.ansible.sources import git_pull
repo_path = tmp_path / "repo"
(repo_path / ".git").mkdir(parents=True)
source = {
"name": "repo",
"path": str(repo_path),
"url": "https://git.example.com/org/repo.git",
"branch": "main",
"http_token": "",
}
captured_env = {}
async def fake_subprocess(*args, **kwargs):
captured_env.update(kwargs.get("env", {}))
proc = MagicMock()
proc.returncode = 0
proc.communicate = AsyncMock(return_value=(b"", b""))
return proc
with patch("asyncio.create_subprocess_exec", side_effect=fake_subprocess):
await git_pull(source)
assert "GIT_ASKPASS" not in captured_env
```
- [ ] **Step 5.2: Run to confirm failures**
```bash
pytest tests/core/test_git_askpass.py -v 2>&1 | tail -10
```
Expected: `test_get_sources_includes_http_token` FAILED (KeyError: `http_token`), asyncio tests FAILED.
- [ ] **Step 5.3: Update `get_sources()` in `steward/ansible/sources.py`**
In `get_sources()`, inside the `if src_type == "git":` block, add:
```python
"http_token": src.get("http_token", ""),
```
The full updated return dict for git sources becomes:
```python
result.append({
"name": src["name"],
"type": src_type,
"path": path,
"url": src.get("url"),
"branch": src.get("branch", "main"),
"pull_interval_seconds": int(src.get("pull_interval_seconds", 3600)),
"http_token": src.get("http_token", ""), # ADD THIS LINE
})
```
For `local` sources, add `"http_token": ""` so callers can always rely on the key:
```python
result.append({
"name": src["name"],
"type": src_type,
"path": path,
"url": src.get("url"),
"branch": src.get("branch", "main"),
"pull_interval_seconds": int(src.get("pull_interval_seconds", 3600)),
"http_token": "", # local sources don't use auth
})
```
- [ ] **Step 5.4: Update `git_pull()` in `steward/ansible/sources.py`**
Replace the existing `git_pull()` function with:
```python
async def git_pull(source: dict) -> None:
"""Clone the git repo if absent; pull if already present.
When source['http_token'] is set, injects credentials via a temporary
GIT_ASKPASS script so they never touch .git/config or process args.
"""
import os
import stat
import tempfile
path = Path(source["path"])
token = source.get("http_token", "")
env = None
askpass_path = None
if token:
# Write a temp askpass script that echoes the token as the git password.
fd, askpass_path = tempfile.mkstemp(prefix="steward-askpass-", suffix=".sh")
try:
script = (
"#!/bin/sh\n"
'case "$1" in\n'
' Username*) echo "oauth2" ;;\n'
f' Password*) echo "{token}" ;;\n'
"esac\n"
)
os.write(fd, script.encode())
finally:
os.close(fd)
os.chmod(askpass_path, stat.S_IRWXU)
env = {**os.environ, "GIT_ASKPASS": askpass_path}
try:
if not (path / ".git").exists():
path.mkdir(parents=True, exist_ok=True)
proc = await asyncio.create_subprocess_exec(
"git", "clone",
"--branch", source["branch"],
"--single-branch",
source["url"], str(path),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
logger.error(
"git clone failed for %r: %s",
source["name"],
stderr.decode(errors="replace"),
)
else:
proc = await asyncio.create_subprocess_exec(
"git", "-C", str(path), "pull",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
logger.error(
"git pull failed for %r: %s",
source["name"],
stderr.decode(errors="replace"),
)
finally:
if askpass_path and os.path.exists(askpass_path):
os.unlink(askpass_path)
```
- [ ] **Step 5.5: Run tests to confirm they pass**
```bash
pytest tests/core/test_git_askpass.py -v
```
Expected: 4 PASSED.
- [ ] **Step 5.6: Update `ansible_save_source()` in `steward/settings/routes.py`**
In the `ansible_save_source()` function (line ~249), inside the `if src_type == "git":` block, add:
```python
token = form.get("http_token", "").strip()
if token:
entry["http_token"] = token
elif "http_token" in sources[idx]:
# Preserve existing token if form submitted empty (password fields don't round-trip)
entry["http_token"] = sources[idx]["http_token"]
```
Also find `ansible_add_source()` (around line ~212) and add the same token-saving logic in the `if src_type == "git":` block.
- [ ] **Step 5.7: Add `http_token` field to `_ansible_sources.html`**
Inside the `ansible-edit-git-fields-{{ idx }}` div, after the `pull_interval_seconds` form-group, add:
```html
<div class="form-group" style="margin:0;grid-column:1/-1;">
<label style="font-size:0.8rem;">
HTTP Token
<span style="color:var(--text-muted);font-weight:normal;">(optional — Gitea PAT for private repos)</span>
</label>
<input type="password" name="http_token"
placeholder="{% if src.http_token %}token saved — enter new to replace{% endif %}"
autocomplete="new-password">
</div>
```
Inside the `ansible-add-git-fields` div, after the `pull_interval_seconds` form-group, add:
```html
<div class="form-group" style="margin:0;grid-column:1/-1;">
<label style="font-size:0.8rem;">HTTP Token
<span style="color:var(--text-muted);font-weight:normal;">(optional)</span>
</label>
<input type="password" name="http_token" autocomplete="new-password">
</div>
```
- [ ] **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:<source_name>:<relative_path>
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 7385) with the scope picker. The existing block is:
```html
<div class="form-group">
<label>Inventory</label>
<select name="inventory_path">
{% for inv in sd.inventories %}
<option value="{{ inv }}">{{ inv }}</option>
{% endfor %}
<option value="">— Enter manually below —</option>
</select>
</div>
<div class="form-group">
<label>Or enter inventory path manually</label>
<input type="text" id="manual-inv-{{ sd.source.name }}" placeholder="inventories/production/hosts">
</div>
```
Replace with:
```html
<div class="form-group">
<label>Run Against</label>
<select name="inventory_scope">
{% if targets %}
<optgroup label="All Targets">
<option value="steward:all">All targets ({{ targets | length }})</option>
</optgroup>
{% if groups %}
<optgroup label="Group">
{% for grp in groups %}
<option value="steward:group:{{ grp.id }}">Group: {{ grp.name }}</option>
{% endfor %}
</optgroup>
{% endif %}
<optgroup label="Single Target">
{% for tgt in targets %}
<option value="steward:target:{{ tgt.id }}">{{ tgt.name }}</option>
{% endfor %}
</optgroup>
{% endif %}
{% if sd.inventories %}
<optgroup label="Repo Inventory File">
{% for inv in sd.inventories %}
<option value="repo:{{ sd.source.name }}:{{ inv }}">{{ sd.source.name }}/{{ inv }}</option>
{% endfor %}
</optgroup>
{% endif %}
{% if not targets and not sd.inventories %}
<option value="" disabled>No targets or inventory files found</option>
{% endif %}
</select>
</div>
```
Also update the "Execute" button's onclick — remove the `manual-inv` logic since there's no manual field anymore:
```html
<button type="submit" class="btn">Execute</button>
```
(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
<tr>
<th>Scope</th>
<td style="font-size:0.9rem;font-family:ui-monospace,monospace;">{{ run.inventory_scope }}</td>
</tr>
```
(Check the existing table structure in `run_detail.html` first — look for `run.playbook_path` and follow the same `<tr>` 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/<group_id>")
@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/<group_id>")
@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/<group_id>/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 %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
<h1 class="page-title" style="margin-bottom:0;">Inventory Groups</h1>
<a href="/ansible/inventory/targets" class="btn btn-ghost btn-sm">Targets</a>
</div>
<div class="card" style="margin-bottom:1.5rem;">
<h3 class="section-title">New Group</h3>
<form method="post" action="/ansible/inventory/groups" style="display:flex;gap:0.75rem;align-items:flex-end;">
<div class="form-group" style="flex:1;margin:0;">
<label>Name</label>
<input type="text" name="name" placeholder="webservers" required>
</div>
<button type="submit" class="btn">Create</button>
</form>
</div>
{% if not groups %}
<div class="card">
<p style="color:var(--text-muted);">No groups yet. Create one above.</p>
</div>
{% else %}
<div class="card-flush">
<table class="table">
<thead>
<tr><th>Name</th><th>Members</th><th></th></tr>
</thead>
<tbody>
{% for grp in groups %}
<tr>
<td><strong>{{ grp.name }}</strong></td>
<td style="color:var(--text-muted);font-size:0.9rem;">{{ grp.targets | length }}</td>
<td class="td-actions">
<a href="/ansible/inventory/groups/{{ grp.id }}" class="btn btn-sm btn-ghost">Edit</a>
<form method="post" action="/ansible/inventory/groups/{{ grp.id }}/delete" style="display:inline;">
<button type="submit" class="btn btn-sm btn-danger"
onclick="return confirm('Delete group {{ grp.name }}?')">×</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% 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 %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
<h1 class="page-title" style="margin-bottom:0;">Group: {{ group.name }}</h1>
<a href="/ansible/inventory/groups" class="btn btn-ghost btn-sm">← Groups</a>
</div>
<form method="post" action="/ansible/inventory/groups/{{ group.id }}">
<div class="card" style="margin-bottom:1.5rem;">
<h3 class="section-title">Settings</h3>
<div class="form-group">
<label>Name</label>
<input type="text" name="name" value="{{ group.name }}" required>
</div>
<div class="form-group">
<label>
Group Variables
<span style="color:var(--text-muted);font-weight:normal;font-size:0.82rem;">
(YAML, applied to all members — per-target vars override these)
</span>
</label>
<textarea name="ansible_vars" rows="8"
style="width:100%;font-family:ui-monospace,monospace;font-size:0.85rem;"
placeholder="ansible_user: deploy&#10;http_port: 80">{{ ansible_vars_yaml }}</textarea>
</div>
</div>
<div class="card" style="margin-bottom:1.5rem;">
<h3 class="section-title">Members</h3>
{% if not all_targets %}
<p style="color:var(--text-muted);font-size:0.9rem;">No targets exist yet.
<a href="/ansible/inventory/targets">Add targets</a> first.</p>
{% else %}
<div style="display:grid;gap:0.4rem;margin-bottom:1rem;">
{% for tgt in all_targets %}
<label style="display:flex;align-items:center;gap:0.6rem;font-weight:normal;cursor:pointer;">
<input type="checkbox" name="target_ids" value="{{ tgt.id }}"
{% if tgt.id in member_ids %}checked{% endif %}>
<span>{{ tgt.name }}</span>
<span style="color:var(--text-muted);font-size:0.82rem;">{{ tgt.address }}</span>
</label>
{% endfor %}
</div>
{% endif %}
</div>
<button type="submit" class="btn">Save Changes</button>
</form>
{% 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/<target_id>")
@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/<target_id>")
@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/<target_id>/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 %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
<h1 class="page-title" style="margin-bottom:0;">Inventory Targets</h1>
<a href="/ansible/inventory/groups" class="btn btn-ghost btn-sm">Groups</a>
</div>
<div class="card" style="margin-bottom:1.5rem;">
<h3 class="section-title">New Target</h3>
<form method="post" action="/ansible/inventory/targets" style="display:flex;gap:0.75rem;align-items:flex-end;">
<div class="form-group" style="flex:1;margin:0;">
<label>Name <span style="color:var(--text-muted);font-weight:normal;font-size:0.82rem;">(used as Ansible hostname)</span></label>
<input type="text" name="name" placeholder="webserver-01" required>
</div>
<div class="form-group" style="flex:1;margin:0;">
<label>Address <span style="color:var(--text-muted);font-weight:normal;font-size:0.82rem;">(IP or DNS)</span></label>
<input type="text" name="address" placeholder="192.168.1.10" required>
</div>
<button type="submit" class="btn">Add</button>
</form>
</div>
{% if not targets %}
<div class="card">
<p style="color:var(--text-muted);">No targets yet. Add one above.</p>
</div>
{% else %}
<div class="card-flush">
<table class="table">
<thead>
<tr><th>Name</th><th>Address</th><th>Groups</th><th></th></tr>
</thead>
<tbody>
{% for tgt in targets %}
<tr>
<td><strong>{{ tgt.name }}</strong></td>
<td style="font-family:ui-monospace,monospace;font-size:0.88rem;">{{ tgt.address }}</td>
<td style="font-size:0.82rem;color:var(--text-muted);">
{% for grp in tgt.groups %}
<span style="background:var(--bg-elevated);border-radius:3px;padding:0.1em 0.4em;margin-right:0.25rem;">{{ grp.name }}</span>
{% endfor %}
</td>
<td class="td-actions">
<a href="/ansible/inventory/targets/{{ tgt.id }}" class="btn btn-sm btn-ghost">Edit</a>
<form method="post" action="/ansible/inventory/targets/{{ tgt.id }}/delete" style="display:inline;">
<button type="submit" class="btn btn-sm btn-danger"
onclick="return confirm('Delete target {{ tgt.name }}?')">×</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% 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 %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
<h1 class="page-title" style="margin-bottom:0;">Target: {{ target.name }}</h1>
<a href="/ansible/inventory/targets" class="btn btn-ghost btn-sm">← Targets</a>
</div>
<form method="post" action="/ansible/inventory/targets/{{ target.id }}">
<div class="card" style="margin-bottom:1.5rem;">
<h3 class="section-title">Connection</h3>
<div class="form-group">
<label>Name <span style="color:var(--text-muted);font-weight:normal;font-size:0.82rem;">(Ansible hostname — used in plays as <code>hosts: name</code>)</span></label>
<input type="text" name="name" value="{{ target.name }}" required>
</div>
<div class="form-group">
<label>Address <span style="color:var(--text-muted);font-weight:normal;font-size:0.82rem;">(becomes <code>ansible_host</code> in inventory)</span></label>
<input type="text" name="address" value="{{ target.address }}" required>
</div>
<div class="form-group">
<label>
Target Variables
<span style="color:var(--text-muted);font-weight:normal;font-size:0.82rem;">
(YAML — override group vars for this target only)
</span>
</label>
<textarea name="ansible_vars" rows="8"
style="width:100%;font-family:ui-monospace,monospace;font-size:0.85rem;"
placeholder="ansible_user: ubuntu&#10;ansible_port: 22">{{ ansible_vars_yaml }}</textarea>
</div>
</div>
<div class="card" style="margin-bottom:1.5rem;">
<h3 class="section-title">Groups</h3>
{% if not all_groups %}
<p style="color:var(--text-muted);font-size:0.9rem;">No groups exist yet.
<a href="/ansible/inventory/groups">Create a group</a> first.</p>
{% else %}
<div style="display:grid;gap:0.4rem;margin-bottom:1rem;">
{% for grp in all_groups %}
<label style="display:flex;align-items:center;gap:0.6rem;font-weight:normal;cursor:pointer;">
<input type="checkbox" name="group_ids" value="{{ grp.id }}"
{% if grp.id in member_group_ids %}checked{% endif %}>
<span>{{ grp.name }}</span>
</label>
{% endfor %}
</div>
{% endif %}
</div>
<button type="submit" class="btn">Save Changes</button>
</form>
{% 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("/<host_id>/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 /<host_id>/ansible-link` to handle the link action:
```python
@hosts_bp.post("/<host_id>/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 %}
<div class="card" style="margin-top:1.5rem;">
<h3 class="section-title">Ansible Target</h3>
{% if linked_target %}
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1rem;padding:0.75rem;background:var(--bg-elevated);border-radius:4px;">
<div style="flex:1;">
<strong>{{ linked_target.name }}</strong>
<span style="color:var(--text-muted);font-size:0.85rem;margin-left:0.5rem;">{{ linked_target.address }}</span>
{% if linked_target.groups %}
<div style="margin-top:0.25rem;">
{% for grp in linked_target.groups %}
<span style="font-size:0.78rem;background:var(--bg);border-radius:3px;padding:0.1em 0.4em;margin-right:0.2rem;color:var(--text-muted);">{{ grp.name }}</span>
{% endfor %}
</div>
{% endif %}
</div>
<a href="/ansible/inventory/targets/{{ linked_target.id }}" class="btn btn-ghost btn-sm">Edit Target</a>
<form method="post" action="/hosts/{{ host.id }}/ansible-link" style="display:inline;">
<input type="hidden" name="action" value="unlink">
<button type="submit" class="btn btn-ghost btn-sm">Unlink</button>
</form>
</div>
{% else %}
<p style="color:var(--text-muted);font-size:0.9rem;margin-bottom:1rem;">
No Ansible target linked. Link an existing target or create a new one from this host's data.
</p>
<div style="display:flex;gap:0.75rem;flex-wrap:wrap;">
{% if linkable_targets %}
<form method="post" action="/hosts/{{ host.id }}/ansible-link" style="display:flex;gap:0.5rem;align-items:center;">
<input type="hidden" name="action" value="link">
<select name="target_id" style="flex:1;">
{% for tgt in linkable_targets %}
<option value="{{ tgt.id }}">{{ tgt.name }} ({{ tgt.address }})</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-sm">Link</button>
</form>
{% endif %}
<form method="post" action="/hosts/{{ host.id }}/ansible-link">
<input type="hidden" name="action" value="create">
<button type="submit" class="btn btn-sm btn-ghost">Create Target from This Host</button>
</form>
</div>
{% endif %}
</div>
{% endif %}
```
- [ ] **Step 9.3: Verify the Ansible section appears on host edit**
Navigate to `http://localhost:5000/hosts/<any-host-id>/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 13
- §2 PAT/HTTP Token → Task 5
- §3 Inventory Generation → Task 4
- §4 Run Form → Task 6
- §5 UI Surfaces → Tasks 79
- §6 Repo integration → Task 6 (scope fallback preserved)
- §7 Migrations → Tasks 13
- §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).