2072 lines
73 KiB
Markdown
2072 lines
73 KiB
Markdown
# 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 73–85) 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 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 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 1–3
|
||
- §2 PAT/HTTP Token → Task 5
|
||
- §3 Inventory Generation → Task 4
|
||
- §4 Run Form → Task 6
|
||
- §5 UI Surfaces → Tasks 7–9
|
||
- §6 Repo integration → Task 6 (scope fallback preserved)
|
||
- §7 Migrations → Tasks 1–3
|
||
- §8 Out of scope → not implemented ✓
|
||
- [x] **No placeholders**: All code blocks are complete.
|
||
- [x] **Type consistency**: `AnsibleTarget`, `AnsibleGroup`, `ansible_target_groups` defined in Task 1 and referenced consistently throughout. `fetch_scope_targets(db, scope)` / `generate_inventory(targets)` defined in Task 4 and called in Task 6.
|
||
- [x] **Migration chain**: `0015 → 0016 → 0017`, each `down_revision` points to the previous.
|
||
- [x] **Session pattern**: All routes use `async with current_app.db_sessionmaker() as db:` matching existing code.
|
||
- [x] **Import pattern**: New models added to `steward/models/__init__.py` for Alembic discovery.
|
||
- [x] **`get_sources()` http_token**: Added for both git and local sources (empty string for local).
|