diff --git a/docs/SCALABILITY_PROFILES.md b/docs/SCALABILITY_PROFILES.md index 4cef3be..f4aed8c 100644 --- a/docs/SCALABILITY_PROFILES.md +++ b/docs/SCALABILITY_PROFILES.md @@ -49,6 +49,18 @@ The Ops API reports: - rendered PostgreSQL connection peak, declared server limit, and operator reserve - recovery operation status, mode, checkpoint count, and last update +The separately authorized +`GET /api/v1/ops/infrastructure/dependencies` endpoint collects current, +module-owned infrastructure dependencies through the Core provider contract. +It is intended for the host deployer's destructive-change preflight, not for a +general data export. Results contain stable configuration references, lifecycle +states, scopes, numeric counts and required preparation actions; they never +contain credentials, tenant identifiers, file keys or secret-bearing endpoint +values. Ops itself contributes the current database, Redis coordination, +ingress, and load-balancing runtime bindings; feature modules contribute their +own persisted configuration and data. One unavailable or invalid provider makes the result incomplete, so a +caller must fail closed rather than treating missing provider data as zero. + These values are intentionally diagnostic. They do not replace deployment configuration management, backups, monitoring, or restore drills. diff --git a/package.json b/package.json index 3a9ba6b..20f00ae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/ops-webui", - "version": "0.1.20", + "version": "0.1.21", "private": true, "type": "module", "main": "webui/src/index.ts", diff --git a/pyproject.toml b/pyproject.toml index 2b24e21..bd6d725 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-ops" -version = "0.1.20" +version = "0.1.21" description = "GovOPlaN operations module for health, deployment profile, and sizing visibility." readme = "README.md" requires-python = ">=3.12" authors = [{ name = "GovOPlaN" }] dependencies = [ - "govoplan-core>=0.1.18", + "govoplan-core>=0.1.42", "govoplan-access>=0.1.18", ] diff --git a/src/govoplan_ops/__init__.py b/src/govoplan_ops/__init__.py index af9c50b..0061df3 100644 --- a/src/govoplan_ops/__init__.py +++ b/src/govoplan_ops/__init__.py @@ -2,4 +2,4 @@ __all__ = ["__version__"] -__version__ = "0.1.20" +__version__ = "0.1.21" diff --git a/src/govoplan_ops/backend/api/v1/routes.py b/src/govoplan_ops/backend/api/v1/routes.py index 3f3639d..b6a2c99 100644 --- a/src/govoplan_ops/backend/api/v1/routes.py +++ b/src/govoplan_ops/backend/api/v1/routes.py @@ -47,7 +47,10 @@ from govoplan_core.db.session import get_database from govoplan_core.settings import settings as core_settings from govoplan_ops.backend.manifest import OPS_READ_SCOPES, OPS_RUN_SCOPES -from govoplan_ops.backend.infrastructure import deployment_capability_status +from govoplan_ops.backend.infrastructure import ( + deployment_capability_status, + infrastructure_dependency_inventory, +) router = APIRouter(prefix="/ops", tags=["ops"]) _module_check_cache: dict[str, tuple[float, dict[str, Any]]] = {} @@ -93,6 +96,20 @@ def run_ops_checks( return _ops_status_payload(request, force_module_checks=True) +@router.get("/infrastructure/dependencies") +def infrastructure_dependencies( + request: Request, + principal: ApiPrincipal = Depends(require_any_scope(*OPS_READ_SCOPES)), +) -> dict[str, object]: + """Return a fresh, non-secret inventory for host deployment preflight.""" + + del principal + return infrastructure_dependency_inventory( + _registry(request), + installation_id=core_settings.installation_id, + ).to_dict() + + @router.get("/runtime/nodes") def runtime_nodes( request: Request, diff --git a/src/govoplan_ops/backend/infrastructure.py b/src/govoplan_ops/backend/infrastructure.py index 87a7060..139a888 100644 --- a/src/govoplan_ops/backend/infrastructure.py +++ b/src/govoplan_ops/backend/infrastructure.py @@ -3,8 +3,96 @@ from __future__ import annotations from pathlib import Path from govoplan_core.core.infrastructure_capabilities import ( + INFRASTRUCTURE_DEPENDENCY_PROVIDER_CAPABILITY_PREFIX, + InfrastructureDependency, + InfrastructureDependencyInventory, + InfrastructureDependencyProvider, + collect_infrastructure_dependency_inventory, deployment_capability_status as _deployment_capability_status, + load_infrastructure_capability_receipt, ) +from govoplan_core.settings import settings as core_settings + + +OPS_INFRASTRUCTURE_DEPENDENCY_CAPABILITY = ( + f"{INFRASTRUCTURE_DEPENDENCY_PROVIDER_CAPABILITY_PREFIX}ops" +) + + +class OpsInfrastructureDependencyProvider(InfrastructureDependencyProvider): + """Report Core runtime bindings that optional feature providers do not own.""" + + module_id = "ops" + capability_ids = ( + "database.postgresql", + "coordination.redis", + "network.ingress", + "runtime.load_balancing", + ) + + def infrastructure_dependencies(self) -> tuple[InfrastructureDependency, ...]: + dependencies = [ + InfrastructureDependency( + capability_id="database.postgresql", + module_id=self.module_id, + dependency_type="application_state_binding", + dependency_ref="runtime:database.postgresql", + state="runtime_binding", + scope="system", + summary=( + "GovOPlaN persists application and control-plane state in the active PostgreSQL database." + ), + metrics={}, + required_action=( + "Create and restore-verify a coordinated database backup before rebinding PostgreSQL." + ), + ), + InfrastructureDependency( + capability_id="network.ingress", + module_id=self.module_id, + dependency_type="public_runtime_binding", + dependency_ref="runtime:network.ingress", + state="runtime_binding", + scope="system", + summary="Ops and application APIs are reached through the active ingress boundary.", + metrics={}, + required_action=( + "Verify trusted proxy headers, TLS, health, and public reachability on the replacement ingress." + ), + ), + InfrastructureDependency( + capability_id="runtime.load_balancing", + module_id=self.module_id, + dependency_type="runtime_routing_binding", + dependency_ref="runtime:load-balancing", + state="runtime_binding", + scope="system", + summary="API and WebUI replicas are reached through the runtime load balancer.", + metrics={}, + required_action=( + "Verify health-aware API and WebUI routing before replacing the load balancer." + ), + ), + ] + if core_settings.redis_url.strip(): + dependencies.append( + InfrastructureDependency( + capability_id="coordination.redis", + module_id=self.module_id, + dependency_type="coordination_runtime_binding", + dependency_ref="runtime:coordination.redis", + state="runtime_binding", + scope="system", + summary=( + "The active runtime uses Redis for queues or shared coordination." + ), + metrics={"celery_enabled": int(core_settings.celery_enabled)}, + required_action=( + "Drain queued and in-flight work, verify idempotent recovery, and provision replacement coordination before rebinding or disabling Redis." + ), + ) + ) + return tuple(dependencies) def deployment_capability_status( @@ -15,4 +103,31 @@ def deployment_capability_status( return _deployment_capability_status(path) -__all__ = ["deployment_capability_status"] +def infrastructure_dependency_inventory( + registry: object, + *, + installation_id: str, +) -> InfrastructureDependencyInventory: + """Collect module-owned persisted dependencies for an authorized host plan.""" + + receipt = load_infrastructure_capability_receipt() + if receipt is None: + raise ValueError( + "Infrastructure dependency inventory requires a mounted deployment receipt." + ) + if receipt.installation_id != installation_id: + raise ValueError( + "Mounted deployment receipt belongs to a different installation." + ) + return collect_infrastructure_dependency_inventory( + registry, + installation_id=installation_id, + ) + + +__all__ = [ + "deployment_capability_status", + "infrastructure_dependency_inventory", + "OPS_INFRASTRUCTURE_DEPENDENCY_CAPABILITY", + "OpsInfrastructureDependencyProvider", +] diff --git a/src/govoplan_ops/backend/manifest.py b/src/govoplan_ops/backend/manifest.py index c9f93ad..245699a 100644 --- a/src/govoplan_ops/backend/manifest.py +++ b/src/govoplan_ops/backend/manifest.py @@ -2,6 +2,10 @@ from __future__ import annotations from govoplan_core.core.modules import with_documentation_structured_translations from govoplan_ops.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS +from govoplan_ops.backend.infrastructure import ( + OPS_INFRASTRUCTURE_DEPENDENCY_CAPABILITY, + OpsInfrastructureDependencyProvider, +) from govoplan_core.core.access import ( CAPABILITY_AUTH_PERMISSION_EVALUATOR, @@ -9,6 +13,7 @@ from govoplan_core.core.access import ( ) from govoplan_core.core.modules import ( DocumentationCondition, + DocumentationLink, DocumentationTopic, FrontendModule, FrontendRoute, @@ -95,7 +100,7 @@ def _route_factory(context: ModuleContext): manifest = ModuleManifest( id="ops", name="Ops", - version="0.1.20", + version="0.1.21", required_capabilities=( CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR, @@ -137,15 +142,32 @@ manifest = ModuleManifest( cache_seconds=15, ), ), + capability_factories={ + OPS_INFRASTRUCTURE_DEPENDENCY_CAPABILITY: ( + lambda context: OpsInfrastructureDependencyProvider() + ), + }, documentation=( DocumentationTopic( id="ops.health-governance-and-sizing", title="Inspect platform health and deployment posture", summary="Ops combines module-owned health checks with deployment profile, governance inventory, worker assumptions, and sizing guidance.", - body="Read-only status distinguishes configured capabilities from healthy integrations. Worker and queue providers use a Core runtime-status contract, so Ops never imports a provider backend. The surface distinguishes intentionally disabled, unconfigured, starting, healthy with unsupported queue depth, measured idle, busy, degraded, stale, and unreachable states. It shows enabled/configured state, backend, workers, heartbeat age and stale threshold, queue depth, active/reserved work, and failures only when each value is actually reported; unavailable values are never rendered as zero or healthy. Local development treats intentionally disabled workers as expected, while production profiles require an enabled, configured, reachable provider before queue-backed work is accepted. Polling is bounded to one request, pauses while the page is hidden, and refreshes on return. When the deployment mounts a signed or locally generated non-secret infrastructure capability receipt, Ops shows whether PostgreSQL, Redis, SMTP, file storage, load balancing, and ingress are configured, externally supplied, available but unconfigured, or unavailable. Secret values never cross this boundary; only stable environment or credential-envelope references may be disclosed. Pending post-install tasks remain visible with a stable resume key. Authorized operators can run bounded probes; a probe must not perform unbounded business work or silently repair data. Use readiness and worker results when diagnosing a node, and use the deployment profile and sizing assumptions when planning horizontal capacity.", + body="Read-only status distinguishes configured capabilities from healthy integrations. Worker and queue providers use a Core runtime-status contract, so Ops never imports a provider backend. The surface distinguishes intentionally disabled, unconfigured, starting, healthy with unsupported queue depth, measured idle, busy, degraded, stale, and unreachable states. It shows enabled/configured state, backend, workers, heartbeat age and stale threshold, queue depth, active/reserved work, and failures only when each value is actually reported; unavailable values are never rendered as zero or healthy. Local development treats intentionally disabled workers as expected, while production profiles require an enabled, configured, reachable provider before queue-backed work is accepted. Polling is bounded to one request, pauses while the page is hidden, and refreshes on return. When the deployment mounts a signed or locally generated non-secret infrastructure capability receipt, Ops shows whether PostgreSQL, Redis, SMTP, file storage, load balancing, and ingress are configured, externally supplied, available but unconfigured, or unavailable. Secret values never cross this boundary; only stable environment or credential-envelope references may be disclosed. Pending post-install tasks remain visible with a stable resume key. The separately authorized infrastructure-dependency endpoint asks enabled module providers for their current persisted configuration and data dependencies. Each provider returns only stable references, states, scopes, numeric metrics, and required migration actions; one failed provider marks the entire fresh inventory incomplete so the host deployer blocks a capability change. Authorized operators can run bounded probes; a probe must not perform unbounded business work or silently repair data. Use readiness and worker results when diagnosing a node, and use the deployment profile and sizing assumptions when planning horizontal capacity.", documentation_types=("admin", "user"), audience=("operator", "system_admin"), related_modules=("audit", "docs", "notifications"), + links=( + DocumentationLink( + label="Infrastructure dependency inventory API", + href="/api/v1/ops/infrastructure/dependencies", + kind="api", + ), + DocumentationLink( + label="Operations profile handbook", + href="govoplan-ops/docs/SCALABILITY_PROFILES.md", + kind="repository", + ), + ), translations={ "de": { "title": "Plattformzustand und Bereitstellungsprofil prüfen", @@ -159,7 +181,9 @@ manifest = ModuleManifest( "Die Abfrage bleibt auf eine Anfrage je Intervall begrenzt, pausiert bei ausgeblendeter Seite und wird bei der Rückkehr fortgesetzt. " "Ist ein signierter oder lokal erzeugter Infrastrukturbeleg ohne Geheimwerte eingebunden, zeigt Ops für PostgreSQL, Redis, SMTP, Dateispeicher, Lastverteilung und Ingress, ob die Fähigkeit konfiguriert, extern bereitgestellt, verfügbar aber nicht konfiguriert oder nicht verfügbar ist. " "Geheimwerte überschreiten diese Grenze nie; offengelegt werden dürfen nur stabile Umgebungs- oder Credential-Envelope-Referenzen. " - "Ausstehende Aufgaben nach einer Installation bleiben mit einem stabilen Fortsetzungsschlüssel sichtbar. Autorisierte Betriebsverantwortliche dürfen begrenzte Prüfungen ausführen; eine Prüfung darf weder unbegrenzte Facharbeit auslösen noch Daten stillschweigend reparieren. " + "Ausstehende Aufgaben nach einer Installation bleiben mit einem stabilen Fortsetzungsschlüssel sichtbar. Der separat autorisierte Infrastruktur-Abhängigkeitsendpunkt fragt aktivierte Modulprovider nach ihren aktuellen gespeicherten Konfigurations- und Datenabhängigkeiten. " + "Jeder Provider liefert nur stabile Referenzen, Zustände, Geltungsbereiche, numerische Kennzahlen und erforderliche Migrationsmaßnahmen; schlägt ein Provider fehl, ist das gesamte frische Inventar unvollständig und der Host-Deployer blockiert die Fähigkeitsänderung. " + "Autorisierte Betriebsverantwortliche dürfen begrenzte Prüfungen ausführen; eine Prüfung darf weder unbegrenzte Facharbeit auslösen noch Daten stillschweigend reparieren. " "Nutzen Sie Bereitschafts- und Worker-Ergebnisse zur Diagnose eines Knotens sowie Bereitstellungsprofil und Dimensionierungsannahmen zur Planung horizontaler Kapazität." ), } diff --git a/tests/test_infrastructure_capabilities.py b/tests/test_infrastructure_capabilities.py index e3a6332..d4e946d 100644 --- a/tests/test_infrastructure_capabilities.py +++ b/tests/test_infrastructure_capabilities.py @@ -3,12 +3,73 @@ from __future__ import annotations import json from pathlib import Path import tempfile +from types import SimpleNamespace import unittest +from unittest.mock import patch -from govoplan_ops.backend.infrastructure import deployment_capability_status +from govoplan_core.core.infrastructure_capabilities import InfrastructureDependency +from govoplan_ops.backend.infrastructure import ( + OpsInfrastructureDependencyProvider, + deployment_capability_status, + infrastructure_dependency_inventory, +) + + +class _Provider: + module_id = "mail" + capability_ids = ("mail.smtp",) + + def infrastructure_dependencies(self) -> tuple[InfrastructureDependency, ...]: + return ( + InfrastructureDependency( + capability_id="mail.smtp", + module_id="mail", + dependency_type="smtp_endpoint", + dependency_ref="mail:server-1", + state="active", + scope="system", + summary="Persisted SMTP endpoint.", + metrics={"credential_binding_count": 1}, + required_action="Rebind it before changing SMTP.", + ), + ) + + +class _Registry: + def capability_names(self) -> tuple[str, ...]: + return ("infrastructure.dependency_inventory.mail",) + + def capability(self, name: str) -> object | None: + return _Provider() if name.endswith(".mail") else None class InfrastructureCapabilityTests(unittest.TestCase): + def test_collects_authorized_module_dependency_inventory(self) -> None: + with patch( + "govoplan_ops.backend.infrastructure.load_infrastructure_capability_receipt", + return_value=SimpleNamespace(installation_id="govoplan-test"), + ): + result = infrastructure_dependency_inventory( + _Registry(), + installation_id="govoplan-test", + ).to_dict() + + self.assertTrue(result["complete"]) + self.assertEqual("mail.smtp", result["dependencies"][0]["capability_id"]) + self.assertEqual(1, result["dependencies"][0]["metrics"]["credential_binding_count"]) + + def test_ops_provider_reports_runtime_bindings_without_endpoint_secrets(self) -> None: + dependencies = OpsInfrastructureDependencyProvider().infrastructure_dependencies() + by_capability = {item.capability_id: item for item in dependencies} + + self.assertIn("database.postgresql", by_capability) + self.assertIn("network.ingress", by_capability) + self.assertEqual( + "runtime:database.postgresql", + by_capability["database.postgresql"].dependency_ref, + ) + self.assertNotIn("postgresql://", json.dumps([item.to_dict() for item in dependencies])) + def test_reads_bounded_non_secret_capability_receipt(self) -> None: with tempfile.TemporaryDirectory(prefix="govoplan-ops-capabilities-") as root: path = Path(root) / "capabilities.json" diff --git a/webui/package.json b/webui/package.json index e353cfc..9d4ee5a 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/ops-webui", - "version": "0.1.20", + "version": "0.1.21", "private": true, "type": "module", "main": "src/index.ts",