Files
Juan Denis a93697c7f6 Add agent capability probing and reporting
Probe and report agent feature capabilities (cron, docker, systemd, nginx, etc.) so the panel can gate target pickers without extra round-trips.

Key changes:
- Add agent/internal/capabilities: probes host for features and platform/distro, returns protocol.CapabilitiesMessage cached at agent startup.
- Agent: store probed capabilities, send them on every transport reconnect (sendCapabilities) and include them when using poll transport.
- Protocol: introduce TypeCapabilities and CapabilitiesMessage (map[string]bool + platform/distro fields).
- Poll transport: accept CapabilitiesMessage in Send(), serialize to raw payload and queue for next /poll; support one-shot capabilities in poll body.
- Backend: handle capabilities from socket (on_capabilities) and poll endpoint; persist capabilities in in-memory ConnectedAgent (agent_registry) with helpers get_capabilities/has_capability; include capabilities/platform info in servers API.
- New fleet API (GET /api/v1/fleet/targets): list runnable targets (local + connected agents) and optionally filter agents by a requested feature using the advertised capability map.

Rationale: make agent feature availability explicit and discoverable to the panel so UI pickers can show only applicable agents without asking agents on every UI interaction.
2026-05-01 00:37:48 -04:00

80 lines
2.7 KiB
Python

"""
Fleet API
Cross-server endpoints that don't belong to any single server's resource
tree — primarily the target-picker support that every agent-aware
feature page consumes.
The "target" abstraction:
target = {"kind": "local"}
| {"kind": "agent", "server_id": "...", "server_name": "..."}
Pages render a picker; clicks send `target` in the request body of
feature CRUD calls; the backend dispatches against `target.kind`. See
`backend/app/services/fleet_targeting.py` (Phase 1) for the dispatch
helper.
This module is read-only; it does not write any state.
"""
from flask import Blueprint, jsonify, request
from flask_jwt_extended import jwt_required
from app.models.server import Server
from app.services.agent_registry import agent_registry
fleet_bp = Blueprint('fleet', __name__)
@fleet_bp.route('/targets', methods=['GET'])
@jwt_required()
def list_targets():
"""List runnable targets, optionally filtered by feature.
Query params:
feature — capability name (cron, docker, systemd, …). When
present, only agents whose advertised capability
map has feature=true are returned. When absent,
every connected agent is returned regardless of
capabilities.
The local panel host is always included as the first entry. Even
if no agent has the requested feature the caller still gets the
local target back, which matches the behaviour the UI expects:
"the local target is always available, agents are additive."
"""
feature = (request.args.get('feature') or '').strip() or None
targets = [{'kind': 'local'}]
# Walk the in-memory registry of connected agents. We intentionally
# do NOT pull from the Server table — capabilities are per-session
# state, and a server with status=offline can't take a command no
# matter what it last reported.
for server_id in agent_registry.get_connected_servers():
if feature and not agent_registry.has_capability(server_id, feature):
continue
# Look up the display name. We could cache this on
# ConnectedAgent, but the table is small and the lookup is
# cheap; not worth the staleness risk.
server = Server.query.get(server_id)
if not server:
# Connected agent without a Server row would be a bug
# elsewhere; skip rather than 500.
continue
caps = agent_registry.get_capabilities(server_id) or {}
targets.append({
'kind': 'agent',
'server_id': server.id,
'server_name': server.name,
'hostname': server.hostname,
'capabilities': caps,
})
return jsonify({'targets': targets})