From f3e919892d3e0bd2639d1c40176a7875bf489207 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 16 Jun 2026 11:28:29 -0400 Subject: [PATCH] =?UTF-8?q?feat(plugins):=20plugin=20capability=20registry?= =?UTF-8?q?=20+=20host=5Fagent=E2=86=92Ansible=20deploy=20synergy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements #253's framework: a small core capability registry (steward/core/capabilities.py) where a module/plugin publishes a named, role-gated action and a consumer discovers it via has_capability() and runs it via invoke_capability() — no hard import, graceful degradation, permission propagation (actor role checked against the capability's required_role). Core publishes "ansible.run_playbook" (operator) wrapping ansible.runner. trigger_run (extended to accept a caller-built inventory). First consumer: the host_agent plugin gains "Deploy via Ansible" on its settings page — pick an inventory target/group and it installs/updates the agent via the bundled host_agent/install.yml, minting a fresh token per host and injecting it as an inventory hostvar (turning per-host curl|sh into one run). Exposed role ordering as middleware.role_meets. Unit tests for the registry + role checks. Task #253 (milestone #37). Co-Authored-By: Claude Opus 4.8 (1M context) --- plugins/host_agent/routes.py | 116 +++++++++++++++++- .../host_agent/templates/settings_list.html | 31 +++++ steward/ansible/runner.py | 9 +- steward/app.py | 13 ++ steward/auth/middleware.py | 5 + steward/core/capabilities.py | 95 ++++++++++++++ tests/core/test_capabilities.py | 57 +++++++++ 7 files changed, 320 insertions(+), 6 deletions(-) create mode 100644 steward/core/capabilities.py create mode 100644 tests/core/test_capabilities.py diff --git a/plugins/host_agent/routes.py b/plugins/host_agent/routes.py index ed8ef20..07e672b 100644 --- a/plugins/host_agent/routes.py +++ b/plugins/host_agent/routes.py @@ -8,7 +8,7 @@ from typing import Any from pathlib import Path import secrets -from quart import Blueprint, current_app, jsonify, request, Response, render_template, redirect, url_for +from quart import Blueprint, current_app, jsonify, request, Response, render_template, redirect, url_for, session from steward.auth.middleware import require_role from steward.models.users import UserRole from sqlalchemy import select, func, or_ @@ -535,12 +535,22 @@ def _new_token_pair() -> tuple[str, str]: @host_agent_bp.get("/settings/") @require_role(UserRole.admin) async def settings_list(): - async with current_app.db_sessionmaker() as session: - regs = (await session.execute(select(HostAgentRegistration))).scalars().all() + from steward.core.capabilities import has_capability + from steward.models.ansible_inventory import AnsibleTarget, AnsibleGroup + + ansible_available = has_capability("ansible.run_playbook") + async with current_app.db_sessionmaker() as db: + regs = (await db.execute(select(HostAgentRegistration))).scalars().all() hosts_by_id = { - h.id: h for h in (await session.execute( + h.id: h for h in (await db.execute( select(Host).where(Host.id.in_([r.host_id for r in regs])))).scalars().all() } if regs else {} + targets, groups = [], [] + if ansible_available: + targets = (await db.execute( + select(AnsibleTarget).order_by(AnsibleTarget.name))).scalars().all() + groups = (await db.execute( + select(AnsibleGroup).order_by(AnsibleGroup.name))).scalars().all() new_token = request.args.get("new_token") new_host_id = request.args.get("host_id") @@ -555,6 +565,9 @@ async def settings_list(): ], new_token=new_token, install_url=install_url, + ansible_available=ansible_available, + deploy_targets=targets, + deploy_groups=groups, ) @@ -617,3 +630,98 @@ async def delete_registration(host_id: str): await session.delete(reg) await session.commit() return redirect(url_for("host_agent.settings_list")) + + +# ── Deploy via Ansible (plugin↔core synergy via the capability registry) ────── + +async def _ensure_host_for_target(db, target) -> "Host": + """Find or create the Host that an AnsibleTarget should report under.""" + if getattr(target, "host_id", None): + h = await db.get(Host, target.host_id) + if h: + return h + h = (await db.execute( + select(Host).where(Host.name == target.name))).scalar_one_or_none() + if h is None: + h = Host(name=target.name, address=getattr(target, "address", "") or "") + db.add(h) + await db.flush() + return h + + +async def _mint_registration_token(db, host) -> str: + """Create or rotate the host's agent registration; return the raw token. + + Tokens are stored hashed (unrecoverable), so deploying always mints a fresh + token — the run installs the agent with it. Existing agents get rotated. + """ + reg = (await db.execute(select(HostAgentRegistration).where( + HostAgentRegistration.host_id == host.id))).scalar_one_or_none() + raw, hashed = _new_token_pair() + if reg is None: + db.add(HostAgentRegistration(host_id=host.id, token_hash=hashed)) + else: + reg.token_hash = hashed + reg.token_created_at = datetime.now(timezone.utc) + return raw + + +@host_agent_bp.post("/deploy") +@require_role(UserRole.admin) +async def deploy_via_ansible(): + """Install/update the agent on Ansible inventory targets via the bundled + install playbook — the 'curl | sh becomes a button' synergy. + + Uses the core "ansible.run_playbook" capability (no hard import of the + runner) and injects a freshly-minted per-host token as an inventory hostvar. + """ + import json as _json + from steward.core.capabilities import has_capability, invoke_capability + from steward.ansible.inventory_gen import fetch_scope_targets, generate_inventory + from steward.ansible.sources import BUILTIN_SOURCE_NAME + + if not has_capability("ansible.run_playbook"): + return _error(400, "ansible_unavailable", "Ansible is not available") + + form = await request.form + scope = (form.get("inventory_scope", "") or "").strip() + try: + interval = max(5, int(form.get("agent_interval", "30"))) + except (TypeError, ValueError): + interval = 30 + if not (scope.startswith("steward:target:") + or scope.startswith("steward:group:") + or scope == "steward:all"): + return _error(400, "bad_scope", "Choose a target or group") + + url = public_base_url(request) + async with current_app.db_sessionmaker() as db: + targets = await fetch_scope_targets(db, scope) + if not targets: + return _error(400, "no_targets", "No Ansible targets in that scope") + tokens: dict[str, str] = {} + async with db.begin(): + for t in targets: + host = await _ensure_host_for_target(db, t) + tokens[t.name] = await _mint_registration_token(db, host) + inv = generate_inventory(targets) + for name, tok in tokens.items(): + hv = inv["_meta"]["hostvars"].setdefault(name, {}) + hv["steward_url"] = url + hv["steward_token"] = tok + inventory_content = _json.dumps(inv) + + actor_role = UserRole(session.get("user_role", "viewer")) + run, _source, err = await invoke_capability( + "ansible.run_playbook", actor_role, + current_app._get_current_object(), # type: ignore[attr-defined] + source_name=BUILTIN_SOURCE_NAME, + playbook_path="host_agent/install.yml", + inventory_content=inventory_content, + inventory_scope=scope, + params={"extra_vars": [f"agent_interval={interval}"]}, + triggered_by=session.get("user_id"), + ) + if err: + return _error(400, "deploy_failed", err) + return redirect(url_for("ansible.run_detail", run_id=run.id)) diff --git a/plugins/host_agent/templates/settings_list.html b/plugins/host_agent/templates/settings_list.html index 49684f7..dc52e79 100644 --- a/plugins/host_agent/templates/settings_list.html +++ b/plugins/host_agent/templates/settings_list.html @@ -28,6 +28,37 @@ +{% if ansible_available %} +
+

Deploy via Ansible

+

+ Install (or update) the agent on Ansible inventory hosts in one run — no per-host curl | sh. + A fresh token is minted per host and injected into the run; existing agents are rotated to the new token. +

+ {% if not (deploy_targets or deploy_groups) %} +

+ No Ansible inventory targets yet. Add some under Ansible → Browse. +

+ {% else %} +
+
+ + +
+
+ + +
+ +
+ {% endif %} +
+{% endif %} +
diff --git a/steward/ansible/runner.py b/steward/ansible/runner.py index 1aada6b..a8ea6cd 100644 --- a/steward/ansible/runner.py +++ b/steward/ansible/runner.py @@ -24,10 +24,14 @@ async def trigger_run( inventory_scope: str = "steward:all", params: dict | None = None, triggered_by: str | None = None, + inventory_content: str | None = None, ): """Resolve inventory for the scope, persist an AnsibleRun, and launch it. triggered_by=None marks a system/automated run (alerts, schedules). + If inventory_content is provided, it is used verbatim and scope resolution + is skipped (the caller built a bespoke inventory — e.g. host_agent deploy + injecting per-host tokens); inventory_scope is still recorded for display. Returns (run, source, error): on success error is None; on failure run is None and error is a short human-readable reason. """ @@ -36,9 +40,10 @@ async def trigger_run( if source is None: return None, None, "Source not found" - inventory_content: str | None = None inventory_path: str | None = None - if inventory_scope.startswith("steward:"): + if inventory_content is not None: + pass # caller-supplied inventory wins; scope kept only for display + elif inventory_scope.startswith("steward:"): async with app.db_sessionmaker() as db: targets = await fetch_scope_targets(db, inventory_scope) inventory_content = json.dumps(generate_inventory(targets)) diff --git a/steward/app.py b/steward/app.py index d5bcf06..a97bd86 100644 --- a/steward/app.py +++ b/steward/app.py @@ -123,6 +123,19 @@ def create_app( register_status_source(ping_status_source) register_status_source(dns_status_source) + # Publish the Ansible "run a playbook" capability so plugins (e.g. host_agent + # auto-deploy) can drive runs without importing the runner. Ansible is core, + # so this is always available; consumers still gate on has_capability(). + from .core.capabilities import register_capability + from .ansible.runner import trigger_run + from .models.users import UserRole as _UserRole + register_capability( + "ansible.run_playbook", trigger_run, + label="Run Ansible playbook", + description="Launch an Ansible playbook run (manual, alert, schedule, or plugin-driven).", + required_role=_UserRole.operator, + ) + # ── 8. Build task registry ───────────────────────────────────────────────── app._task_registry = [] diff --git a/steward/auth/middleware.py b/steward/auth/middleware.py index d57bd34..6aca001 100644 --- a/steward/auth/middleware.py +++ b/steward/auth/middleware.py @@ -6,6 +6,11 @@ from steward.models.users import UserRole _ROLE_ORDER = [UserRole.viewer, UserRole.operator, UserRole.admin] +def role_meets(user_role: UserRole, minimum_role: UserRole) -> bool: + """True when user_role is at least minimum_role in the viewer= _ROLE_ORDER.index(minimum_role) + + def require_role(minimum_role: UserRole): """Decorator: requires authenticated user with at least minimum_role. Also allows access for validated share-token requests (viewer level only). diff --git a/steward/core/capabilities.py b/steward/core/capabilities.py new file mode 100644 index 0000000..df62b11 --- /dev/null +++ b/steward/core/capabilities.py @@ -0,0 +1,95 @@ +# steward/core/capabilities.py +"""Plugin/core capability registry — opportunistic, decoupled synergy. + +A capability is a named, permission-gated action that one part of the system +(core module or plugin) publishes and another can discover + invoke WITHOUT a +hard import. The publisher registers a callable under a string key; a consumer +checks `has_capability(key)` (graceful degradation — the synergy is a bonus, +never a requirement) and calls `invoke_capability(key, actor_role, ...)`. + +First consumer: the host_agent plugin invokes "ansible.run_playbook" to deploy +its agent via Ansible instead of importing the Ansible runner directly. + +Security: every capability declares a required_role; invoke_capability enforces +the caller's role meets it, so a low-privilege context can't drive a privileged +action in another module. +""" +from __future__ import annotations + +import inspect +import logging +from dataclasses import dataclass +from typing import Callable + +from steward.auth.middleware import role_meets +from steward.models.users import UserRole + +logger = logging.getLogger(__name__) + + +class CapabilityUnavailable(Exception): + """Raised when an unknown capability key is invoked.""" + + +class CapabilityForbidden(Exception): + """Raised when the actor's role is below the capability's required_role.""" + + +@dataclass +class Capability: + key: str + fn: Callable + label: str + description: str + required_role: UserRole + + +_CAPABILITIES: dict[str, Capability] = {} + + +def register_capability( + key: str, + fn: Callable, + *, + label: str, + description: str = "", + required_role: UserRole = UserRole.admin, +) -> None: + """Publish a capability. Last registration for a key wins (idempotent re-register).""" + _CAPABILITIES[key] = Capability(key, fn, label, description, required_role) + + +def has_capability(key: str) -> bool: + return key in _CAPABILITIES + + +def get_capability(key: str) -> Capability | None: + return _CAPABILITIES.get(key) + + +def list_capabilities() -> list[Capability]: + return list(_CAPABILITIES.values()) + + +def clear_capabilities() -> None: + """Reset the registry (tests).""" + _CAPABILITIES.clear() + + +async def invoke_capability(key: str, actor_role: UserRole, /, *args, **kwargs): + """Invoke a registered capability after a role check. Awaits async callables. + + Raises CapabilityUnavailable if the key isn't registered, CapabilityForbidden + if actor_role is insufficient. + """ + cap = _CAPABILITIES.get(key) + if cap is None: + raise CapabilityUnavailable(key) + if not role_meets(actor_role, cap.required_role): + raise CapabilityForbidden( + f"capability {key!r} requires role {cap.required_role.value}" + ) + result = cap.fn(*args, **kwargs) + if inspect.isawaitable(result): + result = await result + return result diff --git a/tests/core/test_capabilities.py b/tests/core/test_capabilities.py new file mode 100644 index 0000000..33e2677 --- /dev/null +++ b/tests/core/test_capabilities.py @@ -0,0 +1,57 @@ +"""Unit tests for the plugin/core capability registry.""" +import asyncio + +import pytest + +from steward.auth.middleware import role_meets +from steward.core.capabilities import ( + CapabilityForbidden, + CapabilityUnavailable, + clear_capabilities, + get_capability, + has_capability, + invoke_capability, + register_capability, +) +from steward.models.users import UserRole + + +def setup_function(): + clear_capabilities() + + +def test_register_has_get_defaults_to_admin(): + register_capability("x.do", lambda: "ok", label="Do X") + assert has_capability("x.do") + assert get_capability("x.do").label == "Do X" + assert get_capability("x.do").required_role == UserRole.admin + + +def test_invoke_sync_callable(): + register_capability("x.sum", lambda a, b: a + b, label="Sum", required_role=UserRole.viewer) + assert asyncio.run(invoke_capability("x.sum", UserRole.viewer, 2, 3)) == 5 + + +def test_invoke_async_callable(): + async def double(v): + return v * 2 + + register_capability("x.double", double, label="D", required_role=UserRole.viewer) + assert asyncio.run(invoke_capability("x.double", UserRole.operator, 4)) == 8 + + +def test_invoke_unknown_capability_raises(): + with pytest.raises(CapabilityUnavailable): + asyncio.run(invoke_capability("nope", UserRole.admin)) + + +def test_invoke_forbidden_when_role_too_low(): + register_capability("x.admin", lambda: 1, label="A", required_role=UserRole.admin) + with pytest.raises(CapabilityForbidden): + asyncio.run(invoke_capability("x.admin", UserRole.operator)) + + +def test_role_meets_ordering(): + assert role_meets(UserRole.admin, UserRole.operator) is True + assert role_meets(UserRole.operator, UserRole.operator) is True + assert role_meets(UserRole.viewer, UserRole.operator) is False