73 KiB
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:
"""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
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:
from .ansible_inventory import AnsibleTarget, AnsibleGroup, ansible_target_groups
Add to __all__:
"AnsibleTarget", "AnsibleGroup", "ansible_target_groups",
- Step 1.4: Run the migration to verify it applies cleanly
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
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:
"""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
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
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:
"""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:
# Before:
inventory_path: Mapped[str] = mapped_column(String(512), nullable=False)
to:
# 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
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:
"""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
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
"""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
pytest tests/core/test_inventory_gen.py -v
Expected: 8 PASSED.
- Step 4.5: Commit
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:
"""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
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()insteward/ansible/sources.py
In get_sources(), inside the if src_type == "git": block, add:
"http_token": src.get("http_token", ""),
The full updated return dict for git sources becomes:
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:
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()insteward/ansible/sources.py
Replace the existing git_pull() function with:
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
pytest tests/core/test_git_askpass.py -v
Expected: 4 PASSED.
- Step 5.6: Update
ansible_save_source()insteward/settings/routes.py
In the ansible_save_source() function (line ~249), inside the if src_type == "git": block, add:
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_tokenfield to_ansible_sources.html
Inside the ansible-edit-git-fields-{{ idx }} div, after the pull_interval_seconds form-group, add:
<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:
<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
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()insteward/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:
@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_scopefrom the form instead ofinventory_path - Generate
inventory_contentfrom DB forsteward:*scopes - Store
inventory_scopeon theAnsibleRunrow
Full updated function (replaces the existing one at line ~108):
@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:
@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.htmlrun form
Replace the existing "Inventory" form-group block (lines 73–85) with the scope picker. The existing block is:
<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:
<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:
<button type="submit" class="btn">Execute</button>
(The form can submit directly now.)
- Step 6.5: Update
run_detail.htmlto 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:
<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
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
"""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
{% 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
{% 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 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:
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/groupsloads
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
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):
@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
{% 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
{% 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 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
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 insteward/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:
@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:
@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:
{% 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
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:
"""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
pytest tests/integration/test_ansible_inventory.py -m integration -v
Expected: 4 PASSED (requires running Postgres with migrations applied).
- Step 10.3: Commit
git add tests/integration/test_ansible_inventory.py
git commit -m "test(ansible): integration tests for inventory tables + fetch_scope_targets"
Self-Review Checklist
- 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 ✓
- No placeholders: All code blocks are complete.
- Type consistency:
AnsibleTarget,AnsibleGroup,ansible_target_groupsdefined in Task 1 and referenced consistently throughout.fetch_scope_targets(db, scope)/generate_inventory(targets)defined in Task 4 and called in Task 6. - Migration chain:
0015 → 0016 → 0017, eachdown_revisionpoints to the previous. - Session pattern: All routes use
async with current_app.db_sessionmaker() as db:matching existing code. - Import pattern: New models added to
steward/models/__init__.pyfor Alembic discovery. get_sources()http_token: Added for both git and local sources (empty string for local).