Compare commits
13 Commits
06dc5073ca
...
b49496b57b
| Author | SHA1 | Date | |
|---|---|---|---|
| b49496b57b | |||
| 80613d6310 | |||
| 923905e72d | |||
| dd0acaf623 | |||
| 37d9ca8a8a | |||
| 935cbc105c | |||
| 2c991eabd2 | |||
| d906232eb2 | |||
| ea47169049 | |||
| 10df05c13e | |||
| fa32488fdf | |||
| c11b3f01ed | |||
| eb319d715e |
File diff suppressed because it is too large
Load Diff
@@ -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())
|
||||||
@@ -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"))
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 restart=true"
|
<textarea name="extra_vars" rows="2" placeholder="version=1.2.3 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 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 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 %}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user