Compare commits

13 Commits

Author SHA1 Message Date
bvandeusen b49496b57b feat(plugins): default-enable generic bundled plugins on fresh install
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 7s
CI / integration (push) Failing after 2m16s
CI / publish (push) Has been skipped
A fresh install enabled zero plugins — settings.py DEFAULTS had no plugin.*
keys, so to_plugins_cfg returned {} and every plugin had to be flipped on
by hand. Seed docker, host_agent, http, snmp as default-on (generic, non-
vendor-specific); traefik and unifi stay opt-in. Stored choices override
the default, so disabling persists.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 21:24:21 -04:00
bvandeusen 80613d6310 chore(lint): remove unused imports flagged by ruff
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 18:55:21 -04:00
bvandeusen 923905e72d test(ansible): integration tests for inventory tables + fetch_scope_targets 2026-06-05 18:52:10 -04:00
bvandeusen dd0acaf623 feat(ansible): host edit page Ansible target link/create/unlink section 2026-06-05 18:51:33 -04:00
bvandeusen 37d9ca8a8a feat(ansible): inventory targets CRUD templates 2026-06-05 18:50:40 -04:00
bvandeusen 935cbc105c feat(ansible): inventory groups CRUD routes + templates + blueprint registration 2026-06-05 18:50:09 -04:00
bvandeusen 2c991eabd2 feat(ansible): scope picker in run form + create_run() DB inventory generation 2026-06-05 18:49:02 -04:00
bvandeusen d906232eb2 feat(ansible): GIT_ASKPASS per-source HTTP token auth + settings UI 2026-06-05 18:48:00 -04:00
bvandeusen ea47169049 feat(ansible): generate_inventory() + fetch_scope_targets() + unit tests 2026-06-05 18:46:22 -04:00
bvandeusen 10df05c13e feat(ansible): add inventory_scope to AnsibleRun — 0017_ansible_run_scope migration 2026-06-05 18:45:38 -04:00
bvandeusen fa32488fdf feat(ansible): AnsibleGroup + join table — 0016_ansible_inventory_groups migration 2026-06-05 18:45:16 -04:00
bvandeusen c11b3f01ed feat(ansible): AnsibleTarget model + 0015_ansible_inventory_targets migration 2026-06-05 18:45:00 -04:00
bvandeusen eb319d715e docs: Ansible inventory infrastructure implementation plan 2026-06-05 18:19:17 -04:00
27 changed files with 3576 additions and 48 deletions
File diff suppressed because it is too large Load Diff
+81
View File
@@ -0,0 +1,81 @@
"""Ansible --list inventory generation from DB records."""
from __future__ import annotations
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())
+256
View File
@@ -0,0 +1,256 @@
"""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
# ---------------------------------------------------------------------------
# Groups
# ---------------------------------------------------------------------------
@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
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"))
# ---------------------------------------------------------------------------
# Targets
# ---------------------------------------------------------------------------
@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"))
+39 -5
View File
@@ -37,6 +37,8 @@ async def index():
@ansible_bp.get("/browse") @ansible_bp.get("/browse")
@require_role(UserRole.viewer) @require_role(UserRole.viewer)
async def browse(): async def browse():
from steward.models.ansible_inventory import AnsibleTarget, AnsibleGroup
all_sources = _get_sources() all_sources = _get_sources()
source_data = [] source_data = []
for source in all_sources: for source in all_sources:
@@ -47,7 +49,21 @@ async def browse():
"playbooks": playbooks, "playbooks": playbooks,
"inventories": inventories, "inventories": inventories,
}) })
return await render_template("ansible/browse.html", source_data=source_data)
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,
)
@ansible_bp.get("/browse/<source_name>/<path:playbook_path>") @ansible_bp.get("/browse/<source_name>/<path:playbook_path>")
@@ -72,19 +88,35 @@ async def view_playbook(source_name: str, playbook_path: str):
@ansible_bp.post("/runs") @ansible_bp.post("/runs")
@require_role(UserRole.operator) @require_role(UserRole.operator)
async def create_run(): async def create_run():
import json as _json
from steward.ansible.inventory_gen import fetch_scope_targets, generate_inventory
form = await request.form form = await request.form
playbook_path = form.get("playbook_path", "").strip() playbook_path = form.get("playbook_path", "").strip()
source_name = form.get("source_name", "").strip() source_name = form.get("source_name", "").strip()
inventory_path = form.get("inventory_path", "").strip() inventory_scope = form.get("inventory_scope", "steward:all").strip()
if not playbook_path or not inventory_path: if not playbook_path:
return "playbook_path and inventory_path are required", 400 return "playbook_path is required", 400
all_sources = _get_sources() all_sources = _get_sources()
source = next((s for s in all_sources if s["name"] == source_name), None) source = next((s for s in all_sources if s["name"] == source_name), None)
if source is None: if source is None:
return "Source not found", 404 return "Source not found", 404
# Resolve inventory for this scope.
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:"):
parts = inventory_scope.split(":", 2)
inventory_path = parts[2] if len(parts) == 3 else ""
else:
return "Invalid inventory_scope", 400
# Optional run parameters (all passed as argv by the executor — no shell). # Optional run parameters (all passed as argv by the executor — no shell).
extra_vars: list[str] = [] extra_vars: list[str] = []
for line in (form.get("extra_vars", "") or "").splitlines(): for line in (form.get("extra_vars", "") or "").splitlines():
@@ -116,6 +148,7 @@ async def create_run():
id=run_id, id=run_id,
playbook_path=playbook_path, playbook_path=playbook_path,
inventory_path=inventory_path, inventory_path=inventory_path,
inventory_scope=inventory_scope,
source_name=source_name, source_name=source_name,
triggered_by=session["user_id"], triggered_by=session["user_id"],
status=AnsibleRunStatus.running, status=AnsibleRunStatus.running,
@@ -131,9 +164,10 @@ async def create_run():
current_app._get_current_object(), # type: ignore[attr-defined] current_app._get_current_object(), # type: ignore[attr-defined]
run_id, run_id,
playbook_path, playbook_path,
inventory_path, inventory_path or "",
source["path"], source["path"],
params_or_none, params_or_none,
inventory_content,
) )
) )
task.add_done_callback( task.add_done_callback(
+65 -23
View File
@@ -1,6 +1,9 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import logging import logging
import os
import stat
import tempfile
from pathlib import Path from pathlib import Path
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -48,6 +51,7 @@ def get_sources(ansible_cfg: dict) -> list[dict]:
"url": src.get("url"), "url": src.get("url"),
"branch": src.get("branch", "main"), "branch": src.get("branch", "main"),
"pull_interval_seconds": int(src.get("pull_interval_seconds", 3600)), "pull_interval_seconds": int(src.get("pull_interval_seconds", 3600)),
"http_token": src.get("http_token", "") if src_type == "git" else "",
}) })
return result return result
@@ -99,27 +103,65 @@ def read_playbook(source_path: str, relative_path: str) -> str | None:
async def git_pull(source: dict) -> None: async def git_pull(source: dict) -> None:
"""Clone the git repo if absent; pull if already present.""" """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.
"""
path = Path(source["path"]) path = Path(source["path"])
if not (path / ".git").exists(): token = source.get("http_token", "")
path.mkdir(parents=True, exist_ok=True)
proc = await asyncio.create_subprocess_exec( env = None
"git", "clone", askpass_path = None
"--branch", source["branch"], if token:
"--single-branch", fd, askpass_path = tempfile.mkstemp(prefix="steward-askpass-", suffix=".sh")
source["url"], str(path), try:
stdout=asyncio.subprocess.PIPE, script = (
stderr=asyncio.subprocess.PIPE, "#!/bin/sh\n"
) 'case "$1" in\n'
_, stderr = await proc.communicate() ' Username*) echo "oauth2" ;;\n'
if proc.returncode != 0: f' Password*) echo "{token}" ;;\n'
logger.error("git clone failed for %r: %s", source["name"], stderr.decode(errors="replace")) "esac\n"
else: )
proc = await asyncio.create_subprocess_exec( os.write(fd, script.encode())
"git", "-C", str(path), "pull", finally:
stdout=asyncio.subprocess.PIPE, os.close(fd)
stderr=asyncio.subprocess.PIPE, os.chmod(askpass_path, stat.S_IRWXU)
) env = {**os.environ, "GIT_ASKPASS": askpass_path}
_, stderr = await proc.communicate()
if proc.returncode != 0: try:
logger.error("git pull failed for %r: %s", source["name"], stderr.decode(errors="replace")) 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)
+2
View File
@@ -100,6 +100,7 @@ def create_app(
from .dns.routes import dns_bp from .dns.routes import dns_bp
from .alerts.routes import alerts_bp from .alerts.routes import alerts_bp
from .ansible.routes import ansible_bp from .ansible.routes import ansible_bp
from .ansible.inventory_routes import inventory_bp
from .settings.routes import settings_bp from .settings.routes import settings_bp
from .audit.routes import audit_bp from .audit.routes import audit_bp
@@ -110,6 +111,7 @@ def create_app(
app.register_blueprint(dns_bp) app.register_blueprint(dns_bp)
app.register_blueprint(alerts_bp) app.register_blueprint(alerts_bp)
app.register_blueprint(ansible_bp) app.register_blueprint(ansible_bp)
app.register_blueprint(inventory_bp)
app.register_blueprint(settings_bp) app.register_blueprint(settings_bp)
app.register_blueprint(audit_bp) app.register_blueprint(audit_bp)
+11
View File
@@ -60,6 +60,17 @@ DEFAULTS: dict[str, Any] = {
"ping.threshold.good_ms": 50, "ping.threshold.good_ms": 50,
"ping.threshold.warn_ms": 200, "ping.threshold.warn_ms": 200,
"plugins.index_url": "https://git.fabledsword.com/bvandeusen/Steward-plugins/raw/branch/main/index.yaml", "plugins.index_url": "https://git.fabledsword.com/bvandeusen/Steward-plugins/raw/branch/main/index.yaml",
# Default-enabled plugins. These are the generic, non-vendor-specific
# bundled plugins (protocols/standards, not a single product) — useful on
# almost any install, so a fresh deployment comes up monitoring rather than
# blank. Vendor-specific plugins (traefik, unifi) stay opt-in. An operator
# who disables one writes plugin.<name>={"enabled": False}, which overrides
# these defaults (stored value wins in get_all_settings/load_settings_sync).
# Per-plugin yaml config defaults are merged on top at load time.
"plugin.docker": {"enabled": True},
"plugin.host_agent": {"enabled": True},
"plugin.http": {"enabled": True},
"plugin.snmp": {"enabled": True},
# OIDC single-sign-on # OIDC single-sign-on
"oidc.enabled": False, "oidc.enabled": False,
"oidc.discovery_url": "", "oidc.discovery_url": "",
+79 -4
View File
@@ -147,14 +147,38 @@ async def create_host():
@hosts_bp.get("/<host_id>/edit") @hosts_bp.get("/<host_id>/edit")
@require_role(UserRole.operator) @require_role(UserRole.operator)
async def edit_host(host_id: str): 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: async with current_app.db_sessionmaker() as db:
result = await db.execute(select(Host).where(Host.id == host_id)) result = await db.execute(select(Host).where(Host.id == host_id))
host = result.scalar_one_or_none() host = result.scalar_one_or_none()
if host is None: if host is None:
return "Not found", 404 return "Not found", 404
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()
# Only show targets not yet linked to any host (plus the currently linked one)
linkable_result = await db.execute(
select(AnsibleTarget)
.where(AnsibleTarget.host_id.is_(None))
.order_by(AnsibleTarget.name)
)
linkable_targets = linkable_result.scalars().all()
return await render_template( return await render_template(
"hosts/form.html", host=host, probe_types=list(ProbeType), "hosts/form.html",
ansible_sources=_ansible_source_names()) host=host,
probe_types=list(ProbeType),
ansible_sources=_ansible_source_names(),
linked_target=linked_target,
linkable_targets=linkable_targets,
)
@hosts_bp.post("/<host_id>") @hosts_bp.post("/<host_id>")
@@ -178,6 +202,57 @@ async def update_host(host_id: str):
return redirect(url_for("hosts.list_hosts")) return redirect(url_for("hosts.list_hosts"))
@hosts_bp.post("/<host_id>/ansible-link")
@require_role(UserRole.operator)
async def ansible_link(host_id: str):
"""Link, unlink, or create an AnsibleTarget from this host."""
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":
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 first
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":
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")
@hosts_bp.post("/<host_id>/run-playbook") @hosts_bp.post("/<host_id>/run-playbook")
@require_role(UserRole.operator) @require_role(UserRole.operator)
async def run_playbook(host_id: str): async def run_playbook(host_id: str):
@@ -0,0 +1,41 @@
"""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")
@@ -0,0 +1,51 @@
"""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")
@@ -0,0 +1,41 @@
"""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:
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"
)
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)
+2
View File
@@ -5,6 +5,7 @@ from .monitors import PingResult, DnsResult, PingStatus, DnsStatus
from .metrics import PluginMetric from .metrics import PluginMetric
from .alerts import AlertRule, AlertState, AlertEvent, AlertOperator, AlertStateEnum from .alerts import AlertRule, AlertState, AlertEvent, AlertOperator, AlertStateEnum
from .ansible import AnsibleRun, AnsibleRunStatus from .ansible import AnsibleRun, AnsibleRunStatus
from .ansible_inventory import AnsibleTarget, AnsibleGroup, ansible_target_groups
from .settings import AppSetting from .settings import AppSetting
from .dashboard import Dashboard, DashboardWidget, DashboardShareToken from .dashboard import Dashboard, DashboardWidget, DashboardShareToken
@@ -15,6 +16,7 @@ __all__ = [
"PluginMetric", "PluginMetric",
"AlertRule", "AlertState", "AlertEvent", "AlertOperator", "AlertStateEnum", "AlertRule", "AlertState", "AlertEvent", "AlertOperator", "AlertStateEnum",
"AnsibleRun", "AnsibleRunStatus", "AnsibleRun", "AnsibleRunStatus",
"AnsibleTarget", "AnsibleGroup", "ansible_target_groups",
"AppSetting", "AppSetting",
"Dashboard", "DashboardWidget", "DashboardShareToken", "Dashboard", "DashboardWidget", "DashboardShareToken",
] ]
+4 -1
View File
@@ -19,7 +19,10 @@ class AnsibleRun(Base):
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
playbook_path: Mapped[str] = mapped_column(String(512), nullable=False) playbook_path: Mapped[str] = mapped_column(String(512), nullable=False)
inventory_path: Mapped[str] = mapped_column(String(512), nullable=False) inventory_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
inventory_scope: Mapped[str] = mapped_column(
String(255), nullable=False, default="steward:all"
)
source_name: Mapped[str] = mapped_column(String(128), nullable=False) source_name: Mapped[str] = mapped_column(String(128), nullable=False)
# Nullable: automated runs (alert actions, schedules) have no human actor — # Nullable: automated runs (alert actions, schedules) have no human actor —
# NULL renders as "system". Manual runs still record the triggering user. # NULL renders as "system". Manual runs still record the triggering user.
+69
View File
@@ -0,0 +1,69 @@
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",
)
+9
View File
@@ -226,6 +226,9 @@ async def ansible_add_source():
entry["pull_interval_seconds"] = int(form.get("pull_interval_seconds", 3600)) entry["pull_interval_seconds"] = int(form.get("pull_interval_seconds", 3600))
except ValueError: except ValueError:
entry["pull_interval_seconds"] = 3600 entry["pull_interval_seconds"] = 3600
token = form.get("http_token", "").strip()
if token:
entry["http_token"] = token
else: else:
entry["path"] = form.get("path", "").strip() entry["path"] = form.get("path", "").strip()
sources = await _get_ansible_sources() sources = await _get_ansible_sources()
@@ -263,6 +266,12 @@ async def ansible_save_source(idx: int):
entry["pull_interval_seconds"] = int(form.get("pull_interval_seconds", 3600)) entry["pull_interval_seconds"] = int(form.get("pull_interval_seconds", 3600))
except ValueError: except ValueError:
entry["pull_interval_seconds"] = 3600 entry["pull_interval_seconds"] = 3600
token = form.get("http_token", "").strip()
if token:
entry["http_token"] = token
elif "http_token" in sources[idx]:
# Preserve existing token — password fields don't round-trip via browser
entry["http_token"] = sources[idx]["http_token"]
else: else:
entry["path"] = form.get("path", "").strip() entry["path"] = form.get("path", "").strip()
sources[idx] = entry sources[idx] = entry
+30 -14
View File
@@ -71,18 +71,37 @@
readonly style="color:var(--text-muted);" value=""> readonly style="color:var(--text-muted);" value="">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Inventory</label> <label>Run Against</label>
<select name="inventory_path"> <select name="inventory_scope">
{% for inv in sd.inventories %} {% if targets %}
<option value="{{ inv }}">{{ inv }}</option> <optgroup label="All Targets">
{% endfor %} <option value="steward:all">All targets ({{ targets | length }})</option>
<option value="">— Enter manually below —</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 — add targets at /ansible/inventory/targets</option>
{% endif %}
</select> </select>
</div> </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>
<div class="form-group"> <div class="form-group">
<label>Extra vars <span style="color:var(--text-muted);font-weight:normal;">(optional, one key=value per line)</span></label> <label>Extra vars <span style="color:var(--text-muted);font-weight:normal;">(optional, one key=value per line)</span></label>
<textarea name="extra_vars" rows="2" placeholder="version=1.2.3&#10;restart=true" <textarea name="extra_vars" rows="2" placeholder="version=1.2.3&#10;restart=true"
@@ -103,10 +122,7 @@
</label> </label>
</div> </div>
<div style="display:flex;gap:0.75rem;align-items:center;"> <div style="display:flex;gap:0.75rem;align-items:center;">
<button type="button" class="btn" <button type="submit" class="btn">Execute</button>
onclick="var manual=document.getElementById('manual-inv-{{ sd.source.name }}').value.trim();
if(manual){this.closest('form').querySelector('[name=inventory_path]').value=manual;}
htmx.trigger(this.closest('form'),'submit');">Execute</button>
<a href="#" onclick="document.getElementById('run-form-{{ sd.source.name }}').style.display='none';return false;" <a href="#" onclick="document.getElementById('run-form-{{ sd.source.name }}').style.display='none';return false;"
class="btn btn-ghost btn-sm">Cancel</a> class="btn btn-ghost btn-sm">Cancel</a>
</div> </div>
@@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block title %}Group: {{ group.name }} — Steward{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
<h1 class="page-title" style="margin-bottom:0;">Group: {{ group.name }}</h1>
<a href="/ansible/inventory/groups" class="btn btn-ghost btn-sm">← Groups</a>
</div>
<form method="post" action="/ansible/inventory/groups/{{ group.id }}">
<div class="card" style="margin-bottom:1.5rem;">
<h3 class="section-title">Settings</h3>
<div class="form-group">
<label>Name</label>
<input type="text" name="name" value="{{ group.name }}" required>
</div>
<div class="form-group">
<label>
Group Variables
<span style="color:var(--text-muted);font-weight:normal;font-size:0.82rem;">
(YAML — applied to all members; per-target vars override these)
</span>
</label>
<textarea name="ansible_vars" rows="8"
style="width:100%;font-family:ui-monospace,monospace;font-size:0.85rem;"
placeholder="ansible_user: deploy&#10;http_port: 80">{{ ansible_vars_yaml }}</textarea>
</div>
</div>
<div class="card" style="margin-bottom:1.5rem;">
<h3 class="section-title">Members</h3>
{% if not all_targets %}
<p style="color:var(--text-muted);font-size:0.9rem;">No targets exist yet.
<a href="/ansible/inventory/targets">Add targets</a> first.</p>
{% else %}
<div style="display:grid;gap:0.4rem;margin-bottom:1rem;">
{% for tgt in all_targets %}
<label style="display:flex;align-items:center;gap:0.6rem;font-weight:normal;cursor:pointer;">
<input type="checkbox" name="target_ids" value="{{ tgt.id }}"
{% if tgt.id in member_ids %}checked{% endif %}>
<span>{{ tgt.name }}</span>
<span style="color:var(--text-muted);font-size:0.82rem;">{{ tgt.address }}</span>
</label>
{% endfor %}
</div>
{% endif %}
</div>
<button type="submit" class="btn">Save Changes</button>
</form>
{% endblock %}
@@ -0,0 +1,48 @@
{% 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 %}
@@ -0,0 +1,53 @@
{% extends "base.html" %}
{% block title %}Target: {{ target.name }} — Steward{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
<h1 class="page-title" style="margin-bottom:0;">Target: {{ target.name }}</h1>
<a href="/ansible/inventory/targets" class="btn btn-ghost btn-sm">← Targets</a>
</div>
<form method="post" action="/ansible/inventory/targets/{{ target.id }}">
<div class="card" style="margin-bottom:1.5rem;">
<h3 class="section-title">Connection</h3>
<div class="form-group">
<label>Name <span style="color:var(--text-muted);font-weight:normal;font-size:0.82rem;">(Ansible hostname — used in plays as <code>hosts: name</code>)</span></label>
<input type="text" name="name" value="{{ target.name }}" required>
</div>
<div class="form-group">
<label>Address <span style="color:var(--text-muted);font-weight:normal;font-size:0.82rem;">(becomes <code>ansible_host</code> in inventory)</span></label>
<input type="text" name="address" value="{{ target.address }}" required>
</div>
<div class="form-group">
<label>
Target Variables
<span style="color:var(--text-muted);font-weight:normal;font-size:0.82rem;">
(YAML — override group vars for this target only)
</span>
</label>
<textarea name="ansible_vars" rows="8"
style="width:100%;font-family:ui-monospace,monospace;font-size:0.85rem;"
placeholder="ansible_user: ubuntu&#10;ansible_port: 22">{{ ansible_vars_yaml }}</textarea>
</div>
</div>
<div class="card" style="margin-bottom:1.5rem;">
<h3 class="section-title">Groups</h3>
{% if not all_groups %}
<p style="color:var(--text-muted);font-size:0.9rem;">No groups exist yet.
<a href="/ansible/inventory/groups">Create a group</a> first.</p>
{% else %}
<div style="display:grid;gap:0.4rem;margin-bottom:1rem;">
{% for grp in all_groups %}
<label style="display:flex;align-items:center;gap:0.6rem;font-weight:normal;cursor:pointer;">
<input type="checkbox" name="group_ids" value="{{ grp.id }}"
{% if grp.id in member_group_ids %}checked{% endif %}>
<span>{{ grp.name }}</span>
</label>
{% endfor %}
</div>
{% endif %}
</div>
<button type="submit" class="btn">Save Changes</button>
</form>
{% endblock %}
@@ -0,0 +1,57 @@
{% 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 %}
+1 -1
View File
@@ -11,7 +11,7 @@
<div style="display:grid;grid-template-columns:auto 1fr;gap:0.4rem 1rem;font-size:0.9rem;margin-bottom:1rem;"> <div style="display:grid;grid-template-columns:auto 1fr;gap:0.4rem 1rem;font-size:0.9rem;margin-bottom:1rem;">
<span style="color:var(--text-muted);">ID</span><span style="color:var(--text-dim);font-family:monospace;">{{ run.id }}</span> <span style="color:var(--text-muted);">ID</span><span style="color:var(--text-dim);font-family:monospace;">{{ run.id }}</span>
<span style="color:var(--text-muted);">Playbook</span><span>{{ run.playbook_path }}</span> <span style="color:var(--text-muted);">Playbook</span><span>{{ run.playbook_path }}</span>
<span style="color:var(--text-muted);">Inventory</span><span>{{ run.inventory_path }}</span> <span style="color:var(--text-muted);">Scope</span><span style="font-family:ui-monospace,monospace;font-size:0.85rem;">{{ run.inventory_scope }}</span>
<span style="color:var(--text-muted);">Source</span><span>{{ run.source_name }}</span> <span style="color:var(--text-muted);">Source</span><span>{{ run.source_name }}</span>
<span style="color:var(--text-muted);">Triggered by</span><span>{{ triggered_label }}</span> <span style="color:var(--text-muted);">Triggered by</span><span>{{ triggered_label }}</span>
<span style="color:var(--text-muted);">Status</span><span style="color:{{ status_color }};font-weight:bold;">{{ run.status.value.upper() }}</span> <span style="color:var(--text-muted);">Status</span><span style="color:{{ status_color }};font-weight:bold;">{{ run.status.value.upper() }}</span>
+47
View File
@@ -88,4 +88,51 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% if host %}
<div class="card" style="margin-top:1.5rem;max-width:560px;margin-left:auto;margin-right:auto;">
<h3 class="section-title">Ansible Target</h3>
{% if linked_target %}
<div style="display:flex;align-items:center;gap:1rem;padding:0.75rem;background:var(--bg-elevated);border-radius:4px;margin-bottom:1rem;">
<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 one from this host.
</p>
<div style="display:flex;gap:0.75rem;flex-wrap:wrap;align-items:flex-end;">
{% if linkable_targets %}
<form method="post" action="/hosts/{{ host.id }}/ansible-link" style="display:flex;gap:0.5rem;align-items:center;flex:1;">
<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 %}
{% endblock %} {% endblock %}
@@ -94,6 +94,15 @@
<label style="font-size:0.8rem;">Pull interval (seconds)</label> <label style="font-size:0.8rem;">Pull interval (seconds)</label>
<input type="number" name="pull_interval_seconds" value="{{ src.pull_interval_seconds or 3600 }}" min="60"> <input type="number" name="pull_interval_seconds" value="{{ src.pull_interval_seconds or 3600 }}" min="60">
</div> </div>
<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>
</div> </div>
<div id="ansible-edit-local-fields-{{ idx }}" <div id="ansible-edit-local-fields-{{ idx }}"
@@ -165,6 +174,12 @@
<label style="font-size:0.8rem;">Pull interval (seconds)</label> <label style="font-size:0.8rem;">Pull interval (seconds)</label>
<input type="number" name="pull_interval_seconds" value="3600" min="60"> <input type="number" name="pull_interval_seconds" value="3600" min="60">
</div> </div>
<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>
</div> </div>
<div id="ansible-add-local-fields" <div id="ansible-add-local-fields"
+142
View File
@@ -0,0 +1,142 @@
"""Unit tests for GIT_ASKPASS injection in git_pull()."""
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"]
# Script should be cleaned up after the call
assert not os.path.exists(askpass_path), "askpass script should be deleted in finally"
@pytest.mark.asyncio
async def test_git_pull_askpass_contains_token(tmp_path):
"""The askpass script content includes the token before cleanup."""
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": "secret-token",
}
script_content_captured = {}
async def fake_subprocess(*args, **kwargs):
askpass = kwargs.get("env", {}).get("GIT_ASKPASS", "")
if askpass and os.path.exists(askpass):
script_content_captured["content"] = Path(askpass).read_text()
mode = stat.S_IMODE(os.stat(askpass).st_mode)
script_content_captured["executable"] = bool(mode & stat.S_IXUSR)
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 "secret-token" in script_content_captured.get("content", "")
assert "oauth2" in script_content_captured.get("content", "")
assert script_content_captured.get("executable") is True
@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") or {})
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
+93
View File
@@ -0,0 +1,93 @@
"""Unit tests for generate_inventory().
Uses SimpleNamespace duck-typed objects so no DB or SQLAlchemy is required.
"""
from types import SimpleNamespace
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"
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"
@@ -0,0 +1,41 @@
"""Pure-function tests for the default-enabled plugin set.
A fresh install (no stored plugin.* rows) should come up with the generic,
non-vendor-specific bundled plugins already enabled, while vendor-specific
plugins stay opt-in. An operator's stored choice must override the default.
No DB / Quart fixtures — we exercise to_plugins_cfg over the DEFAULTS dict
and a DEFAULTS-merged-with-stored dict, mirroring get_all_settings' merge.
"""
from steward.core import settings as settings_module
from steward.core.settings import DEFAULTS, to_plugins_cfg
DEFAULT_ON = {"docker", "host_agent", "http", "snmp"}
VENDOR_OPT_IN = {"traefik", "unifi"}
def test_generic_plugins_enabled_by_default():
cfg = to_plugins_cfg(DEFAULTS)
for name in DEFAULT_ON:
assert cfg.get(name, {}).get("enabled") is True, name
def test_vendor_plugins_not_enabled_by_default():
cfg = to_plugins_cfg(DEFAULTS)
for name in VENDOR_OPT_IN:
assert name not in cfg, name
def test_stored_choice_overrides_default():
# get_all_settings / load_settings_sync overlay stored values onto DEFAULTS;
# a stored disable must win over the built-in default-on.
merged = {**DEFAULTS, "plugin.docker": {"enabled": False}}
cfg = to_plugins_cfg(merged)
assert cfg["docker"]["enabled"] is False
# untouched defaults remain enabled
assert cfg["http"]["enabled"] is True
def test_defaults_use_plugin_dot_namespace():
# The keys must live under the plugin.<name> namespace to_plugins_cfg reads.
for name in DEFAULT_ON:
assert f"plugin.{name}" in settings_module.DEFAULTS
+178
View File
@@ -0,0 +1,178 @@
"""Integration tests: ansible_targets / ansible_groups tables + fetch_scope_targets.
Requires a live Postgres DB (STEWARD_DATABASE_URL).
Run with:
pytest tests/integration/test_ansible_inventory.py -m integration -v
"""
from __future__ import annotations
import os
import uuid
import pytest
from sqlalchemy import select, text
pytestmark = pytest.mark.integration
_NEEDS_DB = pytest.mark.skipif(
not os.environ.get("STEWARD_DATABASE_URL"),
reason="integration test needs a live Postgres (STEWARD_DATABASE_URL)",
)
def _make_app():
from steward.app import create_app
import asyncio
return asyncio.run(create_app(testing=False))
@_NEEDS_DB
def test_ansible_targets_table_exists():
"""Migration 0015 created the ansible_targets table."""
app = _make_app()
async def _check():
async with app.db_sessionmaker() as db:
result = await db.execute(
text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'ansible_targets'"
)
)
return {row[0] for row in result.fetchall()}
import asyncio
columns = asyncio.run(_check())
assert "id" in columns
assert "name" in columns
assert "address" in columns
assert "ansible_vars" in columns
assert "host_id" in columns
@_NEEDS_DB
def test_ansible_groups_table_exists():
"""Migration 0016 created ansible_groups and ansible_target_groups."""
app = _make_app()
async def _check():
async with app.db_sessionmaker() as db:
groups_result = await db.execute(
text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'ansible_groups'"
)
)
groups_cols = {row[0] for row in groups_result.fetchall()}
join_result = await db.execute(
text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'ansible_target_groups'"
)
)
join_cols = {row[0] for row in join_result.fetchall()}
return groups_cols, join_cols
import asyncio
groups_cols, join_cols = asyncio.run(_check())
assert "id" in groups_cols
assert "name" in groups_cols
assert "ansible_vars" in groups_cols
assert "target_id" in join_cols
assert "group_id" in join_cols
@_NEEDS_DB
def test_ansible_run_scope_column_exists():
"""Migration 0017 added inventory_scope to ansible_runs."""
app = _make_app()
async def _check():
async with app.db_sessionmaker() as db:
result = await db.execute(
text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'ansible_runs'"
)
)
return {row[0] for row in result.fetchall()}
import asyncio
columns = asyncio.run(_check())
assert "inventory_scope" in columns
assert "inventory_path" in columns
@_NEEDS_DB
def test_create_target_and_group_and_fetch():
"""Can create a target+group, assign membership, and fetch via fetch_scope_targets."""
from steward.models.ansible_inventory import AnsibleTarget, AnsibleGroup
from steward.ansible.inventory_gen import fetch_scope_targets, generate_inventory
app = _make_app()
target_id = str(uuid.uuid4())
group_id = str(uuid.uuid4())
target_name = f"test-target-{target_id[:8]}"
group_name = f"test-group-{group_id[:8]}"
async def _run():
# Create
async with app.db_sessionmaker() as db:
async with db.begin():
grp = AnsibleGroup(
id=group_id,
name=group_name,
ansible_vars={"env": "test"},
)
tgt = AnsibleTarget(
id=target_id,
name=target_name,
address="10.0.99.1",
ansible_vars={"ansible_user": "ubuntu"},
)
tgt.groups.append(grp)
db.add(grp)
db.add(tgt)
# Fetch all — target must be in the list
async with app.db_sessionmaker() as db:
all_targets = await fetch_scope_targets(db, "steward:all")
assert any(t.id == target_id for t in all_targets)
# 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 and verify var merging
async with app.db_sessionmaker() as db:
targets = await fetch_scope_targets(db, f"steward:group:{group_id}")
inv = generate_inventory(targets)
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():
t = (await db.execute(
select(AnsibleTarget).where(AnsibleTarget.id == target_id)
)).scalar_one_or_none()
if t:
await db.delete(t)
g = (await db.execute(
select(AnsibleGroup).where(AnsibleGroup.id == group_id)
)).scalar_one_or_none()
if g:
await db.delete(g)
import asyncio
asyncio.run(_run())