88857be24e
Closes #550 (all four): - Cancellation: track live subprocesses; POST /ansible/runs/<id>/cancel (operator) SIGTERMs then SIGKILLs after a grace; new 'cancelled' status (+ migration 0019, ALTER TYPE in autocommit). Queued runs cancel cleanly before launch. Cancel button on run detail. - Concurrency: global semaphore (ansible.max_concurrent_runs, default 3, Settings→Ansible) caps simultaneous runs; excess show 'queued' (new status) until a slot frees. Semaphore bound lazily per running loop. - Structured results: parse PLAY RECAP into per-host ok/changed/unreachable/ failed/skipped + capture failed-task lines, stored in new results JSON column (migration 0020); rendered as a host-summary table on run detail. Keeps live streaming (no json-callback swap). - Retention: full output written to a persistent log artifact (/data/ansible/runs/<id>.log, env-overridable) beyond the 1 MB DB cap and across restarts; in-memory replay buffer bounded + GC'd after completion; Download-log route. Boot reconciliation now also sweeps stale 'queued'. Unit tests for recap parsing + cancel flagging. Status colors updated across run list / detail / schedules. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
725 lines
30 KiB
Python
725 lines
30 KiB
Python
# steward/settings/routes.py
|
|
from __future__ import annotations
|
|
import logging
|
|
from pathlib import Path
|
|
from quart import Blueprint, current_app, render_template, request, redirect, url_for, session
|
|
|
|
from steward.auth.middleware import require_role
|
|
from steward.core.audit import log_audit
|
|
from steward.models.users import UserRole
|
|
from steward.core.settings import (
|
|
get_all_settings, set_setting,
|
|
to_smtp_cfg, to_webhook_cfg, to_ansible_cfg, to_plugins_cfg,
|
|
to_oidc_cfg, to_ldap_cfg,
|
|
)
|
|
|
|
settings_bp = Blueprint("settings", __name__, url_prefix="/settings")
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _discover_plugins() -> list[dict]:
|
|
"""Scan every plugin root for plugin.yaml files.
|
|
|
|
Roots are scanned in order (bundled first), and a plugin name found in an
|
|
earlier root shadows the same name in a later one — matching load_plugins.
|
|
"""
|
|
import yaml
|
|
plugin_dirs = [
|
|
Path(d).resolve()
|
|
for d in current_app.config.get("PLUGIN_DIRS", ["plugins"])
|
|
]
|
|
plugins: list[dict] = []
|
|
seen: set[str] = set()
|
|
for plugin_dir in plugin_dirs:
|
|
if not plugin_dir.exists():
|
|
continue
|
|
for entry in sorted(plugin_dir.iterdir()):
|
|
yaml_path = entry / "plugin.yaml"
|
|
if entry.is_dir() and yaml_path.exists() and entry.name not in seen:
|
|
try:
|
|
with yaml_path.open() as f:
|
|
meta = yaml.safe_load(f) or {}
|
|
meta["_dir"] = entry.name
|
|
plugins.append(meta)
|
|
seen.add(entry.name)
|
|
except Exception:
|
|
logger.warning("Could not read %s", yaml_path)
|
|
return plugins
|
|
|
|
|
|
def _merge_plugin_config(discovered: list[dict], plugins_cfg: dict) -> None:
|
|
"""Merge stored settings into discovered plugin dicts in-place."""
|
|
for plugin in discovered:
|
|
name = plugin["_dir"]
|
|
stored = plugins_cfg.get(name, {})
|
|
plugin["_enabled"] = stored.get("enabled", False)
|
|
defaults = plugin.get("config", {})
|
|
plugin["_config"] = {**defaults, **{k: v for k, v in stored.items() if k != "enabled"}}
|
|
|
|
|
|
async def _reload_app_config() -> None:
|
|
"""Reload app.config from DB so settings take effect without restart."""
|
|
async with current_app.db_sessionmaker() as db:
|
|
fresh = await get_all_settings(db)
|
|
current_app.config.update(
|
|
SESSION_LIFETIME_HOURS=fresh.get("session.lifetime_hours", 8),
|
|
DATA_RETENTION_DAYS=fresh.get("data.retention_days", 90),
|
|
MONITORS_POLL_INTERVAL=fresh.get("monitors.poll_interval_seconds", 60),
|
|
PUBLIC_BASE_URL=fresh.get("general.public_base_url", ""),
|
|
SMTP=to_smtp_cfg(fresh),
|
|
WEBHOOK=to_webhook_cfg(fresh),
|
|
ANSIBLE=to_ansible_cfg(fresh),
|
|
PLUGINS=to_plugins_cfg(fresh),
|
|
OIDC=to_oidc_cfg(fresh),
|
|
LDAP=to_ldap_cfg(fresh),
|
|
)
|
|
|
|
|
|
# ── Redirect root to general tab ──────────────────────────────────────────────
|
|
|
|
@settings_bp.get("/")
|
|
@require_role(UserRole.admin)
|
|
async def index():
|
|
return redirect(url_for("settings.general"))
|
|
|
|
|
|
# ── General ───────────────────────────────────────────────────────────────────
|
|
|
|
@settings_bp.get("/general/")
|
|
@require_role(UserRole.admin)
|
|
async def general():
|
|
async with current_app.db_sessionmaker() as db:
|
|
settings = await get_all_settings(db)
|
|
return await render_template("settings/general.html", settings=settings)
|
|
|
|
|
|
@settings_bp.post("/general/")
|
|
@require_role(UserRole.admin)
|
|
async def save_general():
|
|
form = await request.form
|
|
async with current_app.db_sessionmaker() as db:
|
|
async with db.begin():
|
|
await set_setting(db, "general.public_base_url",
|
|
(form.get("general.public_base_url", "") or "").strip())
|
|
await set_setting(db, "session.lifetime_hours",
|
|
int(form.get("session.lifetime_hours", 8)))
|
|
await set_setting(db, "data.retention_days",
|
|
int(form.get("data.retention_days", 90)))
|
|
await set_setting(db, "monitors.poll_interval_seconds",
|
|
int(form.get("monitors.poll_interval_seconds", 60)))
|
|
await _reload_app_config()
|
|
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
|
|
"settings.saved", detail={"section": "general"})
|
|
return redirect(url_for("settings.general"))
|
|
|
|
|
|
# ── Notifications (SMTP + Webhook) ────────────────────────────────────────────
|
|
|
|
@settings_bp.get("/notifications/")
|
|
@require_role(UserRole.admin)
|
|
async def notifications():
|
|
async with current_app.db_sessionmaker() as db:
|
|
settings = await get_all_settings(db)
|
|
return await render_template("settings/notifications.html", settings=settings)
|
|
|
|
|
|
@settings_bp.post("/notifications/")
|
|
@require_role(UserRole.admin)
|
|
async def save_notifications():
|
|
form = await request.form
|
|
async with current_app.db_sessionmaker() as db:
|
|
async with db.begin():
|
|
recipients_raw = form.get("smtp.recipients", "")
|
|
recipients = [r.strip() for r in recipients_raw.split(",") if r.strip()]
|
|
await set_setting(db, "smtp.host", form.get("smtp.host", ""))
|
|
await set_setting(db, "smtp.port", int(form.get("smtp.port", 587)))
|
|
await set_setting(db, "smtp.tls", "smtp.tls" in form)
|
|
await set_setting(db, "smtp.username", form.get("smtp.username", ""))
|
|
if form.get("smtp.password"):
|
|
await set_setting(db, "smtp.password", form.get("smtp.password"))
|
|
await set_setting(db, "smtp.recipients", recipients)
|
|
await set_setting(db, "webhook.url", form.get("webhook.url", ""))
|
|
await set_setting(db, "webhook.template", form.get("webhook.template", ""))
|
|
await _reload_app_config()
|
|
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
|
|
"settings.saved", detail={"section": "notifications"})
|
|
return redirect(url_for("settings.notifications"))
|
|
|
|
|
|
# ── Ansible Sources ───────────────────────────────────────────────────────────
|
|
|
|
async def _get_ansible_sources() -> list[dict]:
|
|
async with current_app.db_sessionmaker() as db:
|
|
settings = await get_all_settings(db)
|
|
sources = settings.get("ansible.sources", [])
|
|
return sources if isinstance(sources, list) else []
|
|
|
|
|
|
async def _save_ansible_sources(sources: list[dict]) -> None:
|
|
async with current_app.db_sessionmaker() as db:
|
|
async with db.begin():
|
|
await set_setting(db, "ansible.sources", sources)
|
|
await _reload_app_config()
|
|
|
|
|
|
async def _ansible_sources_partial(sources: list[dict]):
|
|
return await render_template("settings/_ansible_sources.html", sources=sources)
|
|
|
|
|
|
@settings_bp.get("/ansible/")
|
|
@require_role(UserRole.admin)
|
|
async def ansible():
|
|
sources = await _get_ansible_sources()
|
|
async with current_app.db_sessionmaker() as db:
|
|
settings = await get_all_settings(db)
|
|
return await render_template(
|
|
"settings/ansible.html",
|
|
sources=sources,
|
|
ssh_key_set=bool(settings.get("ansible.ssh_private_key")),
|
|
become_set=bool(settings.get("ansible.become_password")),
|
|
vault_set=bool(settings.get("ansible.vault_password")),
|
|
host_key_checking=settings.get("ansible.host_key_checking", False),
|
|
max_concurrent_runs=settings.get("ansible.max_concurrent_runs", 3),
|
|
)
|
|
|
|
|
|
@settings_bp.post("/ansible/credentials")
|
|
@require_role(UserRole.admin)
|
|
async def ansible_save_credentials():
|
|
"""Save global Ansible credentials (masked-update: blank keeps the current value)."""
|
|
form = await request.form
|
|
async with current_app.db_sessionmaker() as db:
|
|
async with db.begin():
|
|
for field, key in (
|
|
("ssh_private_key", "ansible.ssh_private_key"),
|
|
("become_password", "ansible.become_password"),
|
|
("vault_password", "ansible.vault_password"),
|
|
):
|
|
if f"clear_{field}" in form:
|
|
await set_setting(db, key, "")
|
|
continue
|
|
raw = form.get(field, "") or ""
|
|
# Preserve PEM whitespace for the key; trim passwords.
|
|
val = raw.strip("\n") if field == "ssh_private_key" else raw.strip()
|
|
if val:
|
|
await set_setting(db, key, val)
|
|
await set_setting(db, "ansible.host_key_checking", "host_key_checking" in form)
|
|
try:
|
|
mcr = max(1, min(50, int(form.get("max_concurrent_runs", 3))))
|
|
except (TypeError, ValueError):
|
|
mcr = 3
|
|
await set_setting(db, "ansible.max_concurrent_runs", mcr)
|
|
await _reload_app_config()
|
|
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
|
|
"settings.saved", detail={"section": "ansible.credentials"})
|
|
return redirect(url_for("settings.ansible"))
|
|
|
|
|
|
@settings_bp.post("/ansible/sources/add")
|
|
@require_role(UserRole.admin)
|
|
async def ansible_add_source():
|
|
form = await request.form
|
|
name = form.get("name", "").strip()
|
|
src_type = form.get("type", "local").strip()
|
|
if not name:
|
|
sources = await _get_ansible_sources()
|
|
return await _ansible_sources_partial(sources)
|
|
entry: dict = {"name": name, "type": src_type}
|
|
if src_type == "git":
|
|
entry["url"] = form.get("url", "").strip()
|
|
entry["branch"] = form.get("branch", "main").strip() or "main"
|
|
try:
|
|
entry["pull_interval_seconds"] = int(form.get("pull_interval_seconds", 3600))
|
|
except ValueError:
|
|
entry["pull_interval_seconds"] = 3600
|
|
token = form.get("http_token", "").strip()
|
|
if token:
|
|
entry["http_token"] = token
|
|
else:
|
|
entry["path"] = form.get("path", "").strip()
|
|
sources = await _get_ansible_sources()
|
|
sources.append(entry)
|
|
await _save_ansible_sources(sources)
|
|
return await _ansible_sources_partial(sources)
|
|
|
|
|
|
@settings_bp.post("/ansible/sources/<int:idx>/remove")
|
|
@require_role(UserRole.admin)
|
|
async def ansible_remove_source(idx: int):
|
|
sources = await _get_ansible_sources()
|
|
if 0 <= idx < len(sources):
|
|
sources.pop(idx)
|
|
await _save_ansible_sources(sources)
|
|
return await _ansible_sources_partial(sources)
|
|
|
|
|
|
@settings_bp.post("/ansible/sources/<int:idx>/save")
|
|
@require_role(UserRole.admin)
|
|
async def ansible_save_source(idx: int):
|
|
sources = await _get_ansible_sources()
|
|
if not (0 <= idx < len(sources)):
|
|
return await _ansible_sources_partial(sources)
|
|
form = await request.form
|
|
src_type = form.get("type", "local").strip()
|
|
entry: dict = {
|
|
"name": form.get("name", sources[idx].get("name", "")).strip(),
|
|
"type": src_type,
|
|
}
|
|
if src_type == "git":
|
|
entry["url"] = form.get("url", "").strip()
|
|
entry["branch"] = form.get("branch", "main").strip() or "main"
|
|
try:
|
|
entry["pull_interval_seconds"] = int(form.get("pull_interval_seconds", 3600))
|
|
except ValueError:
|
|
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:
|
|
entry["path"] = form.get("path", "").strip()
|
|
sources[idx] = entry
|
|
await _save_ansible_sources(sources)
|
|
return await _ansible_sources_partial(sources)
|
|
|
|
|
|
@settings_bp.post("/ansible/sources/<int:idx>/sync")
|
|
@require_role(UserRole.admin)
|
|
async def ansible_sync_source(idx: int):
|
|
"""Trigger a git pull for a git source and return a status badge."""
|
|
sources = await _get_ansible_sources()
|
|
if not (0 <= idx < len(sources)):
|
|
return ('<span style="color:var(--red);font-size:0.8rem;">Source not found</span>', 404)
|
|
source = sources[idx]
|
|
if source.get("type") != "git":
|
|
return '<span style="color:var(--text-muted);font-size:0.8rem;">Not a git source</span>'
|
|
from steward.ansible.sources import git_pull
|
|
from steward.core.settings import to_ansible_cfg
|
|
async with current_app.db_sessionmaker() as db:
|
|
settings = await get_all_settings(db)
|
|
ansible_cfg = to_ansible_cfg(settings)
|
|
from steward.ansible.sources import get_sources
|
|
resolved = next((s for s in get_sources(ansible_cfg) if s["name"] == source["name"]), None)
|
|
if resolved is None:
|
|
return '<span style="color:var(--red);font-size:0.8rem;">Could not resolve source path</span>'
|
|
try:
|
|
await git_pull(resolved)
|
|
return '<span style="color:var(--green);font-size:0.8rem;">Synced</span>'
|
|
except Exception as exc:
|
|
logger.exception("git pull failed for source %r", source["name"])
|
|
return f'<span style="color:var(--red);font-size:0.8rem;">Sync failed: {exc}</span>'
|
|
|
|
|
|
# ── Auth (OIDC / LDAP) ────────────────────────────────────────────────────────
|
|
|
|
@settings_bp.get("/auth/")
|
|
@require_role(UserRole.admin)
|
|
async def auth_settings():
|
|
async with current_app.db_sessionmaker() as db:
|
|
settings = await get_all_settings(db)
|
|
return await render_template("settings/auth.html", settings=settings)
|
|
|
|
|
|
@settings_bp.post("/auth/")
|
|
@require_role(UserRole.admin)
|
|
async def save_auth_settings():
|
|
form = await request.form
|
|
async with current_app.db_sessionmaker() as db:
|
|
async with db.begin():
|
|
# OIDC
|
|
await set_setting(db, "oidc.enabled", "oidc.enabled" in form)
|
|
await set_setting(db, "oidc.discovery_url", form.get("oidc.discovery_url", "").strip())
|
|
await set_setting(db, "oidc.client_id", form.get("oidc.client_id", "").strip())
|
|
if form.get("oidc.client_secret"):
|
|
await set_setting(db, "oidc.client_secret", form.get("oidc.client_secret").strip())
|
|
await set_setting(db, "oidc.scopes", form.get("oidc.scopes", "openid profile email").strip())
|
|
await set_setting(db, "oidc.username_claim", form.get("oidc.username_claim", "preferred_username").strip())
|
|
await set_setting(db, "oidc.email_claim", form.get("oidc.email_claim", "email").strip())
|
|
await set_setting(db, "oidc.groups_claim", form.get("oidc.groups_claim", "groups").strip())
|
|
await set_setting(db, "oidc.admin_group", form.get("oidc.admin_group", "").strip())
|
|
await set_setting(db, "oidc.operator_group", form.get("oidc.operator_group", "").strip())
|
|
# LDAP
|
|
await set_setting(db, "ldap.enabled", "ldap.enabled" in form)
|
|
await set_setting(db, "ldap.host", form.get("ldap.host", "").strip())
|
|
await set_setting(db, "ldap.port", int(form.get("ldap.port") or 389))
|
|
await set_setting(db, "ldap.tls", "ldap.tls" in form)
|
|
await set_setting(db, "ldap.bind_dn", form.get("ldap.bind_dn", "").strip())
|
|
if form.get("ldap.bind_password"):
|
|
await set_setting(db, "ldap.bind_password", form.get("ldap.bind_password").strip())
|
|
await set_setting(db, "ldap.base_dn", form.get("ldap.base_dn", "").strip())
|
|
await set_setting(db, "ldap.user_filter", form.get("ldap.user_filter", "(uid={username})").strip())
|
|
await set_setting(db, "ldap.admin_group_dn", form.get("ldap.admin_group_dn", "").strip())
|
|
await set_setting(db, "ldap.operator_group_dn", form.get("ldap.operator_group_dn", "").strip())
|
|
await set_setting(db, "ldap.attr_username", form.get("ldap.attr_username", "uid").strip())
|
|
await set_setting(db, "ldap.attr_email", form.get("ldap.attr_email", "mail").strip())
|
|
await _reload_app_config()
|
|
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
|
|
"settings.saved", detail={"section": "auth"})
|
|
# Clear OIDC discovery cache so new settings take effect
|
|
from steward.auth.oidc import _discovery_cache
|
|
_discovery_cache.clear()
|
|
return redirect(url_for("settings.auth_settings"))
|
|
|
|
|
|
# ── Reports ───────────────────────────────────────────────────────────────────
|
|
|
|
@settings_bp.get("/reports/")
|
|
@require_role(UserRole.admin)
|
|
async def reports():
|
|
async with current_app.db_sessionmaker() as db:
|
|
settings = await get_all_settings(db)
|
|
return await render_template("settings/reports.html", settings=settings, flash=None)
|
|
|
|
|
|
@settings_bp.post("/reports/")
|
|
@require_role(UserRole.admin)
|
|
async def save_reports():
|
|
form = await request.form
|
|
async with current_app.db_sessionmaker() as db:
|
|
async with db.begin():
|
|
await set_setting(db, "reports.enabled", "reports.enabled" in form)
|
|
await set_setting(db, "reports.schedule_day",
|
|
int(form.get("reports.schedule_day", 6)))
|
|
await set_setting(db, "reports.schedule_hour",
|
|
int(form.get("reports.schedule_hour", 8)))
|
|
return redirect(url_for("settings.reports"))
|
|
|
|
|
|
@settings_bp.post("/reports/send-now/")
|
|
@require_role(UserRole.admin)
|
|
async def send_report_now():
|
|
from steward.core.reports import send_report
|
|
async with current_app.db_sessionmaker() as db:
|
|
settings = await get_all_settings(db)
|
|
ok, message = await send_report(current_app._get_current_object())
|
|
return await render_template(
|
|
"settings/reports.html",
|
|
settings=settings,
|
|
flash={"ok": ok, "message": message},
|
|
)
|
|
|
|
|
|
# ── Plugin repository helpers ─────────────────────────────────────────────────
|
|
|
|
def _get_plugin_repos(settings: dict) -> list[dict]:
|
|
"""Return the list of plugin repositories from settings.
|
|
|
|
Migrates the legacy plugins.index_url single-URL field to the list format
|
|
on first access if plugins.repositories is not yet set.
|
|
"""
|
|
repos = settings.get("plugins.repositories")
|
|
if repos and isinstance(repos, list):
|
|
return repos
|
|
# Migrate legacy single URL
|
|
legacy = settings.get("plugins.index_url", "").strip()
|
|
if legacy:
|
|
return [{"name": "Default", "url": legacy}]
|
|
return []
|
|
|
|
|
|
async def _save_plugin_repos(db, repos: list[dict]) -> None:
|
|
await set_setting(db, "plugins.repositories", repos)
|
|
|
|
|
|
async def _plugin_repos_partial(settings: dict):
|
|
repos = _get_plugin_repos(settings)
|
|
return await render_template("settings/_plugin_repos.html", repos=repos)
|
|
|
|
|
|
def _build_plugin_cfg_from_form(plugin: dict, form) -> dict:
|
|
"""Extract config values for one plugin from a form submission."""
|
|
name = plugin["_dir"]
|
|
enabled = f"plugin.{name}.enabled" in form
|
|
plugin_cfg: dict = {"enabled": enabled}
|
|
for cfg_key in plugin.get("config", {}).keys():
|
|
field_name = f"plugin.{name}.{cfg_key}"
|
|
default_val = plugin["config"][cfg_key]
|
|
if isinstance(default_val, list):
|
|
pass # list configs must be set in plugin.yaml
|
|
elif isinstance(default_val, dict):
|
|
sub_dict = {}
|
|
for sub_key, sub_default in default_val.items():
|
|
sub_field = f"plugin.{name}.{cfg_key}.{sub_key}"
|
|
if isinstance(sub_default, bool):
|
|
sub_dict[sub_key] = sub_field in form
|
|
elif sub_field in form:
|
|
raw_sub = form[sub_field]
|
|
if isinstance(sub_default, int):
|
|
try:
|
|
sub_dict[sub_key] = int(raw_sub)
|
|
except ValueError:
|
|
sub_dict[sub_key] = sub_default
|
|
else:
|
|
sub_dict[sub_key] = raw_sub
|
|
else:
|
|
sub_dict[sub_key] = sub_default
|
|
plugin_cfg[cfg_key] = sub_dict
|
|
elif field_name in form:
|
|
raw_val = form[field_name]
|
|
if isinstance(default_val, bool):
|
|
plugin_cfg[cfg_key] = raw_val.lower() in ("1", "true", "yes", "on")
|
|
elif isinstance(default_val, int):
|
|
try:
|
|
plugin_cfg[cfg_key] = int(raw_val)
|
|
except ValueError:
|
|
plugin_cfg[cfg_key] = default_val
|
|
else:
|
|
plugin_cfg[cfg_key] = raw_val
|
|
return plugin_cfg
|
|
|
|
|
|
# ── Plugins list ──────────────────────────────────────────────────────────────
|
|
|
|
@settings_bp.get("/plugins/")
|
|
@require_role(UserRole.admin)
|
|
async def plugins():
|
|
async with current_app.db_sessionmaker() as db:
|
|
settings = await get_all_settings(db)
|
|
discovered = _discover_plugins()
|
|
_merge_plugin_config(discovered, to_plugins_cfg(settings))
|
|
repos = _get_plugin_repos(settings)
|
|
return await render_template(
|
|
"settings/plugins.html",
|
|
discovered_plugins=discovered,
|
|
repos=repos,
|
|
settings=settings,
|
|
)
|
|
|
|
|
|
# ── Per-plugin detail (settings) ──────────────────────────────────────────────
|
|
|
|
@settings_bp.get("/plugins/<name>/")
|
|
@require_role(UserRole.admin)
|
|
async def plugin_detail(name: str):
|
|
async with current_app.db_sessionmaker() as db:
|
|
settings = await get_all_settings(db)
|
|
discovered = _discover_plugins()
|
|
plugin = next((p for p in discovered if p["_dir"] == name), None)
|
|
if plugin is None:
|
|
return redirect(url_for("settings.plugins"))
|
|
_merge_plugin_config([plugin], to_plugins_cfg(settings))
|
|
from steward.core.plugin_manager import _LOADED_PLUGINS, get_plugin_failures
|
|
return await render_template(
|
|
"settings/plugin_detail.html",
|
|
plugin=plugin,
|
|
is_loaded=name in _LOADED_PLUGINS,
|
|
fail_reason=get_plugin_failures().get(name),
|
|
)
|
|
|
|
|
|
@settings_bp.post("/plugins/<name>/")
|
|
@require_role(UserRole.admin)
|
|
async def save_plugin_detail(name: str):
|
|
form = await request.form
|
|
discovered = _discover_plugins()
|
|
plugin = next((p for p in discovered if p["_dir"] == name), None)
|
|
if plugin is None:
|
|
return redirect(url_for("settings.plugins"))
|
|
|
|
async with current_app.db_sessionmaker() as db:
|
|
old_settings = await get_all_settings(db)
|
|
old_enabled = to_plugins_cfg(old_settings).get(name, {}).get("enabled", False)
|
|
|
|
plugin_cfg = _build_plugin_cfg_from_form(plugin, form)
|
|
|
|
async with current_app.db_sessionmaker() as db:
|
|
async with db.begin():
|
|
await set_setting(db, f"plugin.{name}", plugin_cfg)
|
|
|
|
await _reload_app_config()
|
|
from steward.core.plugin_index import clear_catalog_cache
|
|
clear_catalog_cache()
|
|
|
|
new_enabled = plugin_cfg.get("enabled", False)
|
|
if old_enabled != new_enabled:
|
|
action = "plugin.enabled" if new_enabled else "plugin.disabled"
|
|
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
|
|
action, entity_type="plugin", entity_id=name)
|
|
if new_enabled and not old_enabled:
|
|
from steward.core.plugin_manager import hot_reload_plugin
|
|
hot_reload_plugin(current_app._get_current_object(), name)
|
|
|
|
return redirect(url_for("settings.plugin_detail", name=name))
|
|
|
|
|
|
# ── Plugin repository HTMX routes ─────────────────────────────────────────────
|
|
|
|
@settings_bp.post("/plugins/repos/add")
|
|
@require_role(UserRole.admin)
|
|
async def plugin_repos_add():
|
|
form = await request.form
|
|
async with current_app.db_sessionmaker() as db:
|
|
settings = await get_all_settings(db)
|
|
repos = _get_plugin_repos(settings)
|
|
url = form.get("url", "").strip()
|
|
repo_name = form.get("name", "").strip() or url
|
|
if url:
|
|
repos.append({"name": repo_name, "url": url})
|
|
async with current_app.db_sessionmaker() as db:
|
|
async with db.begin():
|
|
await _save_plugin_repos(db, repos)
|
|
from steward.core.plugin_index import clear_catalog_cache
|
|
clear_catalog_cache()
|
|
return await _plugin_repos_partial({"plugins.repositories": repos})
|
|
|
|
|
|
@settings_bp.post("/plugins/repos/<int:idx>/remove")
|
|
@require_role(UserRole.admin)
|
|
async def plugin_repos_remove(idx: int):
|
|
async with current_app.db_sessionmaker() as db:
|
|
settings = await get_all_settings(db)
|
|
repos = _get_plugin_repos(settings)
|
|
if 0 <= idx < len(repos):
|
|
repos.pop(idx)
|
|
async with current_app.db_sessionmaker() as db:
|
|
async with db.begin():
|
|
await _save_plugin_repos(db, repos)
|
|
from steward.core.plugin_index import clear_catalog_cache
|
|
clear_catalog_cache()
|
|
return await _plugin_repos_partial({"plugins.repositories": repos})
|
|
|
|
|
|
@settings_bp.post("/plugins/repos/<int:idx>/save")
|
|
@require_role(UserRole.admin)
|
|
async def plugin_repos_save(idx: int):
|
|
form = await request.form
|
|
async with current_app.db_sessionmaker() as db:
|
|
settings = await get_all_settings(db)
|
|
repos = _get_plugin_repos(settings)
|
|
if 0 <= idx < len(repos):
|
|
repos[idx] = {
|
|
"name": form.get("name", "").strip() or repos[idx]["name"],
|
|
"url": form.get("url", "").strip() or repos[idx]["url"],
|
|
}
|
|
async with current_app.db_sessionmaker() as db:
|
|
async with db.begin():
|
|
await _save_plugin_repos(db, repos)
|
|
from steward.core.plugin_index import clear_catalog_cache
|
|
clear_catalog_cache()
|
|
return await _plugin_repos_partial({"plugins.repositories": repos})
|
|
|
|
|
|
# ── Plugin catalog (HTMX partial) ─────────────────────────────────────────────
|
|
|
|
@settings_bp.get("/plugins/catalog/")
|
|
@require_role(UserRole.admin)
|
|
async def plugins_catalog():
|
|
"""HTMX partial: list plugins available in the remote catalog."""
|
|
async with current_app.db_sessionmaker() as db:
|
|
settings = await get_all_settings(db)
|
|
repos = _get_plugin_repos(settings)
|
|
repo_urls = [r["url"] for r in repos if r.get("url")]
|
|
|
|
if not repo_urls:
|
|
return await render_template(
|
|
"settings/_catalog_partial.html",
|
|
catalog=[],
|
|
installed_names=set(),
|
|
loaded_names=set(),
|
|
error="No plugin repositories configured.",
|
|
)
|
|
|
|
from steward.core.plugin_index import fetch_catalog
|
|
from steward.core.plugin_manager import _LOADED_PLUGINS
|
|
|
|
force = request.args.get("refresh") == "1"
|
|
try:
|
|
catalog = await fetch_catalog(repo_urls, force=force)
|
|
error = None if catalog else "Catalog is empty or could not be fetched."
|
|
except Exception as exc:
|
|
catalog = []
|
|
error = str(exc)
|
|
|
|
discovered = _discover_plugins()
|
|
installed_names = {p["_dir"] for p in discovered}
|
|
installed_versions = {p["_dir"]: p.get("version", "0.0.0") for p in discovered}
|
|
|
|
return await render_template(
|
|
"settings/_catalog_partial.html",
|
|
catalog=catalog,
|
|
installed_names=installed_names,
|
|
installed_versions=installed_versions,
|
|
loaded_names=_LOADED_PLUGINS,
|
|
error=error,
|
|
)
|
|
|
|
|
|
# ── Plugin install ─────────────────────────────────────────────────────────────
|
|
|
|
@settings_bp.post("/plugins/install/<name>")
|
|
@require_role(UserRole.admin)
|
|
async def install_plugin(name: str):
|
|
"""Download and install a plugin from the catalog, then hot-reload it."""
|
|
form = await request.form
|
|
download_url = form.get("download_url", "").strip()
|
|
checksum = form.get("checksum_sha256", "").strip()
|
|
|
|
if not download_url:
|
|
return await render_template(
|
|
"settings/_install_result.html",
|
|
name=name, success=False,
|
|
message="No download URL provided.",
|
|
)
|
|
|
|
from steward.core.plugin_manager import download_and_install_plugin, hot_reload_plugin
|
|
|
|
ok, msg = await download_and_install_plugin(
|
|
current_app._get_current_object(), name, download_url, checksum
|
|
)
|
|
if not ok:
|
|
return await render_template(
|
|
"settings/_install_result.html",
|
|
name=name, success=False, message=msg,
|
|
)
|
|
|
|
# Attempt hot-reload
|
|
ok2, msg2 = hot_reload_plugin(current_app._get_current_object(), name)
|
|
if ok2:
|
|
return await render_template(
|
|
"settings/_install_result.html",
|
|
name=name, success=True,
|
|
message=f"Installed and activated. {msg2}",
|
|
)
|
|
else:
|
|
return await render_template(
|
|
"settings/_install_result.html",
|
|
name=name, success=True,
|
|
message=f"Installed. {msg2}",
|
|
restart_required=True,
|
|
)
|
|
|
|
|
|
# ── Plugin hot-reload ──────────────────────────────────────────────────────────
|
|
|
|
@settings_bp.post("/plugins/reload/<name>")
|
|
@require_role(UserRole.admin)
|
|
async def reload_plugin(name: str):
|
|
"""Hot-reload a plugin that was installed but not yet active."""
|
|
from steward.core.plugin_manager import hot_reload_plugin
|
|
ok, msg = hot_reload_plugin(current_app._get_current_object(), name)
|
|
return await render_template(
|
|
"settings/_install_result.html",
|
|
name=name, success=ok, message=msg,
|
|
restart_required=(not ok and "restart" in msg.lower()),
|
|
)
|
|
|
|
|
|
# ── App restart ───────────────────────────────────────────────────────────────
|
|
|
|
@settings_bp.post("/plugins/restart/")
|
|
@require_role(UserRole.admin)
|
|
async def restart_app_route():
|
|
"""Trigger an in-app restart (replaces the process via os.execv)."""
|
|
import asyncio
|
|
from steward.core.plugin_manager import restart_app
|
|
|
|
# Schedule the restart slightly after we return the response
|
|
async def _delayed_restart():
|
|
await asyncio.sleep(0.5)
|
|
restart_app()
|
|
|
|
asyncio.create_task(_delayed_restart())
|
|
return await render_template("settings/_restart_pending.html")
|