From b9b5e5e36fbf99c14e27adf3715cfe52ad0c44cf Mon Sep 17 00:00:00 2001 From: JC Date: Thu, 27 Aug 2026 17:00:56 +0000 Subject: [PATCH] feat: add agent management --- backend/app/agent_registry/__init__.py | 1 + backend/app/agent_registry/dependencies.py | 8 + backend/app/agent_registry/models.py | 62 +++ backend/app/agent_registry/repository.py | 127 ++++++ backend/app/agent_registry/router.py | 48 ++ backend/app/agent_registry/service.py | 52 +++ backend/app/frontend/agent_management_view.py | 268 ++++++++++++ backend/app/frontend/router.py | 22 + .../app/frontend/static/agent-management.js | 414 ++++++++++++++++++ backend/app/frontend/static/app.css | 83 ++++ backend/app/frontend/views.py | 11 +- backend/app/identity.py | 2 + backend/app/main.py | 2 + .../tests/test_agent_management_frontend.py | 141 ++++++ backend/tests/test_agent_registry.py | 120 +++++ backend/tests/test_agent_registry_postgres.py | 194 ++++++++ scripts/test-agent-management.sh | 90 ++++ 17 files changed, 1644 insertions(+), 1 deletion(-) create mode 100644 backend/app/agent_registry/__init__.py create mode 100644 backend/app/agent_registry/dependencies.py create mode 100644 backend/app/agent_registry/models.py create mode 100644 backend/app/agent_registry/repository.py create mode 100644 backend/app/agent_registry/router.py create mode 100644 backend/app/agent_registry/service.py create mode 100644 backend/app/frontend/agent_management_view.py create mode 100644 backend/app/frontend/static/agent-management.js create mode 100644 backend/tests/test_agent_management_frontend.py create mode 100644 backend/tests/test_agent_registry.py create mode 100644 backend/tests/test_agent_registry_postgres.py create mode 100755 scripts/test-agent-management.sh diff --git a/backend/app/agent_registry/__init__.py b/backend/app/agent_registry/__init__.py new file mode 100644 index 0000000..26ec0b1 --- /dev/null +++ b/backend/app/agent_registry/__init__.py @@ -0,0 +1 @@ +"""Read-only Agent registry projection.""" diff --git a/backend/app/agent_registry/dependencies.py b/backend/app/agent_registry/dependencies.py new file mode 100644 index 0000000..49d05e2 --- /dev/null +++ b/backend/app/agent_registry/dependencies.py @@ -0,0 +1,8 @@ +"""Dependencies for the Agent registry read model.""" + +from app.agent_registry.repository import AgentRegistryRepository +from app.agent_registry.service import AgentRegistryService + + +def get_agent_registry_service() -> AgentRegistryService: + return AgentRegistryService(AgentRegistryRepository()) diff --git a/backend/app/agent_registry/models.py b/backend/app/agent_registry/models.py new file mode 100644 index 0000000..ea5eec3 --- /dev/null +++ b/backend/app/agent_registry/models.py @@ -0,0 +1,62 @@ +"""Typed read models for Agent Management.""" + +from datetime import datetime +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, Field + + +CredentialState = Literal["legacy", "active", "revoked"] +RoutingState = Literal["assigned", "unassigned", "disabled"] + + +class AgentRegistrySite(BaseModel): + id: UUID + name: str + + +class AgentRegistryZone(BaseModel): + id: UUID + site_id: UUID + site_name: str + name: str + description: str | None = None + is_enabled: bool + assigned_agent_id: str | None = None + networks: list[str] = Field(default_factory=list) + + +class AgentRegistryAgent(BaseModel): + agent_id: str + display_name: str | None = None + + site_id: UUID | None = None + site_name: str + + agent_zone_id: UUID | None = None + agent_zone_name: str | None = None + agent_zone_enabled: bool | None = None + + hostname: str | None = None + version: str | None = None + ip_address: str | None = None + last_checkin_at: datetime + + credential_state: CredentialState + credential_created_at: datetime | None = None + credential_revoked_at: datetime | None = None + + routing_state: RoutingState + + +class AgentRegistryData(BaseModel): + agents: list[AgentRegistryAgent] = Field(default_factory=list) + sites: list[AgentRegistrySite] = Field(default_factory=list) + zones: list[AgentRegistryZone] = Field(default_factory=list) + + +class AgentRegistryResponse(BaseModel): + data: AgentRegistryData + meta: dict[str, object] = Field(default_factory=dict) + error: None = None diff --git a/backend/app/agent_registry/repository.py b/backend/app/agent_registry/repository.py new file mode 100644 index 0000000..4cd37a4 --- /dev/null +++ b/backend/app/agent_registry/repository.py @@ -0,0 +1,127 @@ +"""PostgreSQL reads for the durable Agent registry.""" + +import psycopg +from psycopg.rows import dict_row + +from app.config import settings + + +def default_connection_factory(): + return psycopg.connect( + host=settings.postgres_host, + port=settings.postgres_port, + dbname=settings.postgres_db, + user=settings.postgres_user, + password=settings.postgres_password, + row_factory=dict_row, + ) + + +class AgentRegistryRepository: + def __init__(self, connection_factory=default_connection_factory): + self.connection_factory = connection_factory + + def list_registry(self) -> dict[str, list[dict]]: + connection = self.connection_factory() + + try: + with connection.cursor() as cur: + cur.execute( + """ + SELECT + site.id, + site.name + FROM sites AS site + WHERE EXISTS ( + SELECT 1 + FROM agent_zones AS zone + WHERE zone.site_id = site.id + ) + ORDER BY lower(site.name), site.id + """ + ) + sites = cur.fetchall() + + cur.execute( + """ + SELECT + zone.id, + zone.site_id, + site.name AS site_name, + zone.name, + zone.description, + zone.is_enabled, + assigned.agent_id AS assigned_agent_id, + COALESCE( + array_agg( + network.network::text + ORDER BY network.network::text + ) FILTER (WHERE network.id IS NOT NULL), + ARRAY[]::text[] + ) AS networks + FROM agent_zones AS zone + JOIN sites AS site + ON site.id = zone.site_id + LEFT JOIN minerops_site_agents AS assigned + ON assigned.agent_zone_id = zone.id + LEFT JOIN agent_zone_network_ranges AS network + ON network.agent_zone_id = zone.id + GROUP BY + zone.id, + zone.site_id, + site.name, + zone.name, + zone.description, + zone.is_enabled, + assigned.agent_id + ORDER BY + lower(site.name), + lower(zone.name), + zone.id + """ + ) + zones = cur.fetchall() + + cur.execute( + """ + SELECT + agent.agent_id, + agent.display_name, + agent.site_id, + COALESCE(site.name, agent.site_name) AS site_name, + agent.agent_zone_id, + zone.name AS agent_zone_name, + zone.is_enabled AS agent_zone_enabled, + agent.hostname, + agent.version, + agent.ip_address, + agent.last_checkin_at, + agent.credential_created_at, + agent.credential_revoked_at, + CASE + WHEN agent.credential_revoked_at IS NOT NULL + THEN 'revoked' + WHEN agent.agent_secret_hash IS NOT NULL + THEN 'active' + ELSE 'legacy' + END AS credential_state + FROM minerops_site_agents AS agent + LEFT JOIN sites AS site + ON site.id = agent.site_id + LEFT JOIN agent_zones AS zone + ON zone.id = agent.agent_zone_id + ORDER BY + lower(COALESCE(site.name, agent.site_name)), + lower(COALESCE(agent.display_name, agent.agent_id)), + agent.agent_id + """ + ) + agents = cur.fetchall() + + return { + "sites": sites, + "zones": zones, + "agents": agents, + } + finally: + connection.close() diff --git a/backend/app/agent_registry/router.py b/backend/app/agent_registry/router.py new file mode 100644 index 0000000..94eab00 --- /dev/null +++ b/backend/app/agent_registry/router.py @@ -0,0 +1,48 @@ +"""Read-only Agent Management API.""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, Response + +from app.agent_registry.dependencies import get_agent_registry_service +from app.agent_registry.models import AgentRegistryResponse +from app.agent_registry.service import AgentRegistryService +from app.identity import require_permission + + +router = APIRouter(tags=["agents"]) + +RegistryService = Annotated[ + AgentRegistryService, + Depends(get_agent_registry_service), +] + +AgentViewer = Annotated[ + dict, + Depends(require_permission("agents.view")), +] + + +@router.get( + "/api/agents", + response_model=AgentRegistryResponse, +) +def list_agents( + response: Response, + current_user: AgentViewer, + service: RegistryService, +) -> AgentRegistryResponse: + del current_user + + response.headers["Cache-Control"] = "no-store" + + data = service.list_registry() + + return AgentRegistryResponse( + data=data, + meta={ + "agent_count": len(data.agents), + "site_count": len(data.sites), + "zone_count": len(data.zones), + }, + ) diff --git a/backend/app/agent_registry/service.py b/backend/app/agent_registry/service.py new file mode 100644 index 0000000..f200e6c --- /dev/null +++ b/backend/app/agent_registry/service.py @@ -0,0 +1,52 @@ +"""Application service for the Agent Management read model.""" + +from app.agent_registry.models import ( + AgentRegistryAgent, + AgentRegistryData, + AgentRegistrySite, + AgentRegistryZone, +) + + +class AgentRegistryService: + def __init__(self, repository): + self.repository = repository + + def list_registry(self) -> AgentRegistryData: + rows = self.repository.list_registry() + + sites = [ + AgentRegistrySite(**row) + for row in rows["sites"] + ] + + zones = [ + AgentRegistryZone(**row) + for row in rows["zones"] + ] + + agents: list[AgentRegistryAgent] = [] + + for row in rows["agents"]: + zone_id = row.get("agent_zone_id") + zone_enabled = row.get("agent_zone_enabled") + + if zone_id is None: + routing_state = "unassigned" + elif zone_enabled is False: + routing_state = "disabled" + else: + routing_state = "assigned" + + agents.append( + AgentRegistryAgent( + **row, + routing_state=routing_state, + ) + ) + + return AgentRegistryData( + agents=agents, + sites=sites, + zones=zones, + ) diff --git a/backend/app/frontend/agent_management_view.py b/backend/app/frontend/agent_management_view.py new file mode 100644 index 0000000..23fae42 --- /dev/null +++ b/backend/app/frontend/agent_management_view.py @@ -0,0 +1,268 @@ +"""Semantic HTML for the Internal Alpha Agent Management page.""" + + +def agent_management_content(can_manage_agents: bool) -> str: + management = """ +
+
+
+

Agent deployment

+

Add Agent

+

+ Create a short-lived one-time enrollment for an Agent Zone. + Windows is the currently supported deployment platform. +

+
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +

+
+ + +
+ """ if can_manage_agents else """ +
+
+
+

Agent deployment

+

Agent enrollment

+
+
+

+ Your account can view Agent status but cannot create Agent + enrollments. +

+
+ """ + + return f""" +
+ Loading Agents + Requesting Agent registry and enrollment information. +
+ + + + + + +""" diff --git a/backend/app/frontend/router.py b/backend/app/frontend/router.py index 458be5b..494e653 100644 --- a/backend/app/frontend/router.py +++ b/backend/app/frontend/router.py @@ -18,6 +18,7 @@ DashboardUser = Annotated[dict, Depends(require_permission("dashboard.view"))] MapUser = Annotated[dict, Depends(require_permission("locations.view"))] RepairUser = Annotated[dict, Depends(require_permission("repairs.view"))] MinerUser = Annotated[dict, Depends(require_permission("miners.view"))] +AgentUser = Annotated[dict, Depends(require_permission("agents.view"))] def render_page(view: str, current_user: dict, **context) -> str: @@ -92,6 +93,27 @@ def miner_detail_script() -> Response: headers={"Cache-Control": "no-store"}, ) +@router.get( + "/assets/minerops-agent-management.js", + include_in_schema=False, +) +def agent_management_script() -> Response: + return Response( + (STATIC_ROOT / "agent-management.js").read_text(encoding="utf-8"), + media_type="text/javascript", + headers={"Cache-Control": "no-store"}, + ) + + +@router.get( + "/agent/management", + response_class=HTMLResponse, + include_in_schema=False, +) +def agent_management_page(current_user: AgentUser) -> str: + return render_page("agent_management", current_user) + + @router.get("/dashboard", response_class=HTMLResponse, include_in_schema=False) def dashboard_page(current_user: DashboardUser) -> str: return render_page("dashboard", current_user) diff --git a/backend/app/frontend/static/agent-management.js b/backend/app/frontend/static/agent-management.js new file mode 100644 index 0000000..ff5830f --- /dev/null +++ b/backend/app/frontend/static/agent-management.js @@ -0,0 +1,414 @@ +(function () { + "use strict"; + + if (document.body.dataset.view !== "agent_management") return; + + const ids = [ + "agentManagementLoading", + "agentManagementError", + "agentManagementErrorText", + "agentManagementRetry", + "agentManagementContent", + "agentManagementRefresh", + "agentCount", + "agentZoneCount", + "agentWaitingEnrollmentCount", + "agentRows", + "agentEmpty", + "agentZoneRows", + "agentZoneEmpty", + "agentEnrollmentRows", + "agentEnrollmentEmpty", + "agentCreateForm", + "agentSite", + "agentZone", + "agentDisplayName", + "agentPlatform", + "agentCreateSubmit", + "agentCreateStatus", + "agentEnrollmentResult", + "agentEnrollmentToken", + "agentEnrollmentExpires", + "agentEnrollmentCopy", + "agentEnrollmentDismiss", + "agentEnrollmentCopyStatus" + ]; + + const el = {}; + + ids.forEach(function (id) { + el[id] = document.getElementById(id); + }); + + let registry = { + agents: [], + sites: [], + zones: [] + }; + + let enrollments = []; + + function text(value, fallback) { + if (value === null || value === undefined || value === "") { + return fallback || "Unavailable"; + } + return String(value); + } + + function dateTime(value) { + if (!value) return "Never"; + const parsed = new Date(value); + if (Number.isNaN(parsed.valueOf())) return "Unavailable"; + return parsed.toLocaleString(); + } + + function words(value) { + return text(value, "Unknown") + .replaceAll("_", " ") + .replace(/\b\w/g, function (letter) { + return letter.toUpperCase(); + }); + } + + function cell(value, className) { + const node = document.createElement("td"); + if (className) node.className = className; + node.textContent = value; + return node; + } + + function setState(name) { + el.agentManagementLoading.hidden = name !== "loading"; + el.agentManagementError.hidden = name !== "error"; + el.agentManagementContent.hidden = name !== "ready"; + } + + async function api(path, options) { + const response = await fetch(path, Object.assign({ + credentials: "same-origin", + cache: "no-store", + headers: { + "Accept": "application/json" + } + }, options || {})); + + const payload = await response.json().catch(function () { + return {}; + }); + + if (!response.ok) { + const detail = payload && payload.detail + ? String(payload.detail) + : "Request failed with status " + response.status + "."; + throw new Error(detail); + } + + return payload; + } + + function renderAgents() { + el.agentRows.replaceChildren(); + + registry.agents.forEach(function (agent) { + const row = document.createElement("tr"); + + const identity = document.createElement("td"); + const name = document.createElement("strong"); + const id = document.createElement("span"); + + name.textContent = text(agent.display_name, "Unnamed Agent"); + id.className = "secondary"; + id.textContent = agent.agent_id; + + identity.append(name, id); + + const location = [ + text(agent.site_name, "Unknown site"), + text(agent.agent_zone_name, "No Agent Zone") + ].join(" / "); + + const host = [ + text(agent.hostname, "Unknown host"), + text(agent.ip_address, "No IP") + ].join(" / "); + + row.append( + identity, + cell(location), + cell(host), + cell(text(agent.version, "Unknown")), + cell(dateTime(agent.last_checkin_at)), + cell(words(agent.credential_state)), + cell(words(agent.routing_state)) + ); + + el.agentRows.appendChild(row); + }); + + el.agentEmpty.hidden = registry.agents.length !== 0; + } + + function renderZones() { + el.agentZoneRows.replaceChildren(); + + registry.zones.forEach(function (zone) { + const row = document.createElement("tr"); + + const networks = Array.isArray(zone.networks) && zone.networks.length + ? zone.networks.join(", ") + : "No networks recorded"; + + row.append( + cell(zone.site_name), + cell(zone.name), + cell(networks), + cell(text(zone.assigned_agent_id, "Unassigned")), + cell(zone.is_enabled ? "Enabled" : "Disabled") + ); + + el.agentZoneRows.appendChild(row); + }); + + el.agentZoneEmpty.hidden = registry.zones.length !== 0; + } + + function renderEnrollments() { + el.agentEnrollmentRows.replaceChildren(); + + enrollments.forEach(function (enrollment) { + const row = document.createElement("tr"); + + row.append( + cell(enrollment.display_name), + cell( + enrollment.site_name + " / " + + enrollment.agent_zone_name + ), + cell(words(enrollment.status)), + cell(dateTime(enrollment.created_at)), + cell(dateTime(enrollment.expires_at)), + cell(text(enrollment.enrolled_agent_id, "Not enrolled")) + ); + + el.agentEnrollmentRows.appendChild(row); + }); + + el.agentEnrollmentEmpty.hidden = enrollments.length !== 0; + } + + function populateSites() { + if (!el.agentSite) return; + + const selected = el.agentSite.value; + + el.agentSite.replaceChildren(); + + const placeholder = document.createElement("option"); + placeholder.value = ""; + placeholder.textContent = "Choose a site"; + el.agentSite.appendChild(placeholder); + + registry.sites.forEach(function (site) { + const option = document.createElement("option"); + option.value = site.id; + option.textContent = site.name; + el.agentSite.appendChild(option); + }); + + if ([...el.agentSite.options].some(function (option) { + return option.value === selected; + })) { + el.agentSite.value = selected; + } + + populateZones(); + } + + function populateZones() { + if (!el.agentZone || !el.agentSite) return; + + const siteId = el.agentSite.value; + const selected = el.agentZone.value; + + el.agentZone.replaceChildren(); + + const placeholder = document.createElement("option"); + placeholder.value = ""; + + if (!siteId) { + placeholder.textContent = "Choose a site first"; + el.agentZone.disabled = true; + el.agentZone.appendChild(placeholder); + return; + } + + placeholder.textContent = "Choose an Agent Zone"; + el.agentZone.appendChild(placeholder); + + const available = registry.zones.filter(function (zone) { + return ( + zone.site_id === siteId && + zone.is_enabled === true && + !zone.assigned_agent_id + ); + }); + + available.forEach(function (zone) { + const option = document.createElement("option"); + option.value = zone.id; + option.textContent = zone.name; + el.agentZone.appendChild(option); + }); + + el.agentZone.disabled = available.length === 0; + + if ([...el.agentZone.options].some(function (option) { + return option.value === selected; + })) { + el.agentZone.value = selected; + } + } + + function renderSummary() { + el.agentCount.textContent = String(registry.agents.length); + el.agentZoneCount.textContent = String(registry.zones.length); + + const waiting = enrollments.filter(function (item) { + return item.status === "waiting"; + }).length; + + el.agentWaitingEnrollmentCount.textContent = String(waiting); + } + + function render() { + renderSummary(); + renderAgents(); + renderZones(); + renderEnrollments(); + populateSites(); + } + + async function refreshData(showLoading) { + if (showLoading !== false) setState("loading"); + + try { + const results = await Promise.all([ + api("/api/agents"), + api("/api/agent-enrollments") + ]); + + registry = results[0] && results[0].data + ? results[0].data + : { agents: [], sites: [], zones: [] }; + + enrollments = results[1] && Array.isArray(results[1].data) + ? results[1].data + : []; + + render(); + setState("ready"); + } catch (error) { + el.agentManagementErrorText.textContent = + error.message || "Agent information could not be loaded."; + setState("error"); + } + } + + async function createEnrollment(event) { + event.preventDefault(); + + if (!el.agentCreateForm) return; + + el.agentCreateSubmit.disabled = true; + el.agentCreateStatus.textContent = "Creating enrollment..."; + + try { + const payload = await api("/api/agent-enrollments", { + method: "POST", + headers: { + "Accept": "application/json", + "Content-Type": "application/json" + }, + body: JSON.stringify({ + site_id: el.agentSite.value, + agent_zone_id: el.agentZone.value, + display_name: el.agentDisplayName.value.trim() + }) + }); + + const enrollment = payload.data; + + el.agentEnrollmentToken.textContent = + enrollment.enrollment_token; + + el.agentEnrollmentExpires.textContent = + "Expires " + dateTime(enrollment.expires_at); + + el.agentEnrollmentCopyStatus.textContent = ""; + el.agentEnrollmentResult.hidden = false; + el.agentCreateStatus.textContent = "Enrollment created."; + + el.agentDisplayName.value = ""; + + await refreshData(false); + } catch (error) { + el.agentCreateStatus.textContent = + error.message || "Enrollment could not be created."; + } finally { + el.agentCreateSubmit.disabled = false; + } + } + + async function copyToken() { + const token = el.agentEnrollmentToken.textContent; + + if (!token) return; + + try { + await navigator.clipboard.writeText(token); + el.agentEnrollmentCopyStatus.textContent = + "Enrollment token copied."; + } catch (error) { + el.agentEnrollmentCopyStatus.textContent = + "Copy was unavailable. Select and copy the token manually."; + } + } + + function dismissToken() { + el.agentEnrollmentToken.textContent = ""; + el.agentEnrollmentExpires.textContent = ""; + el.agentEnrollmentCopyStatus.textContent = ""; + el.agentEnrollmentResult.hidden = true; + } + + el.agentManagementRetry.addEventListener("click", function () { + refreshData(true); + }); + + if (el.agentManagementRefresh) { + el.agentManagementRefresh.addEventListener("click", function () { + refreshData(true); + }); + } + + if (el.agentSite) { + el.agentSite.addEventListener("change", populateZones); + } + + if (el.agentCreateForm) { + el.agentCreateForm.addEventListener("submit", createEnrollment); + } + + if (el.agentEnrollmentCopy) { + el.agentEnrollmentCopy.addEventListener("click", copyToken); + } + + if (el.agentEnrollmentDismiss) { + el.agentEnrollmentDismiss.addEventListener( + "click", + dismissToken + ); + } + + refreshData(true); +}()); diff --git a/backend/app/frontend/static/app.css b/backend/app/frontend/static/app.css index d405bb4..365f5e4 100644 --- a/backend/app/frontend/static/app.css +++ b/backend/app/frontend/static/app.css @@ -1771,3 +1771,86 @@ button.map-position:hover { flex-direction: column; } } + +/* Agent Management v1 */ + +.agent-summary-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + margin-bottom: 20px; +} + +.agent-summary-card { + display: grid; + gap: 6px; +} + +.agent-summary-card span { + font-size: 0.85rem; +} + +.agent-summary-card strong { + font-size: 2rem; +} + +.agent-management-columns { + display: grid; + gap: 20px; + margin-bottom: 20px; +} + +.agent-table td:first-child { + display: grid; + gap: 4px; +} + +.agent-create-form { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.agent-create-form .agent-form-actions, +.agent-create-form #agentCreateStatus { + grid-column: 1 / -1; +} + +.agent-form-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; +} + +.agent-enrollment-result { + margin-top: 20px; + padding-top: 20px; + border-top: 1px solid var(--line); +} + +.agent-token-box { + overflow-wrap: anywhere; + padding: 14px; + margin: 12px 0; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface-muted); +} + +.agent-token-box code { + user-select: all; +} + +@media (max-width: 760px) { + .agent-summary-grid, + .agent-create-form { + grid-template-columns: 1fr; + } +} + +/* Agent Management action visibility */ +.agent-management-columns .quiet-button, +.agent-enrollment-panel .quiet-button { + color: var(--ink); +} diff --git a/backend/app/frontend/views.py b/backend/app/frontend/views.py index c5908bc..7b63db3 100644 --- a/backend/app/frontend/views.py +++ b/backend/app/frontend/views.py @@ -6,6 +6,7 @@ from typing import Callable from app.frontend.miner_detail_view import miner_detail_content from app.frontend.monitoring_trends_view import monitoring_trends_content from app.frontend.system_health_view import system_health_content +from app.frontend.agent_management_view import agent_management_content NAVIGATION = ( @@ -15,6 +16,7 @@ NAVIGATION = ( ("repairs", "Repair Center", "/operations/repairs", "repairs.view"), ("miners", "Miners", "/assets/miners", "miners.view"), ("system_health", "System Health", "/system-health", "system_health.view"), + ("agent_management", "Agents", "/agent/management", "agents.view"), ) PAGE_DETAILS = { @@ -53,6 +55,11 @@ PAGE_DETAILS = { "System Health", "Inspect application services, operational data, and maintenance facts.", ), + "agent_management": ( + "Administration / Agents", + "Agent Management", + "Inspect Agent identities, routing boundaries, and secure enrollment.", + ), } @@ -462,7 +469,9 @@ def render_app_page( else '' ) content = ( - system_health_content() + agent_management_content(has_permission("agents.manage")) + if view == "agent_management" + else system_health_content() if view == "system_health" else miner_detail_content() if view == "miner_detail" diff --git a/backend/app/identity.py b/backend/app/identity.py index 10a687d..9a16d28 100644 --- a/backend/app/identity.py +++ b/backend/app/identity.py @@ -779,6 +779,8 @@ def required_api_permission(method: str, path: str) -> str | None: return "repairs.read" if component_request(path): return "components.create" if method == "POST" else "components.read" + if path == "/api/agents": + return "agents.view" if path == "/api/agent-zones": return "agents.view" if method == "GET" else "agents.manage" if path == "/api/agent-enrollments": diff --git a/backend/app/main.py b/backend/app/main.py index 509d8f8..ef76a33 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -13,6 +13,7 @@ from pydantic import BaseModel, ConfigDict, EmailStr, Field, ValidationError from app.config import settings from app.agent_commands.dependencies import require_agent_identity from app.agent_commands.router import router as agent_commands_router +from app.agent_registry.router import router as agent_registry_router from app.agent_zones.dependencies import get_agent_zone_service from app.agent_zones.models import MinerZoneAssociationResult from app.agent_zones.service import AgentZoneService @@ -73,6 +74,7 @@ app.include_router(frontend_router) app.include_router(inventory_router) app.include_router(miner_control_router) app.include_router(agent_commands_router) +app.include_router(agent_registry_router) app.include_router(agent_zones_router) app.include_router(agent_enrollment_router) app.include_router(components_router) diff --git a/backend/tests/test_agent_management_frontend.py b/backend/tests/test_agent_management_frontend.py new file mode 100644 index 0000000..816c765 --- /dev/null +++ b/backend/tests/test_agent_management_frontend.py @@ -0,0 +1,141 @@ +"""Frontend contract tests for Agent Management.""" + +import unittest +from pathlib import Path + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.frontend.router import router +from app.identity import get_current_user, required_page_permission + + +STATIC_ROOT = Path(__file__).parents[1] / "app" / "frontend" / "static" + + +class AgentManagementFrontendTests(unittest.TestCase): + def setUp(self): + self.app = FastAPI() + self.app.include_router(router) + self.client = TestClient(self.app) + + def authenticate(self, permissions): + self.app.dependency_overrides[get_current_user] = lambda: { + "id": "00000000-0000-4000-8000-000000009001", + "username": "agent-test-user", + "display_name": "Agent Test User", + "role": "Test Role", + "permissions": tuple(permissions), + } + + def test_page_requires_agents_view(self): + self.authenticate(("unrelated.permission",)) + self.assertEqual( + self.client.get("/agent/management").status_code, + 403, + ) + + self.authenticate(("agents.view",)) + response = self.client.get("/agent/management") + + self.assertEqual(response.status_code, 200) + self.assertIn("Agent Management", response.text) + self.assertIn('href="/agent/management"', response.text) + + def test_manage_controls_follow_agents_manage_permission(self): + self.authenticate(("agents.view",)) + + restricted = self.client.get("/agent/management").text + + self.assertNotIn('id="agentCreateForm"', restricted) + restricted_text = " ".join(restricted.split()) + self.assertIn( + "cannot create Agent enrollments.", + restricted_text, + ) + + self.authenticate(("agents.view", "agents.manage")) + + managed = self.client.get("/agent/management").text + + self.assertIn('id="agentCreateForm"', managed) + self.assertIn('id="agentSite"', managed) + self.assertIn('id="agentZone"', managed) + self.assertIn('id="agentDisplayName"', managed) + self.assertIn("Linux x64 — coming later", managed) + self.assertIn("Linux ARM64 / Raspberry Pi — coming later", managed) + + def test_route_and_asset_contract(self): + self.assertEqual( + required_page_permission("/agent/management"), + "agents.view", + ) + + self.authenticate(("agents.view",)) + + page = self.client.get("/agent/management") + self.assertIn( + "/assets/minerops-agent-management.js", + page.text, + ) + + asset = self.client.get( + "/assets/minerops-agent-management.js" + ) + + self.assertEqual(asset.status_code, 200) + self.assertEqual( + asset.headers.get("cache-control"), + "no-store", + ) + + def test_javascript_is_secret_conscious_and_safe_dom_only(self): + source = ( + STATIC_ROOT / "agent-management.js" + ).read_text(encoding="utf-8") + + self.assertIn('api("/api/agents")', source) + self.assertIn('api("/api/agent-enrollments")', source) + self.assertIn( + 'api("/api/agent-enrollments", {', + source, + ) + + for unsafe in ( + "innerHTML", + "outerHTML", + "insertAdjacentHTML", + "localStorage", + "sessionStorage", + "console.log", + ): + self.assertNotIn(unsafe, source) + + self.assertIn( + "el.agentEnrollmentToken.textContent", + source, + ) + self.assertIn( + "el.agentEnrollmentToken.textContent = \"\"", + source, + ) + + def test_agent_management_quiet_actions_have_visible_text(self): + css = (STATIC_ROOT / "app.css").read_text(encoding="utf-8") + + self.assertIn( + ".agent-management-columns .quiet-button,", + css, + ) + self.assertIn( + ".agent-enrollment-panel .quiet-button {", + css, + ) + self.assertIn( + "color: var(--ink);", + css, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_agent_registry.py b/backend/tests/test_agent_registry.py new file mode 100644 index 0000000..86eaede --- /dev/null +++ b/backend/tests/test_agent_registry.py @@ -0,0 +1,120 @@ +"""Focused tests for the Agent Management read model.""" + +import unittest +from uuid import UUID + +from app.agent_registry.service import AgentRegistryService +from app.identity import required_api_permission + + +SITE_ID = UUID("00000000-0000-4000-8000-000000001800") +ZONE_ID = UUID("00000000-0000-4000-8000-000000001801") + + +class FakeAgentRegistryRepository: + def list_registry(self): + return { + "sites": [ + { + "id": SITE_ID, + "name": "Test Site", + } + ], + "zones": [ + { + "id": ZONE_ID, + "site_id": SITE_ID, + "site_name": "Test Site", + "name": "Network A", + "description": None, + "is_enabled": True, + "assigned_agent_id": "agent-test", + "networks": ["10.50.0.0/24"], + } + ], + "agents": [ + { + "agent_id": "agent-test", + "display_name": "Test Agent", + "site_id": SITE_ID, + "site_name": "Test Site", + "agent_zone_id": ZONE_ID, + "agent_zone_name": "Network A", + "agent_zone_enabled": True, + "hostname": "test-agent", + "version": "1.0.0-alpha", + "ip_address": "10.50.0.10", + "last_checkin_at": "2026-08-26T20:00:00+00:00", + "credential_state": "active", + "credential_created_at": "2026-08-26T19:00:00+00:00", + "credential_revoked_at": None, + } + ], + } + + +class AgentRegistryServiceTests(unittest.TestCase): + def test_projection_is_factual_and_zone_aware(self): + data = AgentRegistryService( + FakeAgentRegistryRepository() + ).list_registry() + + self.assertEqual(len(data.agents), 1) + self.assertEqual(len(data.sites), 1) + self.assertEqual(len(data.zones), 1) + + agent = data.agents[0] + self.assertEqual(agent.agent_id, "agent-test") + self.assertEqual(agent.credential_state, "active") + self.assertEqual(agent.routing_state, "assigned") + self.assertEqual( + data.zones[0].networks, + ["10.50.0.0/24"], + ) + + def test_unassigned_and_disabled_routing_are_not_fabricated_online_states(self): + repository = FakeAgentRegistryRepository() + + repository.list_registry = lambda: { + "sites": [], + "zones": [], + "agents": [ + { + "agent_id": "legacy", + "display_name": None, + "site_id": None, + "site_name": "Legacy Site", + "agent_zone_id": None, + "agent_zone_name": None, + "agent_zone_enabled": None, + "hostname": None, + "version": None, + "ip_address": None, + "last_checkin_at": "2026-08-26T20:00:00+00:00", + "credential_state": "legacy", + "credential_created_at": None, + "credential_revoked_at": None, + } + ], + } + + data = AgentRegistryService(repository).list_registry() + + self.assertEqual( + data.agents[0].routing_state, + "unassigned", + ) + self.assertEqual( + data.agents[0].credential_state, + "legacy", + ) + + def test_api_permission_mapping_is_explicit(self): + self.assertEqual( + required_api_permission("GET", "/api/agents"), + "agents.view", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_agent_registry_postgres.py b/backend/tests/test_agent_registry_postgres.py new file mode 100644 index 0000000..7f7ac06 --- /dev/null +++ b/backend/tests/test_agent_registry_postgres.py @@ -0,0 +1,194 @@ +"""PostgreSQL integration test for the Agent registry projection.""" + +import unittest +from uuid import uuid4 + +from app.agent_registry.repository import ( + AgentRegistryRepository, + default_connection_factory, +) +from app.agent_registry.service import AgentRegistryService + + +class AgentRegistryPostgresTests(unittest.TestCase): + def setUp(self): + self.site_id = uuid4() + self.zone_id = uuid4() + self.agent_id = f"agent-registry-{uuid4().hex}" + + connection = default_connection_factory() + + try: + with connection.cursor() as cur: + cur.execute( + """ + INSERT INTO sites (id, name) + VALUES (%s, %s) + """, + ( + self.site_id, + "Registry Integration Site", + ), + ) + + cur.execute( + """ + INSERT INTO agent_zones ( + id, + site_id, + name, + description, + is_enabled + ) + VALUES (%s, %s, %s, %s, true) + """, + ( + self.zone_id, + self.site_id, + "Registry Network", + "Integration-test network", + ), + ) + + cur.execute( + """ + INSERT INTO agent_zone_network_ranges ( + agent_zone_id, + network + ) + VALUES (%s, '10.251.0.0/24'::cidr) + """, + (self.zone_id,), + ) + + cur.execute( + """ + INSERT INTO minerops_site_agents ( + agent_id, + site_name, + site_id, + agent_zone_id, + display_name, + hostname, + version, + ip_address, + agent_secret_hash, + credential_created_at + ) + VALUES ( + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + now() + ) + """, + ( + self.agent_id, + "Registry Integration Site", + self.site_id, + self.zone_id, + "Registry Test Agent", + "registry-agent", + "1.0.0-alpha", + "10.251.0.10", + "a" * 64, + ), + ) + + connection.commit() + finally: + connection.close() + + def tearDown(self): + connection = default_connection_factory() + + try: + with connection.cursor() as cur: + cur.execute( + """ + DELETE FROM minerops_site_agents + WHERE agent_id = %s + """, + (self.agent_id,), + ) + + cur.execute( + """ + DELETE FROM agent_zone_network_ranges + WHERE agent_zone_id = %s + """, + (self.zone_id,), + ) + + cur.execute( + """ + DELETE FROM agent_zones + WHERE id = %s + """, + (self.zone_id,), + ) + + cur.execute( + """ + DELETE FROM sites + WHERE id = %s + """, + (self.site_id,), + ) + + connection.commit() + finally: + connection.close() + + def test_registry_joins_site_zone_network_and_redacts_secret_hash(self): + data = AgentRegistryService( + AgentRegistryRepository() + ).list_registry() + + agents = [ + item + for item in data.agents + if item.agent_id == self.agent_id + ] + + self.assertEqual(len(agents), 1) + + agent = agents[0] + + self.assertEqual( + agent.site_name, + "Registry Integration Site", + ) + self.assertEqual( + agent.agent_zone_name, + "Registry Network", + ) + self.assertEqual(agent.credential_state, "active") + self.assertEqual(agent.routing_state, "assigned") + + zones = [ + zone + for zone in data.zones + if zone.id == self.zone_id + ] + + self.assertEqual(len(zones), 1) + self.assertEqual( + zones[0].networks, + ["10.251.0.0/24"], + ) + + serialized = data.model_dump_json() + + self.assertNotIn("agent_secret_hash", serialized) + self.assertNotIn("a" * 64, serialized) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test-agent-management.sh b/scripts/test-agent-management.sh new file mode 100755 index 0000000..d7cb929 --- /dev/null +++ b/scripts/test-agent-management.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${repo_root}" + +project_name="${MINEROPS_AGENT_MANAGEMENT_TEST_PROJECT:-minerops-agent-management-test-$$}" + +compose=( + docker compose + --project-name "${project_name}" + --env-file .env.dev + -f compose.dev.yaml +) + +cleanup() { + status=$? + + if (( status != 0 )); then + "${compose[@]}" logs \ + --no-color \ + --tail 120 \ + postgres || true + fi + + "${compose[@]}" down \ + --volumes \ + --remove-orphans \ + >/dev/null 2>&1 || true + + exit "${status}" +} + +trap cleanup EXIT + +[[ -f .env.dev ]] || { + echo "Missing .env.dev" >&2 + exit 1 +} + +for source_file in \ + backend/app/agent_registry/__init__.py \ + backend/app/agent_registry/models.py \ + backend/app/agent_registry/repository.py \ + backend/app/agent_registry/service.py \ + backend/app/agent_registry/dependencies.py \ + backend/app/agent_registry/router.py \ + backend/tests/test_agent_registry.py \ + backend/tests/test_agent_registry_postgres.py \ + backend/tests/test_agent_management_frontend.py \ + backend/app/frontend/agent_management_view.py \ + backend/app/frontend/static/agent-management.js +do + [[ -r "${source_file}" ]] || { + echo "Missing ${source_file}" >&2 + exit 1 + } +done + +"${compose[@]}" up --detach postgres + +for attempt in $(seq 1 60); do + if "${compose[@]}" exec -T postgres sh -ec \ + 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \ + >/dev/null 2>&1 + then + break + fi + + (( attempt < 60 )) || { + echo "PostgreSQL did not become ready" >&2 + exit 1 + } + + sleep 1 +done + +"${compose[@]}" build backend + +"${compose[@]}" run --rm --no-deps \ + backend alembic upgrade head + +"${compose[@]}" run --rm --no-deps \ + -v "${repo_root}/backend/tests:/app/tests:ro" \ + backend python -m unittest -v \ + tests.test_agent_registry \ + tests.test_agent_registry_postgres \ + tests.test_agent_management_frontend + +echo "Agent Management tests passed."