feat(plugins): plugin capability registry + host_agent→Ansible deploy synergy
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 7s
CI / integration (push) Successful in 2m18s
CI / publish (push) Successful in 1m10s

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:
2026-06-16 11:28:29 -04:00
parent 656bda2e3d
commit f3e919892d
7 changed files with 320 additions and 6 deletions
@@ -28,6 +28,37 @@
</form>
</div>
{% if ansible_available %}
<div class="card">
<h3 style="margin-bottom:0.4rem;">Deploy via Ansible</h3>
<p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:0.75rem;">
Install (or update) the agent on Ansible inventory hosts in one run — no per-host <code>curl | sh</code>.
A fresh token is minted per host and injected into the run; existing agents are rotated to the new token.
</p>
{% if not (deploy_targets or deploy_groups) %}
<p style="font-size:0.85rem;color:var(--text-dim);">
No Ansible inventory targets yet. Add some under <a href="/ansible/browse">Ansible → Browse</a>.
</p>
{% else %}
<form method="post" action="/plugins/host_agent/deploy"
style="display:flex;gap:0.75rem;align-items:flex-end;flex-wrap:wrap;">
<div class="form-group" style="margin-bottom:0;">
<label>Target</label>
<select name="inventory_scope" required>
{% for g in deploy_groups %}<option value="steward:group:{{ g.id }}">Group: {{ g.name }}</option>{% endfor %}
{% for t in deploy_targets %}<option value="steward:target:{{ t.id }}">Target: {{ t.name }}</option>{% endfor %}
</select>
</div>
<div class="form-group" style="margin-bottom:0;">
<label>Report interval (s)</label>
<input type="number" name="agent_interval" value="30" min="5" style="width:7rem;">
</div>
<button type="submit" class="btn">Deploy</button>
</form>
{% endif %}
</div>
{% endif %}
<div class="card-flush">
<table class="table">
<thead>