feat(plugins): plugin capability registry + host_agent→Ansible deploy synergy
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
|
||||
@@ -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<operator<admin order."""
|
||||
return _ROLE_ORDER.index(user_role) >= _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).
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user